diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfae646..fd343b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,8 +70,11 @@ jobs: args: check windows-compile: - name: windows-compile + name: windows-preview-checks runs-on: windows-latest + env: + # Use cgofuse's native Go Windows loader, matching the preview build. + CGO_ENABLED: "0" steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 @@ -80,7 +83,16 @@ jobs: cache: true - name: Compile all test packages without claiming runtime validation run: go test ./... -run '^$' -count=1 + - name: Test Windows adapters, worker lifecycle and tray state + run: go test ./internal/tray ./internal/enroll ./internal/cli ./internal/service ./internal/mountfs ./internal/pack ./internal/dirsync ./internal/sessionns ./internal/vfs -run 'TestMonitor|TestPolicy|TestDashboard|TestHistory|TestIncidents|TestUnreadable|TestPeriodicEnrollment|TestEnrollmentWorker|TestPersistentEnrollment|TestWindows|TestFuse|TestPublishedLogicalBytes' -count=1 -timeout=120s + - name: Test the WinFsp frontend and engine handover + run: go test -tags winfsp ./internal/mountfs -run 'TestWindowsCore|TestWindowsFuseErrorMapping|TestFuse' -count=1 -timeout=120s - run: go build ./cmd/codexfold + - name: Build Windows mount and native tray + run: | + go build -tags winfsp -o "$env:RUNNER_TEMP/codexfold-winfsp.exe" ./cmd/codexfold + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go build -ldflags='-H=windowsgui' -o "$env:RUNNER_TEMP/codexfold-tray.exe" ./cmd/codexfold-tray race: name: race diff --git a/.gitignore b/.gitignore index c3254d8..1db1d81 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .DS_Store .worktrees/ /.tmp/ +/.tmp-test-windows-runtime/ # Xcode build products and derived data platform/darwin/fskit/build/ diff --git a/README.md b/README.md index 92f1570..5b818d3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The requirements and release gates for normal JSONL paths backed transparently b |---|---|---|---| | macOS 27 | Apple-native Swift FSKit | Historical signed build 102 real-client and native-mount matrix; current worktree requalification is partial | `fs-engine-preview` | | Linux | FUSE3 | Real unprivileged mount, mutation, remount, recovery, performance, and user-service lifecycle | Preview; real Codex client validation remains incomplete | -| Windows | WinFsp | Cross-build and compile coverage | Not runtime-validated | +| Windows | WinFsp, SCM services and native WebView2 tray | Complete local-copy Codex app-server lifecycle, exact-byte restart, persistent worker and guarded live engine replacement on an isolated mount | Tested preview flows; Desktop GUI, reboot and SYSTEM engine-split qualification remain incomplete | The transparent filesystem preview has these explicit boundaries: @@ -30,11 +30,11 @@ The transparent filesystem preview has these explicit boundaries: - Release source metadata is `0.3.0 (103)`. Build 103 compiles and passes nested signature verification, while the completed mounted and real-client matrix remains historical build 102 evidence. Changes in the current worktree require their own candidate attachment, real-client workload, restart, fault-injection, and recovery evidence. - The earlier synchronous FUSE-T NFS route remains historical validation evidence and a development fallback only. FUSE-T's third-party FSKit backend remains rejected after deterministic byte-loss and cache-invalidation failures; it is not the Apple-native FSKit implementation in this repository. - Linux FUSE3 has real unprivileged read, append, copy-on-write, truncate, archive rename, crash recovery, remount, performance, and `systemd --user` lifecycle evidence. -- Windows has a WinFsp adapter and native Windows Service host that cross-compile, but no real Windows/WinFsp host has validated them yet. -- The production service and production Codex home remain disabled by default. The current worktree's isolated flow has observed a real Cockpit **Start**, an isolated Desktop/app-server process chain, and an unchanged production Codex process. It has not yet attached the current CodexFold candidate, observed a real Desktop task mutation through that managed route, or completed candidate-only fault injection and recovery; `codexInstanceAcceptanceComplete`, `codexFoldCandidateAcceptanceComplete`, and `realAcceptanceComplete` therefore remain false. Promotion is blocked by that exact evidence matrix, not by a fixed observation period. +- This Windows branch has passed complete local-copy tests with Codex 0.159.2 app-server: resume, history reads, recorder writes, fork, archive/unarchive of a compressed thread, and a separate managed deletion with physical purge. SCM stop/start preserves exact bytes and client access. A persistent SCM folding worker supports pause/resume and fresh progress; the native tray adds policy controls, 30-day aggregate history, incident details and JSON diagnostics. An isolated real WinFsp mount passed storage-engine replacement and failed-candidate rollback while preserving the frontend PID and mount nonce. A legacy installation needs one offline upgrade to enable that split. The local legacy namespace has been activated; the full Desktop GUI, reboot/power loss and SYSTEM engine-split qualification remain unverified. See the [Windows preview guide](docs/windows-preview.md). +- The macOS production service and production Codex home remain disabled by default. The current worktree's isolated macOS flow has observed a real Cockpit **Start**, an isolated Desktop/app-server process chain, and an unchanged production Codex process. It has not yet attached the current CodexFold candidate, observed a real Desktop task mutation through that managed route, or completed candidate-only fault injection and recovery; `codexInstanceAcceptanceComplete`, `codexFoldCandidateAcceptanceComplete`, and `realAcceptanceComplete` therefore remain false. Promotion is blocked by that exact evidence matrix, not by a fixed observation period. - CodexFold recovery operates only on CodexFold-owned components. It never quits, restarts, signals, or reopens a running Codex process. On macOS the backend, supervisor, and incident helper are resident; the menu-bar App is restarted after a crash but an explicit user Quit remains a quit. - The native macOS menu-bar App shows current health, measured space saved, current read/write speed, compact trends, selectable 1-hour/24-hour/7-day/30-day history, and the timeline of failures that lasted at least ten seconds. A standalone menu-bar launch that has never observed or registered the file-service runtime shows an unconnected state instead of reporting a critical incident. History stores only timestamps, health, counts, byte totals, and aggregate transfer rates, never session content. -- The current worktree validation has not installed, updated, stopped, restarted, or signaled the production CodexFold service, FSKit App/extension, or LaunchAgent set. Any exact production target requires a new explicit user authorization before an apply or lifecycle operation. +- The current macOS worktree validation has not installed, updated, stopped, restarted, or signaled the production CodexFold service, FSKit App/extension, or LaunchAgent set. Any exact production target requires explicit user authorization before an apply or lifecycle operation. See [the Linux FUSE3 validation](docs/validation-linux-fuse3.md) and [the macOS canary validation](docs/validation-macos-canary.md) for the evidence boundary. The default build remains storage-only; platform mounts require explicit build tags and installed host prerequisites. diff --git a/cmd/codexfold-tray/assets/app.manifest b/cmd/codexfold-tray/assets/app.manifest new file mode 100644 index 0000000..382e623 --- /dev/null +++ b/cmd/codexfold-tray/assets/app.manifest @@ -0,0 +1,17 @@ + + + + CodexFold + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + + + diff --git a/cmd/codexfold-tray/assets/codexfold-attention.ico b/cmd/codexfold-tray/assets/codexfold-attention.ico new file mode 100644 index 0000000..a27be91 Binary files /dev/null and b/cmd/codexfold-tray/assets/codexfold-attention.ico differ diff --git a/cmd/codexfold-tray/assets/codexfold.ico b/cmd/codexfold-tray/assets/codexfold.ico new file mode 100644 index 0000000..993278b Binary files /dev/null and b/cmd/codexfold-tray/assets/codexfold.ico differ diff --git a/cmd/codexfold-tray/assets/generate.go b/cmd/codexfold-tray/assets/generate.go new file mode 100644 index 0000000..130dc04 --- /dev/null +++ b/cmd/codexfold-tray/assets/generate.go @@ -0,0 +1,100 @@ +//go:build ignore + +// Draw the Windows storage glyph at multiple resolutions without external assets. +// Run from cmd/codexfold-tray: go run assets/generate.go +package main + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "math" + "os" +) + +func rounded(x, y, left, top, right, bottom, radius float64) bool { + dx := math.Max(math.Max(left+radius-x, 0), x-(right-radius)) + dy := math.Max(math.Max(top+radius-y, 0), y-(bottom-radius)) + return x >= left && x <= right && y >= top && y <= bottom && dx*dx+dy*dy <= radius*radius +} + +func glyph(size int, warning bool) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + const samples = 4 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + var r, g, b, a float64 + for sy := 0; sy < samples; sy++ { + for sx := 0; sx < samples; sx++ { + px := (float64(x) + (float64(sx)+.5)/samples) * 64 / float64(size) + py := (float64(y) + (float64(sy)+.5)/samples) * 64 / float64(size) + c := color.NRGBA{} + if rounded(px, py, 2, 2, 62, 62, 14) { + c = color.NRGBA{uint8(66 - py*.13), uint8(125 - py*.2), uint8(201 - py*.15), 255} + } + // Stacked sheets flowing into a compact storage drive. + if rounded(px, py, 19, 15, 45, 18, 1.5) || rounded(px, py, 16, 22, 48, 25, 1.5) || rounded(px, py, 12, 30, 52, 49, 4) { + c = color.NRGBA{248, 251, 255, 255} + } + if rounded(px, py, 18, 38, 35, 41, 1.5) || math.Hypot(px-44, py-39.5) <= 1.8 { + c = color.NRGBA{58, 112, 185, 255} + } + if warning && math.Hypot(px-51, py-51) <= 10 { + c = color.NRGBA{255, 255, 255, 255} + if math.Hypot(px-51, py-51) <= 8 { + c = color.NRGBA{208, 142, 39, 255} + } + } + r += float64(c.R) * float64(c.A) / 255 + g += float64(c.G) * float64(c.A) / 255 + b += float64(c.B) * float64(c.A) / 255 + a += float64(c.A) + } + } + if a > 0 { + img.SetNRGBA(x, y, color.NRGBA{uint8(r * 255 / a), uint8(g * 255 / a), uint8(b * 255 / a), uint8(a / (samples * samples))}) + } + } + } + return img +} + +func writeIcon(name string, warning bool) { + sizes := []int{16, 20, 24, 32, 40, 48, 64, 128, 256} + var frames [][]byte + for _, size := range sizes { + var frame bytes.Buffer + if err := png.Encode(&frame, glyph(size, warning)); err != nil { + panic(err) + } + frames = append(frames, frame.Bytes()) + } + var output bytes.Buffer + write := func(value any) { + if err := binary.Write(&output, binary.LittleEndian, value); err != nil { + panic(err) + } + } + write(uint16(0)) + write(uint16(1)) + write(uint16(len(sizes))) + offset := 6 + 16*len(sizes) + for i, size := range sizes { + write([4]byte{byte(size % 256), byte(size % 256), 0, 0}) + write(uint16(1)) + write(uint16(32)) + write(uint32(len(frames[i]))) + write(uint32(offset)) + offset += len(frames[i]) + } + for _, frame := range frames { + output.Write(frame) + } + if err := os.WriteFile("assets/"+name+".ico", output.Bytes(), 0644); err != nil { + panic(err) + } +} + +func main() { writeIcon("codexfold", false); writeIcon("codexfold-attention", true) } diff --git a/cmd/codexfold-tray/main_other.go b/cmd/codexfold-tray/main_other.go new file mode 100644 index 0000000..8700203 --- /dev/null +++ b/cmd/codexfold-tray/main_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +import "fmt" + +func main() { fmt.Println("The CodexFold tray companion is available on Windows.") } diff --git a/cmd/codexfold-tray/main_windows.go b/cmd/codexfold-tray/main_windows.go new file mode 100644 index 0000000..06460bd --- /dev/null +++ b/cmd/codexfold-tray/main_windows.go @@ -0,0 +1,61 @@ +//go:build windows + +package main + +//go:generate go run assets/generate.go +//go:generate go run github.com/akavel/rsrc@v0.10.2 -manifest assets/app.manifest -ico assets/codexfold.ico,assets/codexfold-attention.ico -arch amd64 -o rsrc_windows_amd64.syso +//go:generate go run github.com/akavel/rsrc@v0.10.2 -manifest assets/app.manifest -ico assets/codexfold.ico,assets/codexfold-attention.ico -arch arm64 -o rsrc_windows_arm64.syso + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/tray" +) + +func main() { + store := flag.String("store", "", "CodexFold store directory; defaults to CODEX_HOME/fold-store") + background := flag.Bool("background", false, "Start in the notification area") + quit := flag.Bool("quit", false, "Close the tray for this store without stopping the filesystem") + diagnostics := flag.String("diagnostics", "", "Export aggregate diagnostics to a JSON file and exit") + flag.Parse() + if *store == "" { + home, err := codex.ResolveHome("") + if err != nil { + tray.ShowError(err) + os.Exit(1) + } + *store = filepath.Join(home, "fold-store") + } + absolute, err := filepath.Abs(*store) + if err == nil && *diagnostics != "" { + output, outputErr := filepath.Abs(*diagnostics) + if outputErr == nil { + monitor := tray.NewMonitor(absolute) + outputErr = tray.ExportDiagnostics(output, monitor.DiagnosticSnapshot(time.Now())) + } + if outputErr != nil { + fmt.Fprintln(os.Stderr, outputErr) + os.Exit(1) + } + return + } + if err == nil && *quit { + if err := tray.RequestExit(absolute); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if err == nil { + err = tray.Run(absolute, *background) + } + if err != nil { + tray.ShowError(fmt.Errorf("CodexFold: %w", err)) + os.Exit(1) + } +} diff --git a/cmd/codexfold-tray/resources_windows_test.go b/cmd/codexfold-tray/resources_windows_test.go new file mode 100644 index 0000000..7431b8c --- /dev/null +++ b/cmd/codexfold-tray/resources_windows_test.go @@ -0,0 +1,32 @@ +//go:build windows + +package main + +import ( + "golang.org/x/sys/windows" + "testing" +) + +func TestWindowsIconResourcesAndDPIManifest(t *testing.T) { + var instance windows.Handle + if err := windows.GetModuleHandleEx(0, nil, &instance); err != nil { + t.Fatal(err) + } + user32 := windows.NewLazySystemDLL("user32.dll") + for _, resource := range []uintptr{2, 12} { + for _, size := range []uintptr{16, 24, 32, 48, 64} { + icon, _, err := user32.NewProc("LoadImageW").Call(uintptr(instance), resource, 1, size, size, 0) + if icon == 0 { + t.Fatalf("resource %d at %d pixels: %v", resource, size, err) + } + user32.NewProc("DestroyIcon").Call(icon) + } + } + if contextProc := user32.NewProc("GetThreadDpiAwarenessContext"); contextProc.Find() == nil { + context, _, _ := contextProc.Call() + equal, _, _ := user32.NewProc("AreDpiAwarenessContextsEqual").Call(context, ^uintptr(3)) + if equal == 0 { + t.Fatal("tray executable does not enable PerMonitorV2 through its manifest") + } + } +} diff --git a/cmd/codexfold-tray/rsrc_windows_amd64.syso b/cmd/codexfold-tray/rsrc_windows_amd64.syso new file mode 100644 index 0000000..c2ec89f Binary files /dev/null and b/cmd/codexfold-tray/rsrc_windows_amd64.syso differ diff --git a/cmd/codexfold-tray/rsrc_windows_arm64.syso b/cmd/codexfold-tray/rsrc_windows_arm64.syso new file mode 100644 index 0000000..09272d4 Binary files /dev/null and b/cmd/codexfold-tray/rsrc_windows_arm64.syso differ diff --git a/docs/windows-preview.md b/docs/windows-preview.md new file mode 100644 index 0000000..266ea07 --- /dev/null +++ b/docs/windows-preview.md @@ -0,0 +1,280 @@ +# Windows preview + +Build and test from PowerShell in the repository root. The filesystem needs +WinFsp, and the tray needs Microsoft WebView2 Runtime. Keep the store on a local +NTFS volume: Windows writer leases rely on hard links. + +```powershell +$env:CGO_ENABLED='0' +go build -tags winfsp -o dist/codexfold.exe ./cmd/codexfold +go build -ldflags='-H=windowsgui' -o dist/codexfold-tray.exe ./cmd/codexfold-tray +./scripts/test-windows-use.ps1 -Mode Mount -KeepRunning +``` + +The Windows preview uses cgofuse's native Go loader. The setting above avoids +a build-time dependency on WinFsp's C SDK headers; WinFsp Runtime remains required +to mount the drive. + +For the isolated native tray smoke check, use Node.js 22 or newer and the same +WebView2 Runtime. It verifies settings, persistent history, material rendering, +window restoration and controls at desktop, narrow and short sizes: + +```powershell +node internal/tray/testdata/windows-smoke.cjs dist/codexfold-tray.exe .tmp/tray-screenshots +``` + +Fixture data and the temporary WebView2 profile stay under the checkout's `.tmp` +directory and are removed after the check. `CODEXFOLD_TEST_TEMP` may select another +temporary directory. Screenshots requested by the command remain for inspection. + +The script creates two synthetic sessions and an independent SQLite database +under `.tmp/windows-use/run-*`. It checks fold/restore hashes, packed storage, +real mounted reads, flushed appends, service termination and remount, rejected +writes while unmounted, and a byte-identical rollback. It keeps one mounted +session and opens the tray for that test store. It does not access the default +Codex home. Each run keeps its original files, restored files and logs. + +To try copies of this computer's existing sessions instead: + +```powershell +./scripts/test-windows-use.ps1 -Mode Mount -RealSessionCopies -KeepRunning +``` + +This reads the default Codex home (or the existing `CODEX_HOME`) without changing +its database, routes or session files. It selects two regular session files +inside that home, each no larger than 64 MiB and unchanged for at least an hour, +then copies them into a new `run-local-*` directory with an independent test +database. Source hashes are checked during copying and after the test. Login +credentials, configuration and the original database are not copied. These test +files still contain real session text; keep the local test directory private. + +Storage figures include retained sources, backups, loose objects and packs. +This test deliberately retains recovery copies, so a smaller encoded data set +does not mean the test directory has reclaimed disk space. + +The active test paths and PID are in `.tmp/windows-use/current.json`. Open the +printed mount directory to inspect the remaining `.jsonl` file. This isolated +namespace exposes managed sessions; it is not a general-purpose writable drive. +The tray shows aggregate health, storage and I/O without reading session text. + +Stop the active test filesystem with: + +```powershell +./scripts/stop-windows-test.ps1 +``` + +Closing or quitting the tray leaves the filesystem running. The stop script +checks the saved process identity before stopping it and retains the data. + +The complete-copy canary also uses the installed, unmodified Codex 0.159.2 +app-server. It has passed resume, history reads, recorder writes, fork, +archive/unarchive (including the compressed managed thread), and deletion of a +separate managed fork with a completed physical purge receipt. SCM installation, +graceful stop/start, unchanged SHA-256 across restart, and client access after +restart have been exercised. The tray receives live health and storage metrics. + +`scripts/windows-model-smoke.cjs` also creates a short synthetic conversation +using the installed client and a real `gpt-6.1-sol` model at medium effort. Its +first reply, continuation after fold/pack/canonical migration, and continuation +in an already-open client after an unexpected filesystem-child exit all passed. +SCM restarted the service automatically; both committed rollout hashes matched +before and after that failure, and a fresh client recovered all three completed +turns. Packed storage verified 386 objects and both manifests with zero issues. +This crash test ran between turns, not during an in-flight write. The test uses +existing ChatGPT authentication through the experimental external-token protocol +in memory; it does not copy login files or transmit copied production history. + +Canonical Windows use requires an SCM-managed local WinFsp drive with an NTFS store. +The SCM host runs a separate filesystem child: WinFsp's FUSE loop has its own +service runner, so hosting it directly inside `svc.Run` prematurely unmounts. +The parent closes its control pipe to request a normal unmount. The global volume +restricts access to the Codex home owner and LocalSystem. Directory junctions and +SQLite normalization preserve the original home paths, including Windows +verbatim drive aliases. Plain directory mounts and foreground network drives +are insufficient for the canonical client namespace. + +To prepare full local use, build these binaries and preview the paths: + +```powershell +go build -tags winfsp -o dist/codexfold.exe ./cmd/codexfold +go build -ldflags='-H=windowsgui' -o dist/codexfold-tray.exe ./cmd/codexfold-tray +go build -o dist/codexfold-home-backup.exe ./scripts/windows-full-fixture.go +./scripts/enable-windows-local.ps1 +``` + +After exiting **all Codex Desktop and CLI clients**, run the following in an +administrator PowerShell window. The script creates a verified session-tree and +consistent SQLite recovery copy without rebasing its original routes, installs +the binaries in Program Files, activates the namespace, preflights native files, +and starts automatic enrollment (one stable session every five minutes, with a +one-hour unchanged window). It retains existing configuration and login files. + +```powershell +./scripts/enable-windows-local.ps1 -Apply +``` + +For the local checkout, double-click `启用正式使用.cmd` after exiting the clients. +It opens the administrator confirmation and runs the same activation script. +If a managed Codex daemon remains, stop it yourself with +`codex app-server daemon stop`, then retry the launcher. It never closes Codex +processes. Installation logs are kept in `%LOCALAPPDATA%\CodexFold\logs`; the +console reports the verified recovery-copy path and stays open for review. + +The default mount is `V:`; use `-MountDrive` to select a different unused drive. +For an alternate home, pass `-CodexHome` consistently to both scripts. The tray +starts with Windows login; the filesystem starts as a Windows service. Reopen +Codex only after the script reports success. + +To restore ordinary directories, exit the clients again and run: + +```powershell +./scripts/restore-windows-local.ps1 -Apply +``` + +The equivalent local entry point is `恢复普通存储.cmd`, also after exiting clients. + +This restores the latest visible bytes of each managed session, stops the +service, removes the canonical links, and disables service/tray autostart. +Recovery copies and the store remain on disk. The recovery copy is intentionally +retained, so full local use does not immediately reclaim its space. + +For an already mounted Windows home, the compression worker can be updated +without stopping the filesystem or Codex: + +```powershell +go build -tags winfsp -ldflags='-H=windowsgui' -o dist/codexfold-enroll.exe ./cmd/codexfold +./scripts/enable-windows-enrollment-service.ps1 -Apply +``` + +This installs `com.codexfold.enroll` as a delayed automatic Windows service; +the old worker script remains a compatible entry point. Installation requests +administrator elevation, replaces only the enrollment executable, and verifies +that the running filesystem service PID has not changed. The executable lives +under `%ProgramFiles%\CodexFold\Enrollment`, and its administrator-owned binding +and logs live under `%ProgramData%\CodexFold\Enrollment`. The binding explicitly +records the user's SID, home, store, native root and mount: SYSTEM's profile is +never a default data location. The owned login shortcut is removed after a +successful handover; the tray remains a login companion. + +The existing `worker-policy.json` is preserved, including a user's disabled +setting. The service has an exclusive process lock and an independent progress +file. Missing or malformed policy cannot fall back to stale process defaults. +The persisted enabled setting remains separate from effective readiness: +missing mount, inactive namespace, stale paused acknowledgement or a re-enabled +built-in loop publish `waiting-filesystem` and prevent work. There is no five-minute +startup deadline. User pause keeps the service available for later re-enable; +SCM stop/shutdown cancels work without changing the policy. Observation records +and existing pack/migration/retirement recovery proofs are reused on subsequent +cycles rather than fabricated or discarded. + +Both services use 5/15/60-second SCM failure restart actions. Enrollment and its +children run below normal priority. A Windows job with kill-on-close contains +the host and its children so a failed host cannot leave pack/migrate processes +running alongside its replacement. The restore script stops and disables the +owned enrollment service before changing storage. The tray distinguishes waiting +for the filesystem, a stopped worker, invalid settings and a user-disabled policy. +Missing Windows rollout paths are skipped individually while mount, +namespace, active-writer, and stability checks remain required. Freshly observed +sessions still need the configured one-hour observation window. +For a selected idle session using a Windows verbatim drive path, the worker +checks file identity, the observed fingerprint, and content hashes before +normalizing only that row's equivalent path through a guarded SQLite update. +The file stays in place, allowing the existing mounted host to recognize it. + +The tray separates published-pack compression reduction from retained/pending +occupancy and current net saving. Its background read-only inventory measures +physical usage, while the verified published recovery archive supplies the +compression baseline; unpublished manifests do not inflate compression saving. +The current generation is scanned separately so shared hardlinks cannot make +its compressed footprint disappear through whole-store traversal order. Negative +net saving is displayed as extra occupancy rather than clamped to zero. These +measurements do not authorize deletion or promise that every retained byte is +immediately reclaimable. + +The Windows tray now edits the persisted automatic-folding policy: enable/pause, +check interval, idle window, archived-session scope and automatic/fixed batch size. +It displays the real batch completion, last observed queue and next check time. +Missing or invalid policy can be repaired explicitly into a paused configuration; +concurrent changes are refused rather than overwritten. Aggregate chart samples and +confirmed incidents persist in `enrollment/ui-history-v1.json` for 30 days with +age-based downsampling. Gaps while the tray is quit are not filled in. Incident +details include impact, recovery time, recommendations and the relevant logs. +The diagnostics dialog copies or exports JSON with settings, aggregate status, +history and build identity; it excludes session contents and authentication data. +The application-owned window caption, buttons, switches, progress tracks, range +selector, selectable options, menus and diagnostic sheets share the same Liquid +Glass material system. Light/dark themes and reduced-motion, reduced-transparency +and increased-contrast settings are supported. The selector remains a standard +HTML select with styled in-view picker surfaces. Tray context actions open this +same glass menu in the status window. Windows-owned save dialogs, notifications +and startup-failure dialogs retain their native appearance. + +The material and control proportions were reworked against Apple's published +[iOS 27 design resources](https://developer.apple.com/design/resources/), including +the [iOS 27 UI Kit](https://www.sketch.com/s/04c24d8b-38fb-4afb-8836-36617e022f02) +and the [iOS and iPadOS 27 Figma kit](https://www.figma.com/community/file/1651309003795292092/ios-and-ipados-27). +The Windows renderer uses neutral glass, 64-by-28 switches, capsule selections, +and rounded menu surfaces. Size-specific SVG displacement maps refract the +backdrop at rounded edges while leaving text sharp. This is an independent +WebView2 implementation, not UIKit's native material renderer; Apple fonts, +symbols, wallpapers and kit files are not distributed with the application. + +New Windows services keep WinFsp in a resident frontend and run the existing storage +engine in a separate child through a local named pipe. RPC uses a private launch +token, and pipe access excludes network logons; a SYSTEM frontend accepts update +control only from SYSTEM and administrators. Engine images use verified SHA-256 +names beneath the installed binary's protected `Core` directory. Startup reloads +the committed image binding. A live update drains current calls, refuses open file +handles, requires folding to be paused, stops the old engine cleanly, and verifies +the replacement protocol/PID/build before committing. A failed candidate restores +the prior engine; ambiguous writes are returned as errors and are never replayed. +The mounted health identity retains its nonce and reports the current engine build. + +An existing combined Windows mount needs one offline upgrade to activate this +separation. After exiting all Codex clients, build the candidate and run the +following from an administrator PowerShell. Omit `-Apply` to preview the exact +installation paths and hashes. The script verifies both service bindings, stops +only the owned folding worker without changing its policy, uses the existing +verified binary-update/rollback transaction, and resumes the worker afterward. + +```powershell +go build -tags winfsp -o dist/codexfold.exe ./cmd/codexfold +./scripts/upgrade-windows-core.ps1 -Apply +``` + +Subsequent engine updates preserve the mount and resident frontend. Pause folding +in the tray and wait for its paused acknowledgement, then run as administrator: + +```powershell +./dist/codexfold.exe fs service update-daemon-live ./dist/codexfold.exe --definition "$env:ProgramData\CodexFold\service.json" --apply --json +``` + +An update waits for existing file handles to close; it does not close clients or +their files. The resident WinFsp frontend itself still needs an offline update. +Isolated real WinFsp checks verified managed reads, flushed appends, the open-handle +guard, unchanged mount nonce/frontend PID during engine replacement, failed +candidate rollback and subsequent appends. The production mount has not yet been +switched to this architecture. Native clipboard/save-dialog interaction and the +SYSTEM version of the new engine split remain outside those isolated checks. + +The production mount has accepted a manually selected archived session without +restarting the filesystem, with its bytes verified against the pre-activation +backup. Windows policy/status readers allow atomic replacement; native snapshot +retirement uses the existing Windows directory-sync handling. This does not add +a sudden-power-loss durability guarantee. + +The local activation completed after the clients were exited, with a verified +343-file recovery copy. The worker update and archived-session migration then +completed while Codex stayed open, without restarting the mounted filesystem. +The persistent worker's SCM stop/shutdown paths, startup waiting, hot pause/resume, +policy retention and isolated child-job teardown have regression coverage. On +the local installation, a safely paused enrollment host was terminated and SCM +automatically started a new instance; the original filesystem PID and an archived +rollout hash remained unchanged. The tray's new states were checked in real +WebView2 at desktop, narrow and short window sizes. Windows reboot, sudden machine power +loss, failure during an in-flight turn, and the full Desktop GUI remain +unvalidated. The whole +repository suite still includes failures in macOS-specific paths, Unix file +modes, and privilege-dependent symlink fixtures; Windows-focused checks and the +session-deletion suite pass. These results establish the tested preview flows, +not complete cross-platform release qualification. diff --git a/go.mod b/go.mod index 9ecbd8b..90b117e 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module github.com/samekind/codexfold go 1.26 require ( + github.com/Microsoft/go-winio v0.6.2 + github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 github.com/klauspost/compress v1.19.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/winfsp/cgofuse v1.6.0 golang.org/x/sys v0.36.0 modernc.org/sqlite v1.40.1 @@ -14,10 +17,10 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/pflag v1.0.9 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f642994..ad88398 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -7,6 +9,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -29,6 +35,8 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/cli/fs.go b/internal/cli/fs.go index 26f6353..b94d6d2 100644 --- a/internal/cli/fs.go +++ b/internal/cli/fs.go @@ -584,8 +584,11 @@ func newFSServeCommand() *cobra.Command { } nativeRoot = filepath.Clean(nativeRoot) } - if frontend != "fuse" && frontend != "native-fskit" { - return errors.New("filesystem frontend must be fuse or native-fskit") + if frontend != "fuse" && frontend != "native-fskit" && frontend != "windows-proxy" && frontend != "windows-engine" { + return errors.New("filesystem frontend must be fuse, native-fskit, windows-proxy or windows-engine") + } + if strings.HasPrefix(frontend, "windows-") && runtime.GOOS != "windows" { + return errors.New("Windows engine frontends are available only on Windows") } if frontend == "native-fskit" { if runtime.GOOS != "darwin" { @@ -616,6 +619,16 @@ func newFSServeCommand() *cobra.Command { return err } } + if frontend == "windows-proxy" && apply { + return serveWindowsResidentHost(command, home, store, mount, foreground) + } + if frontend == "windows-engine" && apply { + stopEngine, err := prepareWindowsStorageEngine(command) + if err != nil { + return err + } + defer stopEngine() + } states, _, err := vfs.DiscoverSessionStatesDetailedReadOnly(store) if err != nil && !(errors.Is(err, os.ErrNotExist) && !apply) { return err @@ -776,6 +789,8 @@ func newFSServeCommand() *cobra.Command { known := make(map[string]uint64) knownRoutes := make(map[string]string) knownPacks := make(map[string]string) + pendingRecovery := make(map[string]bool) + recoveryRequested := make(chan struct{}, 1) lastStateIssueSignature := "" lastReloadError := "" lastMissingKnownSignature := "" @@ -788,6 +803,8 @@ func newFSServeCommand() *cobra.Command { managedStatusPath := "" if frontend == "native-fskit" { managedStatusPath = service.FSKitStatusPath(nativeFSKitResource, "managed") + } else if runtime.GOOS == "windows" { + managedStatusPath = filepath.Join(store, "fs", "status", "managed.json") } managedStatus := newManagedStatusReporterForStore(managedStatusPath, store, mount, nativeFSKitResource, func(err error) { _, _ = fmt.Fprintf(command.ErrOrStderr(), "write managed session status: %v\n", err) @@ -811,6 +828,7 @@ func newFSServeCommand() *cobra.Command { delete(known, sessionID) delete(knownRoutes, sessionID) delete(knownPacks, sessionID) + delete(pendingRecovery, sessionID) observation := managedReloadObservation{ Sequence: nextManagedObservationSequence(), Fatal: fmt.Errorf("open managed session %s: %w", sessionID, err), @@ -842,6 +860,12 @@ func newFSServeCommand() *cobra.Command { if err == nil { known[state.SessionID] = state.Generation knownPacks[state.SessionID] = resolver.Generation() + // An exact read-only open still needs background recovery. + pendingRecovery[state.SessionID] = true + select { + case recoveryRequested <- struct{}{}: + default: + } } else { recordManagedLoaderFailure(sessionID, started, err) } @@ -1156,7 +1180,7 @@ func newFSServeCommand() *cobra.Command { continue } generation, generationKnown := known[state.SessionID] - if generationKnown && generation == state.Generation && knownPacks[state.SessionID] == currentPack { + if !pendingRecovery[state.SessionID] && generationKnown && generation == state.Generation && knownPacks[state.SessionID] == currentPack { if knownRoutes[state.SessionID] == route { continue } @@ -1177,9 +1201,10 @@ func newFSServeCommand() *cobra.Command { return observation } upsertTime += time.Since(stepStarted) + delete(pendingRecovery, state.SessionID) continue } - if known[state.SessionID] == state.Generation && knownPacks[state.SessionID] == currentPack { + if !pendingRecovery[state.SessionID] && known[state.SessionID] == state.Generation && knownPacks[state.SessionID] == currentPack { continue } managed, resolver, err := openState(state) @@ -1193,6 +1218,7 @@ func newFSServeCommand() *cobra.Command { } known[state.SessionID] = managed.State().Generation knownPacks[state.SessionID] = resolver.Generation() + delete(pendingRecovery, state.SessionID) } if profileStartup { _, _ = fmt.Fprintf(command.ErrOrStderr(), "startup-profile loop retirement_sync=%s upsert=%s open=%s attach=%s ack=%s states=%d\n", retirementSyncTime, upsertTime, upsertProfile.Open, upsertProfile.Attach, upsertProfile.Ack, len(states)) @@ -1250,12 +1276,16 @@ func newFSServeCommand() *cobra.Command { }) var storageStatusDone <-chan struct{} var activityCounter *mountfs.IOActivityCounter - if frontend == "native-fskit" { + if frontend == "native-fskit" || runtime.GOOS == "windows" { + storageStatusPath := service.FSKitStatusPath(nativeFSKitResource, "storage") + if runtime.GOOS == "windows" { + storageStatusPath = filepath.Join(store, "fs", "status", "storage.json") + } storageStatusDone = startStorageStatusReporter( ctx, command.ErrOrStderr(), store, - service.FSKitStatusPath(nativeFSKitResource, "storage"), + storageStatusPath, 10*time.Minute, storageMaintenanceDone, storageStatusRefresh, @@ -1268,12 +1298,14 @@ func newFSServeCommand() *cobra.Command { watcherDone := make(chan struct{}) go func() { defer close(watcherDone) - runManagedReloadLoopAfterInitial(ctx, time.Second, 10*time.Second, initialManagedReloadErr, load, func(err error) { + runManagedReloadLoopAfterInitial(ctx, time.Second, 10*time.Second, initialManagedReloadErr, recoveryRequested, load, func(err error) { reportManagedReloadSafe(err) }) }() var mountErr error - if frontend == "native-fskit" { + if frontend == "windows-engine" { + mountErr = serveWindowsStorageEngine(ctx, command, filesystem, startEnrollment) + } else if frontend == "native-fskit" { mountErr = mountfs.ServeNativeFSKit(ctx, filesystem, mountfs.NativeFSKitServerOptions{ SocketPath: nativeFSKitSocket, ResourcePath: nativeFSKitResource, StatusPath: service.FSKitStatusPath(nativeFSKitResource, "daemon"), MountPoint: mount, @@ -1283,8 +1315,10 @@ func newFSServeCommand() *cobra.Command { OnReady: startEnrollment, }) } else { + stopStatus := startPlatformDaemonStatus(ctx, store, mount, activityCounter, command.ErrOrStderr()) startEnrollment() - mountErr = mountfs.Mount(ctx, mountfs.HostOptions{MountPoint: mount, Filesystem: filesystem, Foreground: foreground, OperationRecorder: operationRecorder}) + mountErr = mountfs.Mount(ctx, mountfs.HostOptions{MountPoint: mount, StorageRoot: store, NamespaceRoot: home, Filesystem: filesystem, Foreground: foreground, OperationRecorder: operationRecorder, Activity: activityCounter}) + stopStatus() } cancel() <-managedHeartbeatDone @@ -1303,6 +1337,9 @@ func newFSServeCommand() *cobra.Command { } } sessionCloseErr := filesystem.CloseSessions() + if frontend == "windows-engine" && errors.Is(mountErr, context.Canceled) { + mountErr = nil + } return errors.Join(mountErr, nativeWatcherErr, sessionCloseErr) }, } @@ -1313,7 +1350,7 @@ func newFSServeCommand() *cobra.Command { command.Flags().BoolVar(&foreground, "foreground", true, "Keep the FUSE host in the foreground") command.Flags().BoolVar(&canonicalNamespace, "canonical-namespace", false, "Expose sessions and archived_sessions as a shared virtual namespace") command.Flags().StringVar(&nativeRoot, "native-root", "", "Backing root for unmanaged canonical session files") - command.Flags().StringVar(&frontend, "frontend", "fuse", "Filesystem frontend: fuse or native-fskit") + command.Flags().StringVar(&frontend, "frontend", "fuse", "Filesystem frontend: fuse, native-fskit, windows-proxy or windows-engine") command.Flags().StringVar(&nativeFSKitSocket, "fskit-socket", "", "Native FSKit daemon Unix socket; defaults to a short per-home path in /private/tmp") command.Flags().StringVar(&nativeFSKitResource, "fskit-resource", "", "Native FSKit resource; defaults to the security-scoped /fs/native-fskit directory") command.Flags().StringVar(&operationTracePath, "operation-trace", "", "Absolute path for sanitized FUSE operation names") @@ -1604,19 +1641,19 @@ func startManagedStatusHeartbeat(ctx context.Context, reporter *managedStatusRep func runManagedReloadLoop(ctx context.Context, minimumDelay time.Duration, maximumDelay time.Duration, load func() error, report func(error)) { delayState := newManagedReloadDelayState(minimumDelay, maximumDelay, managedRecoveryDeadline) delay := delayState.minimumDelay - runManagedReloadLoopWithDelay(ctx, delayState, delay, load, report) + runManagedReloadLoopWithDelay(ctx, delayState, delay, nil, load, report) } -func runManagedReloadLoopAfterInitial(ctx context.Context, minimumDelay time.Duration, maximumDelay time.Duration, initialErr error, load func() error, report func(error)) { +func runManagedReloadLoopAfterInitial(ctx context.Context, minimumDelay time.Duration, maximumDelay time.Duration, initialErr error, wake <-chan struct{}, load func() error, report func(error)) { delayState := newManagedReloadDelayState(minimumDelay, maximumDelay, managedRecoveryDeadline) // The synchronous startup load has already observed current state. A // healthy backend can wait for the normal poll; a failed load must retry // promptly so the ten-second incident deadline remains meaningful. delay := delayState.next(initialErr, time.Now()) - runManagedReloadLoopWithDelay(ctx, delayState, delay, load, report) + runManagedReloadLoopWithDelay(ctx, delayState, delay, wake, load, report) } -func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReloadDelayState, delay time.Duration, load func() error, report func(error)) { +func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReloadDelayState, delay time.Duration, wake <-chan struct{}, load func() error, report func(error)) { for { timer := time.NewTimer(delay) select { @@ -1626,6 +1663,8 @@ func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReload } return case <-timer.C: + case <-wake: + timer.Stop() } err := load() report(err) diff --git a/internal/cli/fs_enroll.go b/internal/cli/fs_enroll.go index 89ef991..4011fa1 100644 --- a/internal/cli/fs_enroll.go +++ b/internal/cli/fs_enroll.go @@ -10,6 +10,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" "github.com/samekind/codexfold/internal/codex" @@ -39,6 +40,8 @@ type enrollmentFlags struct { statusPaths []string reclaimWorkers int backgroundIdle func() bool + externalWorker bool + filesystemReady func() error } type enrollmentApplyHooks struct { @@ -92,6 +95,7 @@ var runEnrollmentCommand = func(ctx context.Context, args []string) error { } } command.Env = enrollmentChildEnvironment(os.Environ()) + configureEnrollmentChild(command) output, err := command.CombinedOutput() if err != nil { return fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) @@ -142,6 +146,9 @@ func newFSEnrollCommand() *cobra.Command { command.AddCommand(newFSEnrollPlanCommand()) command.AddCommand(newFSEnrollApplyCommand()) command.AddCommand(newFSEnrollReclaimCommand()) + command.AddCommand(newFSEnrollRunCommand()) + command.AddCommand(newFSEnrollStopCommand()) + addPlatformEnrollmentCommands(command) return command } @@ -322,6 +329,10 @@ func applyEnrollmentCycle(ctx context.Context, flags enrollmentFlags, hooks enro foldErrors = append(foldErrors, fmt.Errorf("prepare enrollment for %s: %w", decision.SessionID, err)) continue } + if err := normalizeEnrollmentAlias(ctx, home, &decision); err != nil { + foldErrors = append(foldErrors, fmt.Errorf("normalize enrollment path for %s: %w", decision.SessionID, err)) + continue + } if err := runEnrollmentCommand(ctx, []string{"fold", decision.SessionID, "--codex-home", home, "--store", store, "--apply", "--overwrite"}); err != nil { if ctx.Err() != nil { return result, err @@ -461,7 +472,7 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval if control.Enabled { nextApply = time.Now() } - progress := applyEnrollmentControl(newEnrollmentProgress(flags, control, enroll.PhaseDisabled), flags, control) + progress := applyEnrollmentControl(newEnrollmentProgress(flags, control, enrollmentInactivePhase(control)), flags, control) if control.Enabled { progress.Phase = enroll.PhaseIdle progress.NextCheckAt = nextApply @@ -506,7 +517,7 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval progress.Phase = enroll.PhaseIdle progress.NextCheckAt = nextApply } else { - progress.Phase = enroll.PhaseDisabled + progress.Phase = enrollmentInactivePhase(control) progress.NextCheckAt = time.Time{} } publishEnrollmentProgress(flags, progress) @@ -515,10 +526,12 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval if !control.Enabled || nextApply.IsZero() || now.Before(nextApply) { progress = applyEnrollmentControl(progress, flags, control) if control.Enabled { - progress.Phase = enroll.PhaseIdle + if progress.Phase != enroll.PhaseWaitingReclaim { + progress.Phase = enroll.PhaseIdle + } progress.NextCheckAt = nextApply } else { - progress.Phase = enroll.PhaseDisabled + progress.Phase = enrollmentInactivePhase(control) progress.NextCheckAt = time.Time{} } publishEnrollmentProgress(flags, progress) @@ -536,6 +549,11 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval cycleCtx, cycleCancel := context.WithCancel(ctx) watchDone := make(chan struct{}) + // The cycle can spend minutes inside a child command. Keep publishing + // worker liveness without inventing completed operations, and serialize + // those writes with phase/progress callbacks so an older snapshot cannot + // overwrite a newer one. + var progressMu sync.Mutex go func() { defer close(watchDone) poll := enrollmentPolicyPollInterval @@ -553,16 +571,27 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval cycleCancel() return } + progressMu.Lock() + publishEnrollmentProgress(flags, progress) + progressMu.Unlock() } } }() result, err := runServiceEnrollmentCycle(cycleCtx, cycleFlags, enrollmentApplyHooks{ - onManagedCount: func(count int) { progress.ManagedCount = count }, + onManagedCount: func(count int) { + progressMu.Lock() + defer progressMu.Unlock() + progress.ManagedCount = count + }, onPhase: func(phase string) { + progressMu.Lock() + defer progressMu.Unlock() progress.Phase = phase publishEnrollmentProgress(flags, progress) }, onProgress: func(done, total int) { + progressMu.Lock() + defer progressMu.Unlock() progress.CycleDone = done progress.CycleTotal = total publishEnrollmentProgress(flags, progress) @@ -603,7 +632,7 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval progress.NextCheckAt = nextApply } else { nextApply = time.Time{} - progress.Phase = enroll.PhaseDisabled + progress.Phase = enrollmentInactivePhase(control) progress.NextCheckAt = time.Time{} } progress = applyEnrollmentControl(progress, flags, control) @@ -638,10 +667,31 @@ func resolveEnrollmentControl(flags enrollmentFlags, flagInterval time.Duration) } control, err := enroll.LoadControl(path) if err != nil { - return enroll.Control{ + invalid := enroll.Control{ Present: true, ConfigError: fmt.Sprintf("enrollment policy is invalid: %v", err), } + if flags.externalWorker { + invalid.BlockedPhase = enroll.PhaseConfigInvalid + } + return invalid + } + if flags.externalWorker { + // A missing worker policy must not revive process defaults. The service + // stays available for a later valid policy, without changing user intent. + control.Present = true + control.RequestedEnabled = control.Enabled + if control.Enabled { + readyErr := requireBuiltinEnrollmentPaused(enrollmentStorePath(flags), time.Now()) + if readyErr == nil && flags.filesystemReady != nil { + readyErr = flags.filesystemReady() + } + if readyErr != nil { + control.Enabled = false + control.BlockedPhase = enroll.PhaseWaitingFilesystem + control.BlockReason = readyErr.Error() + } + } } if !control.Present { return flagEnrollmentControl(flags, flagInterval) @@ -663,6 +713,9 @@ func flagEnrollmentControl(flags enrollmentFlags, flagInterval time.Duration) en } func enrollmentControlPath(flags enrollmentFlags) string { + if flags.externalWorker { + return enroll.WorkerControlPath(enrollmentStorePath(flags)) + } if flags.storeDir != "" { if !filepath.IsAbs(flags.storeDir) { return "" @@ -695,6 +748,9 @@ func effectiveEnrollmentInterval(control enroll.Control, flagInterval time.Durat func enrollmentControlChanged(previous enroll.Control, next enroll.Control) bool { return previous.Present != next.Present || previous.ConfigError != next.ConfigError || + previous.RequestedEnabled != next.RequestedEnabled || + previous.BlockedPhase != next.BlockedPhase || + previous.BlockReason != next.BlockReason || previous.Enabled != next.Enabled || previous.Interval != next.Interval || previous.StableFor != next.StableFor || @@ -731,14 +787,17 @@ func newEnrollmentProgress(flags enrollmentFlags, control enroll.Control, phase } func applyEnrollmentControl(progress enroll.Progress, flags enrollmentFlags, control enroll.Control) enroll.Progress { - progress.Enabled = control.Enabled + progress.Enabled = control.Enabled || control.RequestedEnabled progress.Interval = control.Interval progress.StableFor = control.StableFor progress.ArchivedOnly = control.ArchivedOnly if control.ConfigError != "" { progress.LastError = control.ConfigError progress.ErrorKind = "configuration" - } else if progress.ErrorKind == "configuration" { + } else if control.BlockReason != "" { + progress.LastError = control.BlockReason + progress.ErrorKind = "filesystem" + } else if progress.ErrorKind == "configuration" || progress.ErrorKind == "filesystem" { progress.LastError = "" progress.ErrorKind = "" } @@ -751,6 +810,13 @@ func applyEnrollmentControl(progress enroll.Progress, flags enrollmentFlags, con return progress } +func enrollmentInactivePhase(control enroll.Control) string { + if control.BlockedPhase != "" { + return control.BlockedPhase + } + return enroll.PhaseDisabled +} + func enrollmentStorePath(flags enrollmentFlags) string { if flags.storeDir != "" { return filepath.Clean(flags.storeDir) @@ -797,7 +863,11 @@ func enrollmentManagedCount(flags enrollmentFlags, result FSEnrollmentApplyResul func publishEnrollmentProgress(flags enrollmentFlags, progress enroll.Progress) { paths := append([]string(nil), flags.statusPaths...) if store := enrollmentStorePath(flags); store != "" { - paths = append(paths, enroll.ProgressPath(store)) + if flags.externalWorker { + paths = append(paths, enroll.WorkerProgressPath(store)) + } else { + paths = append(paths, enroll.ProgressPath(store)) + } } seen := make(map[string]struct{}, len(paths)) for _, path := range paths { diff --git a/internal/cli/fs_enroll_alias.go b/internal/cli/fs_enroll_alias.go new file mode 100644 index 0000000..6581a89 --- /dev/null +++ b/internal/cli/fs_enroll_alias.go @@ -0,0 +1,52 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" +) + +// Older mounted hosts do not recognize a verbatim drive prefix when deriving +// a path relative to the Codex home. Normalize only the selected idle session, +// after proving both spellings identify the same unchanged file. No file moves. +func normalizeEnrollmentAlias(ctx context.Context, home string, decision *enroll.Decision) error { + if runtime.GOOS != "windows" || !strings.HasPrefix(decision.RolloutPath, `\\?\`) { + return nil + } + plain := strings.TrimPrefix(decision.RolloutPath, `\\?\`) + if len(filepath.VolumeName(plain)) != 2 || !filepath.IsAbs(plain) { + return errors.New("unsupported extended enrollment path") + } + if _, err := canonicalRelativeRoute(home, plain); err != nil { + return err + } + original, err := os.Stat(decision.RolloutPath) + if err != nil { + return err + } + target, err := os.Stat(plain) + if err != nil { + return err + } + if !original.Mode().IsRegular() || !os.SameFile(original, target) || original.Size() != decision.Fingerprint.Size || original.ModTime().UnixNano() != decision.Fingerprint.ModTimeUnixNano { + return errors.New("enrollment path alias identity or fingerprint changed") + } + digest, err := hashPath(plain) + if err != nil { + return err + } + if digest.Bytes != decision.Fingerprint.Size { + return errors.New("enrollment alias changed while hashing") + } + if _, err := codex.RouteSession(ctx, codex.RouteOptions{CodexHome: home, SessionID: decision.SessionID, ExpectedPath: decision.RolloutPath, Target: codex.RouteTarget{Path: plain, Bytes: digest.Bytes, SHA256: digest.SHA256}}); err != nil { + return err + } + decision.RolloutPath = plain + return nil +} diff --git a/internal/cli/fs_enroll_alias_windows_test.go b/internal/cli/fs_enroll_alias_windows_test.go new file mode 100644 index 0000000..3d76aff --- /dev/null +++ b/internal/cli/fs_enroll_alias_windows_test.go @@ -0,0 +1,66 @@ +//go:build windows + +package cli + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" +) + +func TestEnrollmentNormalizesOnlyIdenticalWindowsAlias(t *testing.T) { + home, _, _ := fsFixture(t, true) + plain := filepath.Join(home, "sessions", "alias.jsonl") + if err := os.MkdirAll(filepath.Dir(plain), 0o700); err != nil { + t.Fatal(err) + } + content := []byte("{\"type\":\"session_meta\"}\n") + if err := os.WriteFile(plain, content, 0o600); err != nil { + t.Fatal(err) + } + alias := `\\?\` + plain + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`update threads set rollout_path=? where id='session'`, alias); err != nil { + t.Fatal(err) + } + info, err := os.Stat(alias) + if err != nil { + t.Fatal(err) + } + d := enroll.Decision{SessionID: "session", RolloutPath: alias, Fingerprint: enroll.Fingerprint{Size: info.Size(), ModTimeUnixNano: info.ModTime().UnixNano()}} + changed := d + changed.Fingerprint.Size++ + if err := normalizeEnrollmentAlias(context.Background(), home, &changed); err == nil { + t.Fatal("changed source accepted") + } + var route string + if err := db.QueryRow(`select rollout_path from threads where id='session'`).Scan(&route); err != nil || route != alias { + t.Fatalf("failed check changed route: %s %v", route, err) + } + if err := normalizeEnrollmentAlias(context.Background(), home, &d); err != nil { + t.Fatal(err) + } + sessions, err := codex.LoadSessions(home) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 1 || sessions[0].RolloutPath != plain || d.RolloutPath != plain { + t.Fatalf("route not normalized: %#v", sessions) + } + data, err := os.ReadFile(alias) + if err != nil || string(data) != string(content) { + t.Fatalf("alias bytes changed: %v", err) + } + if _, err := canonicalNativeRoute(home, filepath.Join(home, "native"), d.RolloutPath); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/fs_enroll_child_other.go b/internal/cli/fs_enroll_child_other.go new file mode 100644 index 0000000..cf4b6c3 --- /dev/null +++ b/internal/cli/fs_enroll_child_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package cli + +import "os/exec" + +func configureEnrollmentChild(*exec.Cmd) {} diff --git a/internal/cli/fs_enroll_child_windows.go b/internal/cli/fs_enroll_child_windows.go new file mode 100644 index 0000000..50a2f9d --- /dev/null +++ b/internal/cli/fs_enroll_child_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package cli + +import ( + "golang.org/x/sys/windows" + "os/exec" + "syscall" +) + +func configureEnrollmentChild(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW | windows.BELOW_NORMAL_PRIORITY_CLASS} +} diff --git a/internal/cli/fs_enroll_heartbeat_test.go b/internal/cli/fs_enroll_heartbeat_test.go new file mode 100644 index 0000000..87d41ea --- /dev/null +++ b/internal/cli/fs_enroll_heartbeat_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "bytes" + "context" + "os" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" +) + +func TestPeriodicEnrollmentHeartbeatsDuringLongOperation(t *testing.T) { + for _, external := range []bool{false, true} { + name := "builtin" + if external { + name = "worker" + } + t.Run(name, func(t *testing.T) { + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + store := t.TempDir() + flags := enrollmentFlags{storeDir: store, externalWorker: external} + policyPath, progressPath := enroll.ControlPath(store), enroll.ProgressPath(store) + var builtinBefore []byte + if external { + if err := enroll.SaveControl(policyPath, enroll.Control{Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(progressPath, enroll.Progress{Phase: enroll.PhaseDisabled}); err != nil { + t.Fatal(err) + } + var err error + builtinBefore, err = os.ReadFile(progressPath) + if err != nil { + t.Fatal(err) + } + policyPath, progressPath = enroll.WorkerControlPath(store), enroll.WorkerProgressPath(store) + } + if err := enroll.SaveControl(policyPath, enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + started, advance := make(chan struct{}), make(chan struct{}) + runServiceEnrollmentCycle = func(ctx context.Context, _ enrollmentFlags, hooks enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + hooks.onManagedCount(124) + hooks.onPhase(enroll.PhaseReclaiming) + hooks.onProgress(18, 20) + close(started) + select { + case <-advance: + hooks.onManagedCount(125) + hooks.onProgress(19, 20) + case <-ctx.Done(): + return FSEnrollmentApplyResult{}, ctx.Err() + } + <-ctx.Done() + return FSEnrollmentApplyResult{}, ctx.Err() + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { runPeriodicEnrollment(ctx, flags, time.Minute, nil); close(done) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("enrollment loop did not stop") + } + }) + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("long operation did not start") + } + first, err := enroll.LoadProgress(progressPath) + if err != nil { + t.Fatal(err) + } + waitForHeartbeat := func(after time.Time, completed, managed int) enroll.Progress { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + p, err := enroll.LoadProgress(progressPath) + if err == nil && p.UpdatedAt.After(after) && p.CycleDone == completed && p.ManagedCount == managed { + if !p.Enabled || p.Phase != enroll.PhaseReclaiming || p.CycleTotal != 20 { + t.Fatalf("heartbeat changed active operation: %#v", p) + } + return p + } + time.Sleep(enrollmentPolicyPollInterval) + } + t.Fatal("progress timestamp did not advance during long operation") + return enroll.Progress{} + } + second := waitForHeartbeat(first.UpdatedAt, 18, 124) + third := waitForHeartbeat(second.UpdatedAt, 18, 124) + close(advance) + fourth := waitForHeartbeat(third.UpdatedAt, 19, 125) + waitForHeartbeat(fourth.UpdatedAt, 19, 125) + if external { + after, err := os.ReadFile(enroll.ProgressPath(store)) + if err != nil || !bytes.Equal(builtinBefore, after) { + t.Fatal("worker heartbeat overwrote filesystem progress") + } + } + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("active cycle did not stop") + } + stopped, err := os.ReadFile(progressPath) + if err != nil { + t.Fatal(err) + } + time.Sleep(5 * enrollmentPolicyPollInterval) + after, err := os.ReadFile(progressPath) + if err != nil || !bytes.Equal(stopped, after) { + t.Fatal("heartbeat continued after loop stopped") + } + }) + } +} diff --git a/internal/cli/fs_enroll_install_windows.go b/internal/cli/fs_enroll_install_windows.go new file mode 100644 index 0000000..0f9feee --- /dev/null +++ b/internal/cli/fs_enroll_install_windows.go @@ -0,0 +1,394 @@ +//go:build windows + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/service" + "github.com/samekind/codexfold/internal/sessionns" + "github.com/spf13/cobra" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +type enrollmentInstallResult struct { + Applied bool `json:"applied"` + Binding service.WindowsEnrollmentConfig `json:"binding"` + FilesystemPID uint32 `json:"filesystem_pid"` + WorkerPID uint32 `json:"worker_pid"` + Policy enroll.Control `json:"policy"` + Error string `json:"error,omitempty"` +} + +func newEnrollmentServiceInstallCommand() *cobra.Command { + var home, mount, resultPath string + var apply bool + command := &cobra.Command{Use: "install", Short: "Install or update only the persistent Windows enrollment service", Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + result, err := installEnrollmentService(command.Context(), home, mount, apply) + if err != nil { + result.Error = err.Error() + } + if resultPath != "" { + if !filepath.IsAbs(resultPath) { + return errors.New("absolute installation result path is required") + } + data, marshalErr := json.MarshalIndent(result, "", " ") + if marshalErr != nil { + return marshalErr + } + if _, writeErr := service.WriteDefinition(resultPath, append(data, '\n'), true); writeErr != nil { + return errors.Join(err, writeErr) + } + // GUI-subsystem/elevated processes may have no usable stdout. The + // result file is the output channel in that invocation. + return err + } + if err != nil { + return err + } + return writeJSON(command, result) + }} + command.Flags().StringVar(&home, "codex-home", "", "Explicit absolute user Codex home") + command.Flags().StringVar(&mount, "mount", "V:/", "Mounted canonical namespace drive") + command.Flags().StringVar(&resultPath, "result", "", "Write an installation result for an elevated invocation") + command.Flags().BoolVar(&apply, "apply", false, "Install the automatic Windows enrollment service without remounting") + return command +} + +// Use native tools by absolute system path, including when the installer was +// elevated from a user shell whose PATH could resolve a different executable. +type enrollmentSystemRunner struct{} + +func (enrollmentSystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + system, err := windows.GetSystemDirectory() + if err != nil { + return nil, err + } + command := exec.CommandContext(ctx, filepath.Join(system, name), args...) + configureEnrollmentChild(command) + return command.CombinedOutput() +} + +func installEnrollmentService(ctx context.Context, home, mount string, apply bool) (result enrollmentInstallResult, runErr error) { + if !filepath.IsAbs(home) || !filepath.IsAbs(mount) { + return result, errors.New("absolute home and mount paths are required") + } + home, mount = filepath.Clean(home), filepath.Clean(mount) + programRoot := filepath.Join(os.Getenv("ProgramFiles"), "CodexFold", "Enrollment") + dataRoot := filepath.Join(os.Getenv("ProgramData"), "CodexFold", "Enrollment") + if !filepath.IsAbs(programRoot) || !filepath.IsAbs(dataRoot) { + return result, errors.New("Windows installation roots are not absolute") + } + owner, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return result, err + } + config := service.WindowsEnrollmentConfig{Version: 1, ServiceName: service.WindowsEnrollmentServiceName, OwnerSID: owner.User.Sid.String(), + BinaryPath: filepath.Join(programRoot, "codexfold-enroll.exe"), CodexHome: home, Store: filepath.Join(home, "fold-store"), NativeRoot: filepath.Join(home, "fold-native"), Mount: mount, + StdoutPath: filepath.Join(dataRoot, "stdout.log"), StderrPath: filepath.Join(dataRoot, "stderr.log")} + definition := filepath.Join(dataRoot, "service.json") + result.Binding = config + policy, err := enroll.LoadControl(enroll.WorkerControlPath(config.Store)) + if err != nil { + return result, err + } + if !policy.Present { + policy = enroll.Control{Present: true, Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5} + } + result.Policy = policy + if !apply { + return result, nil + } + if !windows.GetCurrentProcessToken().IsElevated() { + return result, errors.New("administrator elevation is required to install the enrollment service") + } + if err := service.ProbeMount(mount); err != nil { + return result, err + } + namespace, err := sessionns.Inspect(sessionns.Options{Home: home, Mount: mount, NativeRoot: config.NativeRoot}) + if err != nil || !namespace.Active { + return result, errors.Join(errors.New("the bound canonical namespace must be active"), err) + } + manager, err := mgr.Connect() + if err != nil { + return result, err + } + defer manager.Disconnect() + fsService, err := manager.OpenService(serviceLabel) + if err != nil { + return result, err + } + defer fsService.Close() + fsStatus, err := fsService.Query() + if err != nil || fsStatus.State != svc.Running { + return result, errors.New("the filesystem service must remain running") + } + fsConfig, err := fsService.Config() + if err != nil { + return result, err + } + fsDefinition := filepath.Join(os.Getenv("ProgramData"), "CodexFold", "service.json") + fsBytes, err := os.ReadFile(fsDefinition) + if err != nil { + return result, err + } + boundFS, err := service.ParseWindowsConfig(fsBytes) + if err != nil || boundFS.ServiceName != serviceLabel || !strings.Contains(fsConfig.BinaryPathName, fsDefinition) || !serviceArgumentEquals(boundFS.Arguments, "--codex-home", home) || !serviceArgumentEquals(boundFS.Arguments, "--store", config.Store) { + return result, errors.New("the running filesystem service belongs to another installation") + } + result.FilesystemPID = fsStatus.ProcessId + runner := enrollmentSystemRunner{} + for _, directory := range []string{programRoot, dataRoot} { + if err := os.MkdirAll(directory, 0o700); err != nil { + return result, err + } + if info, err := os.Lstat(directory); err != nil || info.Mode()&os.ModeSymlink != 0 { + return result, errors.New("enrollment installation directory must not be a link") + } + if output, err := runner.Run(ctx, "icacls.exe", directory, "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "*S-1-5-32-545:(OI)(CI)RX"); err != nil { + return result, fmt.Errorf("protect enrollment installation: %w: %s", err, output) + } + } + installLock, err := service.AcquireProcessLock(filepath.Join(dataRoot, "install.lock")) + if err != nil { + return result, err + } + defer installLock.Close() + // Re-read after taking the installer lock, before pausing the worker. + if current, err := enroll.LoadControl(enroll.WorkerControlPath(config.Store)); err != nil { + return result, err + } else if current.Present { + policy = current + result.Policy = current + } + var previousDefinition []byte + previousServiceRunning := false + // Stop only an existing, explicitly owned worker service. A raw CLI worker + // is stopped via its hot policy so no Codex or filesystem process is touched. + if existing, err := manager.OpenService(config.ServiceName); err == nil { + defer existing.Close() + previous, err := existing.Config() + if err != nil || !strings.Contains(previous.BinaryPathName, config.BinaryPath) || !strings.Contains(previous.BinaryPathName, definition) { + return result, errors.New("an unrelated service uses the enrollment service name") + } + oldBinding, err := os.ReadFile(definition) + if err != nil { + return result, err + } + binding, err := service.ParseWindowsEnrollmentConfig(oldBinding) + if err != nil || !strings.EqualFold(binding.CodexHome, home) || binding.OwnerSID != config.OwnerSID { + return result, errors.New("the enrollment service is bound to another user or home") + } + previousStatus, err := existing.Query() + if err != nil { + return result, err + } + if err := stopEnrollmentSCM(ctx, existing); err != nil { + return result, err + } + previousDefinition = oldBinding + // Reinstalling restores this owned service if the replacement fails. + previousServiceRunning = previousStatus.State == svc.Running + } + paused := policy + paused.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(config.Store), paused); err != nil { + return result, err + } + // Always restore the captured user intent, including when a later install + // step fails. Restoring a paused setting must never silently enable folding. + defer func() { + if !result.Applied { + runErr = errors.Join(runErr, enroll.SaveControl(enroll.WorkerControlPath(config.Store), policy)) + } + }() + if err := waitEnrollmentLockReleased(ctx, config.Store); err != nil { + return result, err + } + builtin, err := enroll.LoadControl(enroll.ControlPath(config.Store)) + if err != nil { + return result, err + } + if !builtin.Present { + builtin = policy + } + builtin.Enabled = false + if err := enroll.SaveControl(enroll.ControlPath(config.Store), builtin); err != nil { + return result, err + } + candidate, err := os.Executable() + if err != nil { + return result, err + } + if strings.EqualFold(candidate, config.BinaryPath) { + return result, errors.New("install from a separate candidate binary") + } + update, err := service.StageBinaryUpdate(candidate, config.BinaryPath) + if err != nil { + return result, err + } + if err := update.Promote(); err != nil { + _ = update.Rollback() + _ = update.Commit() + return result, err + } + defer func() { + if runErr != nil && !result.Applied { + // Keep a failed replacement from leaving an enabled but broken service. + if installed, err := manager.OpenService(config.ServiceName); err == nil { + stopErr := stopEnrollmentSCM(context.Background(), installed) + if stopErr != nil { + installed.Close() + runErr = errors.Join(runErr, stopErr) + return + } + if len(previousDefinition) == 0 { + runErr = errors.Join(runErr, installed.Delete()) + } + installed.Close() + } + rollbackErr := update.Rollback() + runErr = errors.Join(runErr, rollbackErr) + if rollbackErr != nil { + return + } + if len(previousDefinition) > 0 { + _, restoreErr := service.WriteDefinition(definition, previousDefinition, true) + runErr = errors.Join(runErr, restoreErr, enroll.SaveControl(enroll.WorkerControlPath(config.Store), policy)) + if restoreErr == nil && previousServiceRunning { + runErr = errors.Join(runErr, (service.WindowsManager{Runner: runner}).Start(context.Background(), config.ServiceName)) + } + } + } + runErr = errors.Join(runErr, update.Commit()) + }() + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return result, err + } + if _, err := service.WriteDefinition(definition, append(data, '\n'), true); err != nil { + return result, err + } + if _, err := service.ParseWindowsEnrollmentConfig(data); err != nil { + return result, err + } + platform := service.WindowsManager{Runner: runner} + if err := platform.InstallEnrollment(ctx, config.ServiceName, config.BinaryPath, definition); err != nil { + return result, err + } + if err := enroll.SaveControl(enroll.WorkerControlPath(config.Store), policy); err != nil { + return result, err + } + startAttempt := time.Now() + if err := platform.Start(ctx, config.ServiceName); err != nil { + return result, err + } + workerService, err := manager.OpenService(config.ServiceName) + if err != nil { + return result, err + } + defer workerService.Close() + deadline := time.Now().Add(30 * time.Second) + for { + status, err := workerService.Query() + if err != nil { + return result, err + } + progress, _ := enroll.LoadProgress(enroll.WorkerProgressPath(config.Store)) + if status.State == svc.Running && progress.UpdatedAt.After(startAttempt) && progress.Phase != enroll.PhaseStopped && progress.Phase != enroll.PhaseConfigInvalid && progress.Enabled == policy.Enabled { + result.WorkerPID = status.ProcessId + break + } + if time.Now().After(deadline) { + return result, errors.New("enrollment service did not publish a fresh status; inspect its stderr log") + } + select { + case <-ctx.Done(): + return result, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + fsAfter, err := fsService.Query() + if err != nil || fsAfter.State != svc.Running || fsAfter.ProcessId != result.FilesystemPID { + return result, errors.New("filesystem service identity changed during enrollment installation") + } + // Correct the final filesystem recovery action without restarting it. Its + // existing two-retry limit could otherwise strand the mount after a crash. + if output, err := runner.Run(ctx, "sc.exe", "failure", serviceLabel, "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"); err != nil { + return result, fmt.Errorf("filesystem recovery configuration: %w: %s", err, output) + } + result.Applied = true + return result, nil +} + +func serviceArgumentEquals(args []string, flag, value string) bool { + for index, arg := range args { + if arg == flag && index+1 < len(args) { + return strings.EqualFold(filepath.Clean(args[index+1]), filepath.Clean(value)) + } + } + return false +} + +func stopEnrollmentSCM(ctx context.Context, owned *mgr.Service) error { + status, err := owned.Query() + if err != nil { + return err + } + if status.State == svc.Stopped { + return nil + } + if status.State != svc.StopPending { + if _, err := owned.Control(svc.Stop); err != nil { + return err + } + } + deadline := time.Now().Add(40 * time.Second) + for { + status, err = owned.Query() + if err != nil { + return err + } + if status.State == svc.Stopped { + return nil + } + if time.Now().After(deadline) { + return errors.New("enrollment service did not finish stopping") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +func waitEnrollmentLockReleased(ctx context.Context, store string) error { + deadline := time.Now().Add(40 * time.Second) + for { + status, err := service.InspectProcessLock(filepath.Join(store, "enrollment", "worker.lock")) + if !status.Held { + return err + } + if time.Now().After(deadline) { + return errors.New("the previous enrollment worker did not release its lock") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} diff --git a/internal/cli/fs_enroll_run.go b/internal/cli/fs_enroll_run.go new file mode 100644 index 0000000..69a0923 --- /dev/null +++ b/internal/cli/fs_enroll_run.go @@ -0,0 +1,157 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/service" + "github.com/spf13/cobra" +) + +// A separately updatable enrollment worker keeps the mounted filesystem alive. +// It only runs after the built-in loop has acknowledged its disabled policy. +func requireBuiltinEnrollmentPaused(store string, now time.Time) error { + control, err := enroll.LoadControl(enroll.ControlPath(store)) + if err != nil { + return err + } + if !control.Present || control.Enabled { + return errors.New("pause built-in enrollment before running the separate worker") + } + progress, err := enroll.LoadProgress(enroll.ProgressPath(store)) + if err != nil { + return err + } + if progress.Enabled || progress.Phase != enroll.PhaseDisabled || progress.UpdatedAt.IsZero() || now.Sub(progress.UpdatedAt) > 15*time.Second || progress.UpdatedAt.After(now.Add(5*time.Second)) { + return errors.New("waiting for the running filesystem to acknowledge paused enrollment") + } + return nil +} + +func newFSEnrollRunCommand() *cobra.Command { + var flags enrollmentFlags + var apply bool + command := &cobra.Command{ + Use: "run", Short: "Run a separately updatable enrollment worker without remounting", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !apply || !flags.canonicalNamespace { + return errors.New("worker requires --apply and --canonical-namespace") + } + return runEnrollmentWorker(command.Context(), flags, false) + }, + } + addEnrollmentFlags(command, &flags) + command.Flags().BoolVar(&apply, "apply", false, "Allow bounded background enrollment") + return command +} + +// The SCM host keeps this loop alive even when the user pauses folding. Both +// startup and later mount outages use the same fail-closed, retrying control +// resolution; there is no one-shot startup deadline to strand the worker. +func runEnrollmentWorker(parent context.Context, flags enrollmentFlags, persistent bool) error { + store := enrollmentStorePath(flags) + if store == "" || !filepath.IsAbs(store) { + return errors.New("worker requires an absolute store path") + } + lock, err := service.AcquireProcessLock(filepath.Join(store, "enrollment", "worker.lock")) + if err != nil { + return err + } + defer lock.Close() + log, err := os.OpenFile(filepath.Join(store, "enrollment", "worker.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer log.Close() + flags.externalWorker = true + ctx, cancel := context.WithCancel(parent) + defer cancel() + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + if persistent { + <-ctx.Done() + return + } + ticker := time.NewTicker(enrollmentPolicyPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + requested, err := enroll.LoadControl(enroll.WorkerControlPath(store)) + if err == nil && (!requested.Present || !requested.Enabled) { + cancel() + return + } + } + } + }() + fmt.Fprintf(log, "%s worker started pid=%d persistent=%t\n", time.Now().UTC().Format(time.RFC3339), os.Getpid(), persistent) + runPeriodicEnrollment(ctx, flags, defaultServiceEnrollmentInterval, func(result FSEnrollmentApplyResult, err error) { + fmt.Fprintf(log, "%s selected=%d applied=%d error=%v\n", time.Now().UTC().Format(time.RFC3339), result.Apply.Selected, result.Apply.Applied, err) + }) + cancel() + <-watchDone + control := resolveEnrollmentControl(flags, defaultServiceEnrollmentInterval) + phase := enroll.PhaseDisabled + if persistent { + phase = enroll.PhaseStopped + } + progress := applyEnrollmentControl(newEnrollmentProgress(flags, control, phase), flags, control) + publishEnrollmentProgress(flags, progress) + fmt.Fprintf(log, "%s worker stopped\n", time.Now().UTC().Format(time.RFC3339)) + return nil +} + +func newFSEnrollStopCommand() *cobra.Command { + var flags enrollmentFlags + var apply bool + command := &cobra.Command{Use: "stop", Short: "Stop only the separate enrollment worker", Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !apply { + return errors.New("worker stop requires --apply") + } + store := enrollmentStorePath(flags) + if !filepath.IsAbs(store) { + return errors.New("absolute store path required") + } + control, err := enroll.LoadControl(enroll.WorkerControlPath(store)) + if err != nil { + return err + } + if !control.Present { + return nil + } + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + return err + } + deadline := time.Now().Add(30 * time.Second) + for { + status, err := service.InspectProcessLock(filepath.Join(store, "enrollment", "worker.lock")) + if !status.Held { + return err + } + if time.Now().After(deadline) { + return errors.New("enrollment worker has not finished stopping") + } + select { + case <-command.Context().Done(): + return command.Context().Err() + case <-time.After(100 * time.Millisecond): + } + } + }, + } + addEnrollmentFlags(command, &flags) + command.Flags().BoolVar(&apply, "apply", false, "Disable and stop the separate worker") + return command +} diff --git a/internal/cli/fs_enroll_run_test.go b/internal/cli/fs_enroll_run_test.go new file mode 100644 index 0000000..0a2bcf5 --- /dev/null +++ b/internal/cli/fs_enroll_run_test.go @@ -0,0 +1,329 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/service" +) + +func TestEnrollmentWorkerRequiresPausedFreshBuiltin(t *testing.T) { + store := t.TempDir() + now := time.Now() + control := enroll.Control{Enabled: false, Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := requireBuiltinEnrollmentPaused(store, now); err == nil { + t.Fatal("absent policy accepted") + } + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + for _, p := range []enroll.Progress{ + {Enabled: true, Phase: enroll.PhaseFolding, UpdatedAt: now}, + {Enabled: false, Phase: enroll.PhaseDisabled, UpdatedAt: now.Add(-time.Minute)}, + } { + if err := enroll.SaveProgress(enroll.ProgressPath(store), p); err != nil { + t.Fatal(err) + } + if err := requireBuiltinEnrollmentPaused(store, now); err == nil { + t.Fatal("active or stale acknowledgement accepted") + } + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := requireBuiltinEnrollmentPaused(store, now); err != nil { + t.Fatal(err) + } + control.Enabled = true + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + if err := requireBuiltinEnrollmentPaused(store, now); err == nil { + t.Fatal("re-enabled built-in loop accepted") + } +} + +func TestEnrollmentWorkerResumesAfterStaleHeartbeat(t *testing.T) { + store := t.TempDir() + control := enroll.Control{Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + writeHeartbeat := func(at time.Time) { + t.Helper() + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: at}); err != nil { + t.Fatal(err) + } + } + writeHeartbeat(time.Now()) + control.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + started := make(chan int32, 4) + canceled := make(chan struct{}, 1) + var cycles atomic.Int32 + runServiceEnrollmentCycle = func(ctx context.Context, _ enrollmentFlags, _ enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + n := cycles.Add(1) + started <- n + if n == 1 { + <-ctx.Done() + canceled <- struct{}{} + return FSEnrollmentApplyResult{}, ctx.Err() + } + return FSEnrollmentApplyResult{}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + command := newFSEnrollRunCommand() + command.SetArgs([]string{"--apply", "--canonical-namespace", "--codex-home", store, "--store", store}) + done := make(chan error, 1) + exited := make(chan struct{}) + go func() { defer close(exited); done <- command.ExecuteContext(ctx) }() + defer func() { cancel(); <-exited }() + awaitCycle := func(want int32) { + t.Helper() + select { + case n := <-started: + if n != want { + t.Fatalf("cycle = %d, want %d", n, want) + } + case err := <-done: + t.Fatalf("worker exited before recovery: %v", err) + case <-ctx.Done(): + t.Fatal("worker did not resume") + } + } + awaitPaused := func() { + t.Helper() + for { + progress, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err == nil && progress.Enabled && progress.Phase == enroll.PhaseWaitingFilesystem && progress.ErrorKind == "filesystem" { + break + } + select { + case err := <-done: + t.Fatalf("worker exited on stale heartbeat: %v", err) + case <-ctx.Done(): + t.Fatal("worker did not pause") + case <-time.After(10 * time.Millisecond): + } + } + select { + case err := <-done: + t.Fatalf("worker exited while paused: %v", err) + case <-time.After(5 * enrollmentPolicyPollInterval): + } + } + awaitCycle(1) + writeHeartbeat(time.Now().Add(-time.Minute)) + select { + case <-canceled: + case <-ctx.Done(): + t.Fatal("active cycle was not canceled") + } + awaitPaused() + writeHeartbeat(time.Now()) + awaitCycle(2) + // Also recover when sleep interrupts the idle interval between cycles. + writeHeartbeat(time.Now().Add(-time.Minute)) + awaitPaused() + writeHeartbeat(time.Now()) + awaitCycle(3) + // An explicit stop still works even if the filesystem heartbeat is stale. + writeHeartbeat(time.Now().Add(-time.Minute)) + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("explicit stop did not terminate worker") + } +} + +func TestPersistentEnrollmentWorkerWaitsAndKeepsUserPolicy(t *testing.T) { + store := t.TempDir() + policy := enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5} + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + cycles := make(chan enrollmentFlags, 4) + runServiceEnrollmentCycle = func(_ context.Context, flags enrollmentFlags, _ enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + select { + case cycles <- flags: + default: + } + return FSEnrollmentApplyResult{}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + done := make(chan error, 1) + exited := make(chan struct{}) + go func() { + defer close(exited) + done <- runEnrollmentWorker(ctx, enrollmentFlags{codexHome: store, storeDir: store, canonicalNamespace: true}, true) + }() + defer func() { cancel(); <-exited }() + awaitPhase := func(phase string, enabled bool) { + t.Helper() + for { + p, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err == nil && p.Phase == phase && p.Enabled == enabled { + return + } + select { + case err := <-done: + t.Fatalf("persistent worker exited: %v", err) + case <-ctx.Done(): + t.Fatalf("did not reach %s: %#v %v", phase, p, err) + case <-time.After(10 * time.Millisecond): + } + } + } + awaitCycle := func() { + t.Helper() + select { + case flags := <-cycles: + if flags.batchSize != 5 || flags.stableFor != time.Hour { + t.Fatalf("lost user policy: %#v", flags) + } + case <-ctx.Done(): + t.Fatal("worker did not resume after readiness") + } + } + // No startup heartbeat: the service publishes an accurate waiting state + // and remains alive. It must never fabricate an acknowledgement. + awaitPhase(enroll.PhaseWaitingFilesystem, true) + select { + case <-cycles: + t.Fatal("cycle started before filesystem readiness") + default: + } + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + awaitCycle() + // User pause keeps the service available for subsequent hot re-enable. + policy.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + awaitPhase(enroll.PhaseDisabled, false) + if err := os.WriteFile(enroll.WorkerControlPath(store), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + awaitPhase(enroll.PhaseConfigInvalid, false) + policy.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + awaitCycle() + before, err := os.ReadFile(enroll.WorkerControlPath(store)) + if err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("SCM cancellation did not stop worker") + } + after, err := os.ReadFile(enroll.WorkerControlPath(store)) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("service shutdown changed persisted user intent") + } + p, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err != nil || !p.Enabled || p.Phase != enroll.PhaseStopped { + t.Fatalf("shutdown status = %#v, %v", p, err) + } +} + +func TestEnrollmentWorkerPolicyStopsCycleAndKeepsBuiltinProgress(t *testing.T) { + store := t.TempDir() + control := enroll.Control{Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(enroll.ProgressPath(store)) + if err != nil { + t.Fatal(err) + } + control.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + started := make(chan struct{}) + runServiceEnrollmentCycle = func(ctx context.Context, _ enrollmentFlags, _ enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + close(started) + <-ctx.Done() + return FSEnrollmentApplyResult{}, ctx.Err() + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := newFSEnrollRunCommand() + command.SetArgs([]string{"--apply", "--canonical-namespace", "--codex-home", store, "--store", store}) + done := make(chan error, 1) + go func() { done <- command.ExecuteContext(ctx) }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("worker did not start") + } + if lock, err := service.AcquireProcessLock(filepath.Join(store, "enrollment", "worker.lock")); err == nil { + lock.Close() + t.Fatal("duplicate worker lock accepted") + } + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("worker did not stop") + } + after, err := os.ReadFile(enroll.ProgressPath(store)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("worker overwrote built-in progress") + } + progress, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err != nil { + t.Fatal(err) + } + if progress.Enabled || progress.Phase != enroll.PhaseDisabled { + t.Fatalf("worker did not acknowledge stop: %#v", progress) + } +} diff --git a/internal/cli/fs_enroll_service_other.go b/internal/cli/fs_enroll_service_other.go new file mode 100644 index 0000000..40dc561 --- /dev/null +++ b/internal/cli/fs_enroll_service_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package cli + +import "github.com/spf13/cobra" + +func addPlatformEnrollmentCommands(_ *cobra.Command) {} diff --git a/internal/cli/fs_enroll_service_windows.go b/internal/cli/fs_enroll_service_windows.go new file mode 100644 index 0000000..ca1bcf4 --- /dev/null +++ b/internal/cli/fs_enroll_service_windows.go @@ -0,0 +1,164 @@ +//go:build windows + +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + "unsafe" + + "github.com/samekind/codexfold/internal/service" + "github.com/samekind/codexfold/internal/sessionns" + "github.com/spf13/cobra" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func addPlatformEnrollmentCommands(parent *cobra.Command) { + command := &cobra.Command{Use: "service", Short: "Run the persistent Windows enrollment service"} + command.AddCommand(newEnrollmentServiceCommand(false), newEnrollmentServiceCommand(true)) + command.AddCommand(newEnrollmentServiceInstallCommand()) + parent.AddCommand(command) +} + +func newEnrollmentServiceCommand(validateOnly bool) *cobra.Command { + var definition string + name := "run" + if validateOnly { + name = "validate" + } + command := &cobra.Command{Use: name, Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + if !filepath.IsAbs(definition) { + return errors.New("absolute enrollment service binding path is required") + } + data, err := os.ReadFile(definition) + if err != nil { + return err + } + config, err := service.ParseWindowsEnrollmentConfig(data) + if err != nil { + return err + } + if _, err := windows.StringToSid(config.OwnerSID); err != nil { + return fmt.Errorf("invalid enrollment owner SID: %w", err) + } + if validateOnly { + return writeJSON(command, config) + } + binary, err := os.Executable() + if err != nil { + return err + } + if !strings.EqualFold(filepath.Clean(binary), filepath.Clean(config.BinaryPath)) { + return errors.New("enrollment binding binary does not match this executable") + } + isService, err := svc.IsWindowsService() + if err != nil { + return err + } + if !isService { + return errors.New("enrollment service must be started by the Service Control Manager") + } + if err := containEnrollmentServiceChildren(); err != nil { + return err + } + stdout, stderr, closeLogs, err := openWindowsServiceLogs(service.WindowsConfig{StdoutPath: config.StdoutPath, StderrPath: config.StderrPath}) + if err != nil { + return err + } + defer closeLogs() + if err := windows.SetPriorityClass(windows.CurrentProcess(), windows.BELOW_NORMAL_PRIORITY_CLASS); err != nil { + return err + } + flags := enrollmentFlags{codexHome: config.CodexHome, storeDir: config.Store, mountPoint: config.Mount, nativeRoot: config.NativeRoot, canonicalNamespace: true, stableFor: time.Hour, batchSize: 1, externalWorker: true} + flags.filesystemReady = func() error { + if err := service.ProbeMount(config.Mount); err != nil { + return err + } + state, err := sessionns.Inspect(sessionns.Options{Home: config.CodexHome, Mount: config.Mount, NativeRoot: config.NativeRoot}) + if err != nil { + return err + } + if !state.Active { + return errors.New("the bound canonical namespace is not active") + } + return nil + } + handler := &windowsEnrollmentService{log: stderr, run: func(ctx context.Context) error { return runEnrollmentWorker(ctx, flags, true) }} + _ = stdout + return svc.Run(config.ServiceName, handler) + }} + command.Flags().StringVar(&definition, "definition", "", "Administrator-owned enrollment service binding") + return command +} + +// Children inherit this job. If SCM restarts a crashed host, its interrupted +// pack/migrate children cannot keep mutating the store alongside the new host. +// The host deliberately retains the non-inheritable handle until process exit: +// closing it here would terminate the host itself before reporting SCM status. +func containEnrollmentServiceChildren() error { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return err + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + windows.CloseHandle(job) + return err + } + if err := windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil { + windows.CloseHandle(job) + return err + } + return nil +} + +type windowsEnrollmentService struct { + run func(context.Context) error + log io.Writer +} + +func (s *windowsEnrollmentService) Execute(_ []string, requests <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + changes <- svc.Status{State: svc.StartPending, CheckPoint: 1, WaitHint: 15000} + done := make(chan error, 1) + go func() { done <- s.run(ctx) }() + running := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + changes <- running + for { + select { + case err := <-done: + // A normal user pause never exits the persistent loop. An unexpected + // return must trigger SCM recovery, including a nil return. + _, _ = fmt.Fprintf(s.log, "enrollment service exited unexpectedly: %v\n", err) + return false, 1 + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + changes <- running + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending, CheckPoint: 1, WaitHint: 30000} + cancel() + select { + case err := <-done: + if err != nil && !errors.Is(err, context.Canceled) { + _, _ = fmt.Fprintf(s.log, "enrollment shutdown failed: %v\n", err) + return false, 1 + } + return false, 0 + case <-time.After(30 * time.Second): + _, _ = fmt.Fprintln(s.log, "enrollment shutdown timed out") + return false, 1 + } + } + } + } +} diff --git a/internal/cli/fs_enroll_service_windows_test.go b/internal/cli/fs_enroll_service_windows_test.go new file mode 100644 index 0000000..33cb8ca --- /dev/null +++ b/internal/cli/fs_enroll_service_windows_test.go @@ -0,0 +1,121 @@ +//go:build windows + +package cli + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func TestWindowsEnrollmentSCMStopAndShutdown(t *testing.T) { + for _, control := range []svc.Cmd{svc.Stop, svc.Shutdown} { + requests := make(chan svc.ChangeRequest, 1) + changes := make(chan svc.Status, 8) + stopped := make(chan uint32, 1) + handler := &windowsEnrollmentService{log: io.Discard, run: func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }} + go func() { _, code := handler.Execute(nil, requests, changes); stopped <- code }() + if start := <-changes; start.State != svc.StartPending { + t.Fatal(start) + } + if running := <-changes; running.State != svc.Running || running.Accepts&svc.AcceptShutdown == 0 { + t.Fatal(running) + } + requests <- svc.ChangeRequest{Cmd: control} + if stop := <-changes; stop.State != svc.StopPending { + t.Fatal(stop) + } + select { + case code := <-stopped: + if code != 0 { + t.Fatalf("stop code=%d", code) + } + case <-time.After(time.Second): + t.Fatal("SCM control did not cancel worker") + } + } +} + +func TestWindowsEnrollmentJobContainsChildren(t *testing.T) { + ready := filepath.Join(t.TempDir(), "child-pid") + command := exec.Command(os.Args[0], "-test.run=^TestWindowsEnrollmentJobHostHelper$", "-test.timeout=20s") + command.Env = append(os.Environ(), "CODEXFOLD_TEST_JOB_HOST=1", "CODEXFOLD_TEST_JOB_READY="+ready) + configureEnrollmentChild(command) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + if command.ProcessState == nil { + _ = command.Process.Kill() + _ = command.Wait() + } + }() + deadline := time.Now().Add(5 * time.Second) + var pid int + for pid == 0 { + data, err := os.ReadFile(ready) + if err == nil { + pid, _ = strconv.Atoi(string(data)) + } + if time.Now().After(deadline) { + t.Fatal("isolated job child did not start") + } + time.Sleep(10 * time.Millisecond) + } + child, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, uint32(pid)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(child) + defer windows.TerminateProcess(child, 1) + if err := command.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = command.Wait() + result, err := windows.WaitForSingleObject(child, 5000) + if err != nil || result != windows.WAIT_OBJECT_0 { + t.Fatalf("orphan child survived host crash: wait=%d err=%v", result, err) + } +} + +func TestWindowsEnrollmentJobHostHelper(t *testing.T) { + if os.Getenv("CODEXFOLD_TEST_JOB_HOST") != "1" { + t.Skip("isolated subprocess helper") + } + if err := containEnrollmentServiceChildren(); err != nil { + t.Fatal(err) + } + child := exec.Command(os.Args[0], "-test.run=^TestWindowsEnrollmentJobChildHelper$", "-test.timeout=20s") + child.Env = append(os.Environ(), "CODEXFOLD_TEST_JOB_CHILD=1") + configureEnrollmentChild(child) + if err := child.Start(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("CODEXFOLD_TEST_JOB_READY"), []byte(strconv.Itoa(child.Process.Pid)), 0o600); err != nil { + t.Fatal(err) + } + _ = child.Wait() +} + +func TestWindowsEnrollmentJobChildHelper(t *testing.T) { + if os.Getenv("CODEXFOLD_TEST_JOB_CHILD") != "1" { + t.Skip("isolated subprocess helper") + } + time.Sleep(15 * time.Second) +} + +func TestWindowsEnrollmentSCMUnexpectedReturnTriggersRecovery(t *testing.T) { + handler := &windowsEnrollmentService{log: io.Discard, run: func(context.Context) error { return nil }} + _, code := handler.Execute(nil, make(chan svc.ChangeRequest), make(chan svc.Status, 8)) + if code == 0 { + t.Fatal("unexpected exit was reported as a user stop") + } +} diff --git a/internal/cli/fs_enroll_writer.go b/internal/cli/fs_enroll_writer.go index 72f7ffa..5e2e973 100644 --- a/internal/cli/fs_enroll_writer.go +++ b/internal/cli/fs_enroll_writer.go @@ -3,6 +3,7 @@ package cli import ( "context" "path/filepath" + "runtime" "strings" "github.com/samekind/codexfold/internal/codex" @@ -21,6 +22,13 @@ func probeFilesystemMigrationWriter(ctx context.Context, session codex.Session, sessions = append(sessions, codex.Session{ID: session.ID, RolloutPath: alias}) } } + if runtime.GOOS == "windows" && len(sessions) > 1 { + // Canonical migration supplies the validated physical source and + // retained snapshot. Probing the mounted alias after publishing its + // managed owner would conflict with our own migration writer lease. + // Existing native writers remain visible as handles on these files. + sessions = sessions[1:] + } writers, err := enrollmentWriterProbe(ctx, sessions) if err != nil { return false, err diff --git a/internal/cli/fs_enroll_writer_windows.go b/internal/cli/fs_enroll_writer_windows.go index a895b48..5ebee84 100644 --- a/internal/cli/fs_enroll_writer_windows.go +++ b/internal/cli/fs_enroll_writer_windows.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/samekind/codexfold/internal/codex" "golang.org/x/sys/windows" @@ -21,15 +22,24 @@ func detectEnrollmentWriters(ctx context.Context, sessions []codex.Session) (map if err != nil { return nil, fmt.Errorf("encode rollout path for native handle probe: %w", err) } - handle, err := windows.CreateFile( - path, - windows.GENERIC_READ|windows.GENERIC_WRITE, - 0, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL, - 0, - ) + var handle windows.Handle + deadline := time.Now().Add(250 * time.Millisecond) + for { + handle, err = windows.CreateFile(path, windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) && !errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + break + } + if time.Now().After(deadline) { + break + } + // Closing a WinFsp alias can finish its backing-handle cleanup + // asynchronously. Do not mistake our preceding probe for a writer. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } if err == nil { if closeErr := windows.CloseHandle(handle); closeErr != nil { return nil, fmt.Errorf("close native rollout probe: %w", closeErr) @@ -40,6 +50,12 @@ func detectEnrollmentWriters(ctx context.Context, sessions []codex.Session) (map writers[session.ID] = true continue } + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + // SQLite can retain a route after its rollout has been removed. + // Let the planner mark that individual path invalid; its mount and + // namespace gates still block enrollment during an actual outage. + continue + } return nil, fmt.Errorf("probe native rollout handle %s: %w", session.ID, err) } return writers, nil diff --git a/internal/cli/fs_enroll_writer_windows_test.go b/internal/cli/fs_enroll_writer_windows_test.go new file mode 100644 index 0000000..09f7c2c --- /dev/null +++ b/internal/cli/fs_enroll_writer_windows_test.go @@ -0,0 +1,138 @@ +//go:build windows + +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" + "golang.org/x/sys/windows" +) + +func TestWindowsWriterProbeMissingRoutesDoNotBlockOtherSessions(t *testing.T) { + home, store, _ := fsFixture(t, true) + missing := addEnrollmentFixtureSession(t, home, "missing", 1) + if err := os.Remove(missing); err != nil { + t.Fatal(err) + } + live := addEnrollmentFixtureSession(t, home, "live", 2) + writer, err := os.OpenFile(live, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer writer.Close() + allowEnrollmentNamespaceReadiness(t) + oldMount := mountHealthProbe + mountHealthProbe = func(string) error { return nil } + t.Cleanup(func() { mountHealthProbe = oldMount }) + flags := enrollmentFlags{codexHome: home, storeDir: store, mountPoint: filepath.Join(home, "mount"), canonicalNamespace: true, stableFor: time.Nanosecond, batchSize: 1} + first, _, err := buildEnrollmentPlan(context.Background(), flags) + if err != nil { + t.Fatal(err) + } + if err := enroll.SaveObservations(enrollmentObservationPath(store), first.Observations); err != nil { + t.Fatal(err) + } + second, _, err := buildEnrollmentPlan(context.Background(), flags) + if err != nil { + t.Fatal(err) + } + if len(second.Selected) != 1 || second.Selected[0].SessionID != "session" { + t.Fatalf("healthy session was blocked: %#v", second) + } + for _, id := range []string{"missing", "live"} { + found := false + for _, d := range second.Decisions { + if d.SessionID != id { + continue + } + want := enroll.ReasonInvalidPath + if id == "live" { + want = enroll.ReasonWriterActive + } + for _, r := range d.Reasons { + if r == want { + found = true + } + } + } + if !found { + t.Fatalf("missing blocking reason for %s: %#v", id, second) + } + } + if _, err := detectEnrollmentWriters(context.Background(), []codex.Session{{ID: "missing-parent", RolloutPath: filepath.Join(home, "absent", "file.jsonl")}}); err != nil { + t.Fatal(err) + } +} + +func TestWindowsWriterProbeStillRejectsAccessDenied(t *testing.T) { + name := filepath.Join(t.TempDir(), "readonly.jsonl") + if err := os.WriteFile(name, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(name, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(name, 0o600) }) + _, err := detectEnrollmentWriters(context.Background(), []codex.Session{{ID: "readonly", RolloutPath: name}}) + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("access error must not be skipped: %v", err) + } +} + +func TestWindowsWriterProbeWaitsForClosedHandleAndBlocksLiveWriter(t *testing.T) { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + sessions := []codex.Session{{ID: "session", RolloutPath: path}} + writers, err := detectEnrollmentWriters(context.Background(), sessions) + if err != nil || !writers["session"] { + _ = file.Close() + t.Fatalf("live writer was not blocked: %v, %v", writers, err) + } + closed := make(chan struct{}) + go func() { time.Sleep(40 * time.Millisecond); _ = file.Close(); close(closed) }() + writers, err = detectEnrollmentWriters(context.Background(), sessions) + <-closed + if err != nil || writers["session"] { + t.Fatalf("closed backing handle was reported as an active writer: %v, %v", writers, err) + } +} + +func TestWindowsMigrationWriterProbeUsesPhysicalSources(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source.jsonl") + retained := filepath.Join(root, "retained.jsonl") + for _, path := range []string{source, retained} { + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + // The mounted alias is owned by the migration lease at cutover and + // cannot be exclusively opened. Native writers still hold source handles. + session := codex.Session{ID: "session", RolloutPath: filepath.Join(root, "unavailable-mount", "rollout.jsonl")} + active, err := probeFilesystemMigrationWriter(context.Background(), session, source, retained) + if err != nil || active { + t.Fatalf("physical source probe: active=%t err=%v", active, err) + } + file, err := os.OpenFile(source, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer file.Close() + active, err = probeFilesystemMigrationWriter(context.Background(), session, source, retained) + if err != nil || !active { + t.Fatalf("live physical writer was not blocked: active=%t err=%v", active, err) + } +} diff --git a/internal/cli/fs_enrollment_reclaim_test.go b/internal/cli/fs_enrollment_reclaim_test.go index 67048f6..50c17cb 100644 --- a/internal/cli/fs_enrollment_reclaim_test.go +++ b/internal/cli/fs_enrollment_reclaim_test.go @@ -61,7 +61,7 @@ func TestPeriodicEnrollmentDeferredReclaimStaysIncompleteWithoutError(t *testing runPeriodicEnrollment(ctx, enrollmentFlags{storeDir: store}, time.Minute, nil) }() defer func() { cancel(); <-done }() - deadline := time.Now().Add(time.Second) + deadline := time.Now().Add(3 * enrollmentPolicyPollInterval) for time.Now().Before(deadline) { progress, err := enroll.LoadProgress(enroll.ProgressPath(store)) if err == nil && progress.Phase == enroll.PhaseWaitingReclaim { @@ -72,7 +72,8 @@ func TestPeriodicEnrollmentDeferredReclaimStaysIncompleteWithoutError(t *testing } time.Sleep(time.Millisecond) } - t.Fatal("deferred cleanup was not published") + progress, err := enroll.LoadProgress(enroll.ProgressPath(store)) + t.Fatalf("deferred cleanup was not published: progress=%#v error=%v", progress, err) } func TestEnrollmentReclaimProgressWaitsForMaintenance(t *testing.T) { diff --git a/internal/cli/fs_managed_registry.go b/internal/cli/fs_managed_registry.go index 452b73b..d446687 100644 --- a/internal/cli/fs_managed_registry.go +++ b/internal/cli/fs_managed_registry.go @@ -12,6 +12,7 @@ import ( "path/filepath" "sort" + "github.com/samekind/codexfold/internal/dirsync" "github.com/samekind/codexfold/internal/storage" ) @@ -487,7 +488,7 @@ func syncManagedSessionRegistryRoot(root *os.Root) error { return err } defer directory.Close() - if err := directory.Sync(); err != nil { + if err := dirsync.Sync(directory); err != nil { return fmt.Errorf("sync managed session registry directory: %w", err) } return nil diff --git a/internal/cli/fs_managed_reload_wake_test.go b/internal/cli/fs_managed_reload_wake_test.go new file mode 100644 index 0000000..420032e --- /dev/null +++ b/internal/cli/fs_managed_reload_wake_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "context" + "testing" + "time" +) + +func TestManagedRecoveryRequestWakesHealthyReload(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + wake := make(chan struct{}, 1) + loaded := make(chan struct{}, 1) + done := make(chan struct{}) + go func() { + defer close(done) + runManagedReloadLoopAfterInitial(ctx, time.Minute, 10*time.Minute, nil, wake, + func() error { loaded <- struct{}{}; return nil }, func(error) {}) + }() + wake <- struct{}{} + select { + case <-loaded: + case <-time.After(2 * time.Second): + t.Fatal("recovery request did not wake the healthy backend's long poll") + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("reload did not stop after cancellation") + } +} diff --git a/internal/cli/fs_namespace.go b/internal/cli/fs_namespace.go index c459239..a9d0572 100644 --- a/internal/cli/fs_namespace.go +++ b/internal/cli/fs_namespace.go @@ -81,7 +81,7 @@ func newFSNamespaceActivateCommand() *cobra.Command { if err != nil { return err } - if err := waitForCanonicalNamespaceActivation(command.Context(), options.Mount, options.NativeRoot, 30*time.Second); err != nil { + if err := waitForCanonicalNamespaceActivation(command.Context(), options.Home, options.Mount, options.NativeRoot, 30*time.Second); err != nil { _, rollbackErr := sessionns.Deactivate(options) return errors.Join(fmt.Errorf("wait for canonical namespace passthrough: %w", err), rollbackErr) } diff --git a/internal/cli/fs_namespace_activation_test.go b/internal/cli/fs_namespace_activation_test.go index d26f2c4..bde6e37 100644 --- a/internal/cli/fs_namespace_activation_test.go +++ b/internal/cli/fs_namespace_activation_test.go @@ -42,6 +42,37 @@ func TestWaitForCanonicalNativePassthroughRequiresMatchingFileSize(t *testing.T) } } +func TestWaitForCanonicalNamespaceRequiresHomePathVisibility(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + mount := filepath.Join(root, "mount") + native := filepath.Join(root, "native") + for _, base := range []string{home, mount, native} { + for _, namespace := range []string{"sessions", "archived_sessions"} { + if err := os.MkdirAll(filepath.Join(base, namespace), 0o700); err != nil { + t.Fatal(err) + } + } + } + for _, base := range []string{mount, native} { + if err := os.WriteFile(filepath.Join(base, "sessions", "rollout.jsonl"), []byte("history\n"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := waitForCanonicalNativePassthrough(context.Background(), mount, native, time.Second); err != nil { + t.Fatal(err) + } + if err := waitForCanonicalNamespacePassthrough(context.Background(), home, mount, native, time.Millisecond); err == nil { + t.Fatal("a healthy mount with an invisible canonical home passed activation") + } + if err := os.WriteFile(filepath.Join(home, "sessions", "rollout.jsonl"), []byte("history\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := waitForCanonicalNamespacePassthrough(context.Background(), home, mount, native, time.Second); err != nil { + t.Fatal(err) + } +} + func TestFSNamespaceActivationRollsBackWhenPassthroughDoesNotBecomeReady(t *testing.T) { root := t.TempDir() home := filepath.Join(root, "home") @@ -67,7 +98,7 @@ func TestFSNamespaceActivationRollsBackWhenPassthroughDoesNotBecomeReady(t *test mountHealthProbe = func(string) error { return nil } t.Cleanup(func() { mountHealthProbe = previousMountProbe }) previousReadiness := waitForCanonicalNamespaceActivation - waitForCanonicalNamespaceActivation = func(context.Context, string, string, time.Duration) error { + waitForCanonicalNamespaceActivation = func(context.Context, string, string, string, time.Duration) error { return errors.New("mounted tree incomplete") } t.Cleanup(func() { waitForCanonicalNamespaceActivation = previousReadiness }) diff --git a/internal/cli/fs_namespace_readiness.go b/internal/cli/fs_namespace_readiness.go index 3ac6d16..e414f81 100644 --- a/internal/cli/fs_namespace_readiness.go +++ b/internal/cli/fs_namespace_readiness.go @@ -22,7 +22,7 @@ type canonicalNamespaceReadiness struct { // have repopulated, so readiness is intentionally distinct from mount health. var enrollmentCanonicalNamespaceReadinessProbe = probeEnrollmentCanonicalNamespaceReadiness -var waitForCanonicalNamespaceActivation = waitForCanonicalNativePassthrough +var waitForCanonicalNamespaceActivation = waitForCanonicalNamespacePassthrough func probeEnrollmentCanonicalNamespaceReadiness(home string, mount string, nativeRoot string) canonicalNamespaceReadiness { status, err := sessionns.Inspect(sessionns.Options{Home: home, Mount: mount, NativeRoot: nativeRoot, MountProbe: mountHealthProbe}) @@ -32,6 +32,9 @@ func probeEnrollmentCanonicalNamespaceReadiness(home string, mount string, nativ if err := probeCanonicalNativePassthrough(mount, nativeRoot); err != nil { return canonicalNamespaceReadiness{Active: true} } + if err := probeCanonicalNativePassthrough(home, nativeRoot); err != nil { + return canonicalNamespaceReadiness{Active: true} + } return canonicalNamespaceReadiness{Active: true, Ready: true} } @@ -44,6 +47,10 @@ func probeCanonicalNativePassthrough(mount string, nativeRoot string) error { } func waitForCanonicalNativePassthrough(ctx context.Context, mount string, nativeRoot string, timeout time.Duration) error { + return waitForCanonicalNamespacePassthrough(ctx, "", mount, nativeRoot, timeout) +} + +func waitForCanonicalNamespacePassthrough(ctx context.Context, home string, mount string, nativeRoot string, timeout time.Duration) error { if timeout <= 0 { timeout = 30 * time.Second } @@ -51,6 +58,9 @@ func waitForCanonicalNativePassthrough(ctx context.Context, mount string, native var lastErr error for { lastErr = probeCanonicalNativePassthroughMetadata(mount, nativeRoot, true) + if lastErr == nil && home != "" { + lastErr = probeCanonicalNativePassthroughMetadata(home, nativeRoot, true) + } if lastErr == nil { return nil } diff --git a/internal/cli/fs_service.go b/internal/cli/fs_service.go index bded328..9357a6c 100644 --- a/internal/cli/fs_service.go +++ b/internal/cli/fs_service.go @@ -208,15 +208,29 @@ func newFSServiceUpdateBinaryCommand() *cobra.Command { } return errors.Join(fmt.Errorf("promote filesystem service binary: %w", err), rollbackErr, cleanupErr, restartErr) } + restoreEngineBinding := func() error { return nil } + if platform == service.PlatformWindows { + restoreEngineBinding, err = service.PrepareWindowsCoreOfflineUpdate(target) + if err != nil { + rollbackErr := update.Rollback() + var restartErr error + if rollbackErr == nil { + restartErr = startPlatformService(command.Context(), platform, definition, mount) + _ = update.Commit() + } + return errors.Join(err, rollbackErr, restartErr) + } + } if err := startPlatformService(command.Context(), platform, definition, mount); err != nil { _ = stopPlatformService(command.Context(), platform, definition) + bindingErr := restoreEngineBinding() rollbackErr := update.Rollback() var restartErr error - if rollbackErr == nil { + if rollbackErr == nil && bindingErr == nil { restartErr = startPlatformService(command.Context(), platform, definition, mount) _ = update.Commit() } - return errors.Join(fmt.Errorf("start verified filesystem service binary: %w", err), rollbackErr, restartErr) + return errors.Join(fmt.Errorf("start verified filesystem service binary: %w", err), bindingErr, rollbackErr, restartErr) } if err := update.Commit(); err != nil { return fmt.Errorf("remove filesystem binary rollback artifact: %w", err) @@ -951,7 +965,10 @@ func startPlatformService(ctx context.Context, platform service.Platform, defini if err := manager.Start(ctx, label); err != nil { return err } - if _, err := manager.WaitHealthy(ctx, label, mountPoint, 15*time.Second); err != nil { + // Canonical startup validates the retained native history before + // publishing the mount. A complete multi-gigabyte home can exceed + // the short timeout used for the original engine-only preview. + if _, err := manager.WaitHealthy(ctx, label, mountPoint, 5*time.Minute); err != nil { _ = manager.Stop(ctx, label) return err } diff --git a/internal/cli/fs_service_fskit_other.go b/internal/cli/fs_service_fskit_other.go index 14fb690..3a4ad0f 100644 --- a/internal/cli/fs_service_fskit_other.go +++ b/internal/cli/fs_service_fskit_other.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -func addLiveDaemonUpdateCommand(*cobra.Command) {} +func addLiveDaemonUpdateCommand(parent *cobra.Command) { addWindowsLiveDaemonUpdateCommand(parent) } func prepareFSKitAppPlatform(context.Context, string, string, bool) (fsKitAppTransaction, error) { return nil, errors.New("native FSKit app installation is available only on macOS") diff --git a/internal/cli/fs_service_live_update_other.go b/internal/cli/fs_service_live_update_other.go new file mode 100644 index 0000000..4d92f4f --- /dev/null +++ b/internal/cli/fs_service_live_update_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package cli + +import "github.com/spf13/cobra" + +func addWindowsLiveDaemonUpdateCommand(*cobra.Command) {} diff --git a/internal/cli/fs_service_live_update_windows.go b/internal/cli/fs_service_live_update_windows.go new file mode 100644 index 0000000..c2bca0e --- /dev/null +++ b/internal/cli/fs_service_live_update_windows.go @@ -0,0 +1,100 @@ +//go:build windows + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/mountid" + "github.com/samekind/codexfold/internal/service" + "github.com/spf13/cobra" + "os" +) + +func addWindowsLiveDaemonUpdateCommand(parent *cobra.Command) { + parent.AddCommand(newWindowsCoreUpdateCommand()) + parent.AddCommand(&cobra.Command{Use: "core-protocol", Hidden: true, Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + return writeJSON(command, struct { + Version int `json:"version"` + }{mountfs.WindowsCoreProtocol}) + }}) +} + +func newWindowsCoreUpdateCommand() *cobra.Command { + var definitionPath string + var apply, jsonOutput bool + command := &cobra.Command{Use: "update-daemon-live ", Short: "Replace the Windows storage engine while preserving its resident WinFsp mount", Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, arguments []string) error { + candidate, err := filepath.Abs(arguments[0]) + if err != nil { + return err + } + definition, err := resolveServiceDefinitionPath(definitionPath) + if err != nil { + return err + } + store, err := service.DefinitionStore(service.PlatformWindows, definition) + if err != nil { + return err + } + mount, err := service.DefinitionMountPoint(service.PlatformWindows, definition) + if err != nil { + return err + } + probe, cancelProbe := context.WithTimeout(command.Context(), 5*time.Second) + defer cancelProbe() + executable := exec.CommandContext(probe, candidate, "fs", "service", "core-protocol") + configureEnrollmentChild(executable) + output, err := executable.Output() + var protocol struct { + Version int `json:"version"` + } + if err != nil || json.Unmarshal(output, &protocol) != nil || protocol.Version != mountfs.WindowsCoreProtocol { + return errors.New("candidate does not support the Windows engine protocol") + } + before, err := os.ReadFile(filepath.Join(mount, mountid.Path)) + if err != nil { + return err + } + identity, err := mountid.Parse(before) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(command.Context(), 4*time.Minute) + defer cancel() + var result mountfs.CoreUpdateResult + if err := mountfs.CallWindowsCoreControl(ctx, store, "Update", mountfs.CoreUpdateRequest{Candidate: candidate, Apply: apply}, &result); err != nil { + return err + } + if apply { + after, err := os.ReadFile(filepath.Join(mount, mountid.Path)) + if err != nil { + return err + } + current, err := mountid.Parse(after) + if err != nil { + return err + } + if current.Nonce != identity.Nonce || current.BuildSHA256 != result.CandidateSHA256 { + return errors.New("engine switched but the mounted identity could not be verified") + } + } + if jsonOutput { + return writeJSON(command, result) + } + _, err = fmt.Fprintf(command.OutOrStdout(), "dry_run=%t changed=%t old_engine=%d new_engine=%d mount_preserved=%t build=%s recovery=%s\n", result.DryRun, result.Changed, result.PreviousPID, result.ReplacementPID, result.MountPreserved, result.Build, result.RecoveryBinary) + return err + }} + addServiceDefinitionFlags(command, &definitionPath) + command.Flags().BoolVar(&apply, "apply", false, "Replace the quiescent engine without stopping the mount or clients") + command.Flags().BoolVar(&jsonOutput, "json", false, "Emit JSON output") + return command +} diff --git a/internal/cli/fs_service_runtime_windows.go b/internal/cli/fs_service_runtime_windows.go index 1dd4c8c..431c284 100644 --- a/internal/cli/fs_service_runtime_windows.go +++ b/internal/cli/fs_service_runtime_windows.go @@ -8,17 +8,101 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" + "syscall" "time" "github.com/samekind/codexfold/internal/mountfs" "github.com/samekind/codexfold/internal/service" "github.com/spf13/cobra" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" ) func addPlatformServiceCommands(parent *cobra.Command) { parent.AddCommand(newFSServiceRunCommand()) + parent.AddCommand(newFSServiceChildCommand()) +} + +func newFSServiceChildCommand() *cobra.Command { + var definitionPath string + command := &cobra.Command{ + Use: "child", Hidden: true, Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !filepath.IsAbs(definitionPath) { + return errors.New("absolute Windows service definition path is required") + } + definition, err := os.ReadFile(filepath.Clean(definitionPath)) + if err != nil { + return err + } + config, err := service.ParseWindowsConfig(definition) + if err != nil { + return err + } + if config.ServiceName != serviceLabel { + return errors.New("Windows service definition name does not match this binary") + } + if err := os.Setenv("CODEXFOLD_WINDOWS_GLOBAL_MOUNT", "1"); err != nil { + return err + } + ctx, cancel := context.WithCancel(command.Context()) + defer cancel() + // The SCM parent owns this pipe. Closing it, including if the + // parent unexpectedly exits, requests a normal WinFsp unmount. + go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() + serve := newFSServeCommand() + arguments := append([]string(nil), config.Arguments[2:]...) + arguments = append(arguments, "--frontend=windows-proxy") + serve.SetArgs(arguments) + serve.SetOut(command.OutOrStdout()) + serve.SetErr(command.ErrOrStderr()) + serve.SilenceErrors, serve.SilenceUsage = true, true + err = serve.ExecuteContext(ctx) + if errors.Is(err, context.Canceled) { + return nil + } + return err + }, + } + command.Flags().StringVar(&definitionPath, "definition", "", "Absolute Windows service definition path") + return command +} + +func runWindowsServiceChild(ctx context.Context, config service.WindowsConfig, definitionPath string, stdout, stderr io.Writer) error { + // WinFsp's FUSE loop calls FspServiceRun internally. It cannot run in + // the process already hosting Go's svc.Run (ERROR_SERVICE_ALREADY_RUNNING). + child := exec.Command(config.BinaryPath, "fs", "service", "child", "--definition", definitionPath) + child.Stdout, child.Stderr = stdout, stderr + child.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + control, err := child.StdinPipe() + if err != nil { + return err + } + defer control.Close() + if err := child.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- child.Wait() }() + select { + case err := <-done: + if err == nil && ctx.Err() == nil { + return errors.New("Windows filesystem child exited unexpectedly") + } + return err + case <-ctx.Done(): + _ = control.Close() + select { + case err := <-done: + return err + case <-time.After(25 * time.Second): + _ = child.Process.Kill() + <-done + return errors.New("Windows filesystem child did not finish unmounting") + } + } } func newFSServiceRunCommand() *cobra.Command { @@ -58,16 +142,20 @@ func newFSServiceRunCommand() *cobra.Command { return err } defer closeLogs() + // WinFsp writes startup diagnostics through the process standard + // handles, independently of Cobra's writers. SCM supplies no console. + os.Stdout = stdout.(*os.File) + os.Stderr = stderr.(*os.File) + if err := windows.SetStdHandle(windows.STD_OUTPUT_HANDLE, windows.Handle(os.Stdout.Fd())); err != nil { + return err + } + if err := windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(os.Stderr.Fd())); err != nil { + return err + } handler := &windowsFSService{ log: stderr, run: func(ctx context.Context) error { - serve := newFSServeCommand() - serve.SetArgs(config.Arguments[2:]) - serve.SetOut(stdout) - serve.SetErr(stderr) - serve.SilenceErrors = true - serve.SilenceUsage = true - return serve.ExecuteContext(ctx) + return runWindowsServiceChild(ctx, config, definitionPath, stdout, stderr) }, } return svc.Run(config.ServiceName, handler) @@ -87,6 +175,7 @@ func (s *windowsFSService) Execute(_ []string, requests <-chan svc.ChangeRequest defer cancel() done := make(chan error, 1) changes <- svc.Status{State: svc.StartPending, CheckPoint: 1, WaitHint: 15000} + _, _ = fmt.Fprintln(s.log, "starting Windows filesystem service") go func() { done <- s.run(ctx) }() running := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} changes <- running diff --git a/internal/cli/fs_status_other.go b/internal/cli/fs_status_other.go new file mode 100644 index 0000000..822c024 --- /dev/null +++ b/internal/cli/fs_status_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package cli + +import ( + "context" + "github.com/samekind/codexfold/internal/mountfs" + "io" +) + +func startPlatformDaemonStatus(context.Context, string, string, *mountfs.IOActivityCounter, io.Writer) func() { + return func() {} +} diff --git a/internal/cli/fs_status_windows.go b/internal/cli/fs_status_windows.go new file mode 100644 index 0000000..402152b --- /dev/null +++ b/internal/cli/fs_status_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package cli + +import ( + "context" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/fskitstatus" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/service" +) + +// The tray consumes aggregate status only; it never reads rollout contents. +func startPlatformDaemonStatus(parent context.Context, store, mount string, activity *mountfs.IOActivityCounter, diagnostics io.Writer) func() { + ctx, cancel := context.WithCancel(parent) + done := make(chan struct{}) + go func() { + defer close(done) + path := filepath.Join(store, "fs", "status", "daemon.json") + instance := rand.Text() + var sequence uint64 + publish := func(state, detail string) { + sequence++ + totals := activity.Snapshot() + snapshot := fskitstatus.Snapshot{ + Component: "daemon", State: state, Detail: detail, + MountPoint: mount, ResourcePath: filepath.Join(store, "fs"), PID: os.Getpid(), + PublisherInstanceID: instance, BackendID: filepath.Clean(store), ObservationSequence: sequence, + ReadBytesTotal: &totals.ReadBytes, WrittenBytesTotal: &totals.WrittenBytes, + } + if err := fskitstatus.Write(path, snapshot); err != nil { + _, _ = fmt.Fprintf(diagnostics, "write Windows daemon status: %v\n", err) + } + } + publish("starting", "Waiting for the WinFsp mount") + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + publish("stopped", "Filesystem service stopped") + return + case <-ticker.C: + if err := service.ProbeMount(mount); err != nil { + publish("unavailable", err.Error()) + } else { + publish("healthy", "WinFsp mount is responding") + } + } + } + }() + return func() { cancel(); <-done } +} diff --git a/internal/cli/fs_test.go b/internal/cli/fs_test.go index 279a67b..d1367fc 100644 --- a/internal/cli/fs_test.go +++ b/internal/cli/fs_test.go @@ -223,7 +223,7 @@ func TestFSNamespaceActivateAndDeactivateCommandsPreserveNativeFiles(t *testing. t.Cleanup(func() { mountHealthProbe = previousProbe }) mountHealthProbe = func(string) error { return nil } previousReadiness := waitForCanonicalNamespaceActivation - waitForCanonicalNamespaceActivation = func(context.Context, string, string, time.Duration) error { return nil } + waitForCanonicalNamespaceActivation = func(context.Context, string, string, string, time.Duration) error { return nil } t.Cleanup(func() { waitForCanonicalNamespaceActivation = previousReadiness }) executeFS(t, []string{ "fs", "namespace", "activate", "--apply", @@ -1916,7 +1916,12 @@ func TestPeriodicEnrollmentRecoversAfterMalformedPolicyIsFixed(t *testing.T) { started := make(chan struct{}, 1) runServiceEnrollmentCycle = func(context.Context, enrollmentFlags, enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { - started <- struct{}{} + // Only the first start is observed below. Later 1ms cycles must not + // block the scheduler or cancellation while status is being polled. + select { + case started <- struct{}{}: + default: + } return FSEnrollmentApplyResult{}, nil } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/cli/fs_windows_core.go b/internal/cli/fs_windows_core.go new file mode 100644 index 0000000..b849524 --- /dev/null +++ b/internal/cli/fs_windows_core.go @@ -0,0 +1,125 @@ +//go:build windows + +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/service" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type windowsCoreBootKey struct{} + +func prepareWindowsStorageEngine(command *cobra.Command) (func(), error) { + boot, err := mountfs.ReadWindowsCoreBoot(os.Stdin) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(command.Context()) + ctx = context.WithValue(ctx, windowsCoreBootKey{}, boot) + command.SetContext(ctx) + go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() + return cancel, nil +} + +func serveWindowsStorageEngine(ctx context.Context, command *cobra.Command, filesystem *mountfs.Filesystem, onReady func()) error { + boot, ok := command.Context().Value(windowsCoreBootKey{}).(mountfs.WindowsCoreBoot) + if !ok { + return errors.New("storage engine has no private parent binding") + } + return mountfs.ServeWindowsCore(ctx, filesystem, boot, onReady) +} + +func serveWindowsResidentHost(command *cobra.Command, home, store, mount string, foreground bool) error { + lock, err := service.AcquireProcessLock(filepath.Join(store, "fs", "frontend.lock")) + if err != nil { + return err + } + defer lock.Close() + binary, err := os.Executable() + if err != nil { + return err + } + root := filepath.Join(store, "fs", "windows-core") + if os.Getenv("CODEXFOLD_WINDOWS_GLOBAL_MOUNT") == "1" { + // SYSTEM never trusts an executable pointer from the user's store. + root = filepath.Join(filepath.Dir(binary), "Core") + } + arguments := []string{"fs", "serve"} + command.Flags().Visit(func(flag *pflag.Flag) { + if flag.Name != "frontend" { + arguments = append(arguments, "--"+flag.Name+"="+flag.Value.String()) + } + }) + arguments = append(arguments, "--frontend=windows-engine") + host, err := mountfs.NewWindowsCoreHost(command.Context(), mountfs.WindowsCoreOptions{ + Binary: binary, Root: root, Store: store, Arguments: arguments, + Stdout: command.OutOrStdout(), Stderr: command.ErrOrStderr(), + BeforeUpdate: func() error { return requireWindowsEnrollmentPaused(store) }, + }) + if err != nil { + return err + } + defer host.Close() + stopControl, err := host.ServeControl(command.Context()) + if err != nil { + return err + } + defer stopControl() + activity := &mountfs.IOActivityCounter{} + var recorder func(string) + if trace, _ := command.Flags().GetString("operation-trace"); trace != "" { + var closer io.Closer + recorder, closer, err = newOperationRecorder(trace) + if err != nil { + return err + } + defer closer.Close() + } + stopStatus := startPlatformDaemonStatus(command.Context(), store, mount, activity, command.ErrOrStderr()) + defer stopStatus() + return mountfs.Mount(command.Context(), mountfs.HostOptions{ + MountPoint: mount, StorageRoot: store, NamespaceRoot: home, Backend: host, + BackendBuild: host.BuildSHA256, BackendHealthy: host.Healthy, + BuildSHA256: host.BuildSHA256(), Foreground: foreground, Activity: activity, + OperationRecorder: recorder, + }) +} + +func requireWindowsEnrollmentPaused(store string) error { + for _, paths := range [][2]string{{enroll.ControlPath(store), enroll.ProgressPath(store)}, {enroll.WorkerControlPath(store), enroll.WorkerProgressPath(store)}} { + policy, err := enroll.LoadControl(paths[0]) + if err != nil { + return err + } + if policy.Present && policy.Enabled { + return errors.New("pause automatic folding before a live engine update") + } + if _, err := os.Stat(paths[1]); errors.Is(err, os.ErrNotExist) && !policy.Present { + continue + } else if err != nil { + return fmt.Errorf("cannot inspect the paused folding acknowledgement: %w", err) + } + progress, err := enroll.LoadProgress(paths[1]) + if errors.Is(err, os.ErrNotExist) && !policy.Present { + continue + } + if err != nil { + return fmt.Errorf("cannot verify the paused folding acknowledgement: %w", err) + } + if progress.Phase != enroll.PhaseDisabled || progress.UpdatedAt.IsZero() || time.Since(progress.UpdatedAt) > 30*time.Second || time.Until(progress.UpdatedAt) > 5*time.Second { + return errors.New("wait for a fresh paused folding acknowledgement before a live engine update") + } + } + return nil +} diff --git a/internal/cli/fs_windows_core_other.go b/internal/cli/fs_windows_core_other.go new file mode 100644 index 0000000..afd1d54 --- /dev/null +++ b/internal/cli/fs_windows_core_other.go @@ -0,0 +1,20 @@ +//go:build !windows + +package cli + +import ( + "context" + "errors" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/spf13/cobra" +) + +func prepareWindowsStorageEngine(*cobra.Command) (func(), error) { + return nil, errors.New("Windows engine unavailable") +} +func serveWindowsStorageEngine(context.Context, *cobra.Command, *mountfs.Filesystem, func()) error { + return errors.New("Windows engine unavailable") +} +func serveWindowsResidentHost(*cobra.Command, string, string, string, bool) error { + return errors.New("Windows engine unavailable") +} diff --git a/internal/cli/fs_windows_core_test.go b/internal/cli/fs_windows_core_test.go new file mode 100644 index 0000000..bb38d45 --- /dev/null +++ b/internal/cli/fs_windows_core_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package cli + +import ( + "github.com/samekind/codexfold/internal/enroll" + "testing" + "time" +) + +func TestWindowsCorePauseAcknowledgements(t *testing.T) { + store := t.TempDir() + if err := requireWindowsEnrollmentPaused(store); err != nil { + t.Fatal("no loop configured", err) + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err != nil { + t.Fatal("absent external worker must not block a fresh builtin pause", err) + } + policy := enroll.Control{Present: true, Enabled: false, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5} + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err == nil { + t.Fatal("missing configured worker acknowledgement accepted") + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseReclaiming, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err == nil { + t.Fatal("active worker accepted") + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseDisabled, UpdatedAt: time.Now().Add(-time.Minute)}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err == nil { + t.Fatal("stale pause accepted") + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err != nil { + t.Fatal(err) + } +} diff --git a/internal/dirsync/sync_test.go b/internal/dirsync/sync_test.go new file mode 100644 index 0000000..fed6882 --- /dev/null +++ b/internal/dirsync/sync_test.go @@ -0,0 +1,22 @@ +package dirsync + +import ( + "os" + "testing" +) + +func TestDirectoryAndClosedHandle(t *testing.T) { + directory, err := os.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := Sync(directory); err != nil { + t.Fatal(err) + } + if err := directory.Close(); err != nil { + t.Fatal(err) + } + if err := Sync(directory); err == nil { + t.Fatal("closed directory was accepted") + } +} diff --git a/internal/dirsync/sync_unix.go b/internal/dirsync/sync_unix.go new file mode 100644 index 0000000..ad1883b --- /dev/null +++ b/internal/dirsync/sync_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +// Package dirsync isolates the platform-specific directory flush policy. +package dirsync + +import "os" + +func Sync(directory *os.File) error { return directory.Sync() } diff --git a/internal/dirsync/sync_windows.go b/internal/dirsync/sync_windows.go new file mode 100644 index 0000000..ca2d02c --- /dev/null +++ b/internal/dirsync/sync_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package dirsync + +import ( + "fmt" + "os" +) + +// Sync validates the directory handle on Windows. Windows does not expose the +// POSIX fsync-directory operation on a read-only directory handle: File.Sync +// calls FlushFileBuffers and fails with ERROR_ACCESS_DENIED. Data files must +// still be flushed before publication. Existing MoveFileEx write-through paths +// remain in use, but this is NOT a power-loss durability guarantee for directory +// metadata. Windows remains a preview until its crash/power-loss gates pass. +func Sync(directory *os.File) error { + info, err := directory.Stat() + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("directory sync requires a directory: %s", directory.Name()) + } + return nil +} diff --git a/internal/enroll/control.go b/internal/enroll/control.go index befb6df..ffc17a7 100644 --- a/internal/enroll/control.go +++ b/internal/enroll/control.go @@ -2,6 +2,8 @@ package enroll import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -15,14 +17,17 @@ const ( controlVersion = 1 progressVersion = 1 - PhaseDisabled = "disabled" - PhaseIdle = "idle" - PhaseChecking = "checking" - PhaseFolding = "folding" - PhasePacking = "packing" - PhaseMigrating = "migrating" - PhaseReclaiming = "reclaiming" - PhaseWaitingReclaim = "waiting-reclaim" + PhaseDisabled = "disabled" + PhaseIdle = "idle" + PhaseChecking = "checking" + PhaseFolding = "folding" + PhasePacking = "packing" + PhaseMigrating = "migrating" + PhaseReclaiming = "reclaiming" + PhaseWaitingReclaim = "waiting-reclaim" + PhaseWaitingFilesystem = "waiting-filesystem" + PhaseConfigInvalid = "config-invalid" + PhaseStopped = "stopped" ) // Control is the hot-reloaded automatic-folding policy written by the Host GUI @@ -39,6 +44,11 @@ type Control struct { // cannot be decoded or validated. A malformed policy must fail closed // instead of silently reviving stale process flags. ConfigError string + // Runtime gating is separate from the persisted user's intent. These fields + // are internal and never written back into the policy file. + RequestedEnabled bool + BlockedPhase string + BlockReason string } type controlFile struct { @@ -97,14 +107,36 @@ func ProgressPath(store string) string { return filepath.Join(filepath.Clean(store), "enrollment", "status.json") } +func WorkerControlPath(store string) string { + return filepath.Join(filepath.Clean(store), "enrollment", "worker-policy.json") +} + +func WorkerProgressPath(store string) string { + return filepath.Join(filepath.Clean(store), "enrollment", "worker-status.json") +} + func LoadControl(path string) (Control, error) { - data, err := os.ReadFile(path) + control, _, err := LoadControlRevision(path) + return control, err +} + +// LoadControlRevision binds the displayed settings to the exact bytes read. +// A malformed file still has a revision, allowing an explicit GUI repair to +// reject a concurrent replacement rather than silently overwriting it. +func LoadControlRevision(path string) (Control, string, error) { + data, err := readControlFile(path) if errors.Is(err, os.ErrNotExist) { - return Control{}, nil + return Control{}, "", nil } if err != nil { - return Control{}, err + return Control{}, "", err } + digest := sha256.Sum256(data) + control, err := decodeControl(data) + return control, hex.EncodeToString(digest[:]), err +} + +func decodeControl(data []byte) (Control, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var stored controlFile @@ -164,7 +196,7 @@ func SaveControl(path string, control Control) error { } func LoadProgress(path string) (Progress, error) { - data, err := os.ReadFile(path) + data, err := readControlFile(path) if errors.Is(err, os.ErrNotExist) { return Progress{}, nil } @@ -298,7 +330,7 @@ func writeAtomicJSON(path string, temporaryPattern string, value any) error { if err := temporary.Close(); err != nil { return err } - if err := os.Rename(temporaryPath, path); err != nil { + if err := replaceControlFile(temporaryPath, path); err != nil { return err } return syncObservationDirectory(directory) diff --git a/internal/enroll/control_read_other.go b/internal/enroll/control_read_other.go new file mode 100644 index 0000000..366b84d --- /dev/null +++ b/internal/enroll/control_read_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package enroll + +import "os" + +func readControlFile(path string) ([]byte, error) { return os.ReadFile(path) } + +func replaceControlFile(source, target string) error { return os.Rename(source, target) } diff --git a/internal/enroll/control_read_windows.go b/internal/enroll/control_read_windows.go new file mode 100644 index 0000000..44e7037 --- /dev/null +++ b/internal/enroll/control_read_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package enroll + +import ( + "errors" + "io" + "os" + "time" + + "golang.org/x/sys/windows" +) + +// Policy/status writers publish by atomic replacement. Readers must allow +// delete sharing so a concurrent hot-policy update can replace their old file. +func readControlFile(path string) ([]byte, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + file := os.NewFile(uintptr(handle), path) + defer file.Close() + return io.ReadAll(file) +} + +func replaceControlFile(source, target string) error { + deadline := time.Now().Add(time.Second) + for { + err := os.Rename(source, target) + if err == nil { + return nil + } + if !(errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_LOCK_VIOLATION)) || time.Now().After(deadline) { + return err + } + // An older host or external reader may briefly omit delete sharing. + // Retry the atomic replacement; never remove the visible policy. + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/enroll/control_windows_test.go b/internal/enroll/control_windows_test.go new file mode 100644 index 0000000..80ba98d --- /dev/null +++ b/internal/enroll/control_windows_test.go @@ -0,0 +1,33 @@ +//go:build windows + +package enroll + +import ( + "os" + "testing" + "time" +) + +func TestPolicyUpdateWaitsForLegacyReaderWithoutDeleteSharing(t *testing.T) { + path := ControlPath(t.TempDir()) + control := Control{Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := SaveControl(path, control); err != nil { + t.Fatal(err) + } + reader, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + closed := make(chan struct{}) + go func() { time.Sleep(50 * time.Millisecond); _ = reader.Close(); close(closed) }() + control.Enabled = true + err = SaveControl(path, control) + <-closed + if err != nil { + t.Fatal(err) + } + got, err := LoadControl(path) + if err != nil || !got.Enabled { + t.Fatalf("updated policy not visible: %#v %v", got, err) + } +} diff --git a/internal/fskitstatus/status.go b/internal/fskitstatus/status.go index bcbea0b..846acc0 100644 --- a/internal/fskitstatus/status.go +++ b/internal/fskitstatus/status.go @@ -12,6 +12,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -523,7 +525,7 @@ func syncRootDirectory(root *os.Root) error { if err != nil { return err } - syncErr := directory.Sync() + syncErr := dirsync.Sync(directory) closeErr := directory.Close() return errors.Join(syncErr, closeErr) } diff --git a/internal/mountfs/drive_mount_other.go b/internal/mountfs/drive_mount_other.go new file mode 100644 index 0000000..942feac --- /dev/null +++ b/internal/mountfs/drive_mount_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package mountfs + +func prepareWindowsDriveMount(string) (bool, error) { return false, nil } +func platformMountTarget(path string) string { return path } +func platformMountSecurity(string) ([]string, error) { return nil, nil } diff --git a/internal/mountfs/drive_mount_windows.go b/internal/mountfs/drive_mount_windows.go new file mode 100644 index 0000000..39289af --- /dev/null +++ b/internal/mountfs/drive_mount_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package mountfs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func windowsDriveRoot(path string) (byte, bool) { + path = filepath.Clean(path) + if len(path) != 3 || path[1] != ':' || path[2] != '\\' { + return 0, false + } + letter := strings.ToUpper(path[:1])[0] + return letter, letter >= 'A' && letter <= 'Z' +} + +func prepareWindowsDriveMount(path string) (bool, error) { + letter, drive := windowsDriveRoot(path) + if !drive { + return false, nil + } + drives, err := windows.GetLogicalDrives() + if err != nil { + return true, err + } + if drives&(1<= int64(len(f.mountIdentity)) { + identity := f.healthBytes() + if offset < 0 || offset >= int64(len(identity)) { f.recordIO("read", name, handle, offset, len(destination), 0) return 0 } - n := copy(destination, f.mountIdentity[offset:]) + n := copy(destination, identity[offset:]) f.recordIO("read", name, handle, offset, len(destination), n) return n } - n, errno := f.core.Read(handle, destination, offset) + n, errno := f.operations().Read(handle, destination, offset) if errno != 0 { - result := -int(errno) + result := fuseResult(errno) f.recordIO("read", name, handle, offset, len(destination), result) return result } f.recordIO("read", name, handle, offset, len(destination), n) + f.activity.recordRead(n) return n } func (f *fuseFilesystem) Write(name string, data []byte, offset int64, handle uint64) int { - n, errno := f.core.Write(handle, data, offset) + n, errno := f.operations().Write(handle, data, offset) if errno != 0 { - result := -int(errno) + result := fuseResult(errno) f.recordIO("write", name, handle, offset, len(data), result) return result } f.recordIO("write", name, handle, offset, len(data), n) + f.activity.recordWrite(n) return n } func (f *fuseFilesystem) Truncate(name string, size int64, handle uint64) int { var errno syscall.Errno if handle == 0 || handle == ^uint64(0) { - errno = f.core.TruncatePath(name, size) + errno = f.operations().TruncatePath(name, size) } else { - errno = f.core.Truncate(handle, size) + errno = f.operations().Truncate(handle, size) } - result := -int(errno) + result := fuseResult(errno) f.record(fmt.Sprintf("truncate kind=%s handle=%d size=%d result=%d", operationKind(name), handle, size, result)) return result } @@ -215,7 +254,7 @@ func (f *fuseFilesystem) Flush(name string, handle uint64) int { f.recordHandleResult("flush", name, handle, 0) return 0 } - result := -int(f.core.Flush(handle)) + result := fuseResult(f.operations().Flush(handle)) f.recordHandleResult("flush", name, handle, result) return result } @@ -225,7 +264,7 @@ func (f *fuseFilesystem) Fsync(name string, dataOnly bool, handle uint64) int { f.record(fmt.Sprintf("fsync kind=%s handle=%d datasync=%t result=0", operationKind(name), handle, dataOnly)) return 0 } - result := -int(f.core.Fsync(handle)) + result := fuseResult(f.operations().Fsync(handle)) f.record(fmt.Sprintf("fsync kind=%s handle=%d datasync=%t result=%d", operationKind(name), handle, dataOnly, result)) return result } @@ -235,49 +274,49 @@ func (f *fuseFilesystem) Release(name string, handle uint64) int { f.recordHandleResult("release", name, handle, 0) return 0 } - result := -int(f.core.Release(handle)) + result := fuseResult(f.operations().Release(handle)) f.recordHandleResult("release", name, handle, result) return result } func (f *fuseFilesystem) Mkdir(name string, mode uint32) int { - result := -int(f.core.Mkdir(name, mode)) + result := fuseResult(f.operations().Mkdir(name, mode)) f.recordResult("mkdir", name, result) return result } func (f *fuseFilesystem) Rmdir(name string) int { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("rmdir", name, result) return result } func (f *fuseFilesystem) Link(oldName string, _ string) int { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("link", oldName, result) return result } func (f *fuseFilesystem) Symlink(_ string, newName string) int { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("symlink", newName, result) return result } func (f *fuseFilesystem) Readlink(name string) (int, string) { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("readlink", name, result) return result, "" } func (f *fuseFilesystem) Rename(oldName string, newName string) int { - result := -int(f.core.Rename(oldName, newName)) + result := fuseResult(f.operations().Rename(oldName, newName)) f.recordResult("rename", oldName, result) return result } func (f *fuseFilesystem) Unlink(name string) int { - result := -int(f.core.Unlink(name)) + result := fuseResult(f.operations().Unlink(name)) f.recordResult("unlink", name, result) return result } @@ -287,11 +326,14 @@ func (f *fuseFilesystem) Access(name string, _ uint32) int { if cleanPath(name) == "/"+mountid.Path { return 0 } - _, errno := f.core.Getattr(name) - return -int(errno) + _, errno := f.operations().Getattr(name) + return fuseResult(errno) } func (f *fuseFilesystem) Chmod(name string, mode uint32) int { + if remote, ok := f.backend.(HostMetadataBackend); ok { + return fuseResult(remote.Metadata("chmod", name, mode, 0, time.Time{}, time.Time{})) + } path, managed, errc := f.metadataPath(name) if errc != 0 { f.recordResult("chmod", name, errc) @@ -306,6 +348,9 @@ func (f *fuseFilesystem) Chmod(name string, mode uint32) int { } func (f *fuseFilesystem) Chown(name string, uid uint32, gid uint32) int { + if remote, ok := f.backend.(HostMetadataBackend); ok { + return fuseResult(remote.Metadata("chown", name, uid, gid, time.Time{}, time.Time{})) + } path, managed, errc := f.metadataPath(name) if errc != 0 { f.recordResult("chown", name, errc) @@ -320,6 +365,13 @@ func (f *fuseFilesystem) Chown(name string, uid uint32, gid uint32) int { } func (f *fuseFilesystem) Utimens(name string, times []fuse.Timespec) int { + if remote, ok := f.backend.(HostMetadataBackend); ok { + if len(times) != 2 { + return fuseResult(syscall.EINVAL) + } + return fuseResult(remote.Metadata("utimens", name, 0, 0, + time.Unix(times[0].Sec, times[0].Nsec), time.Unix(times[1].Sec, times[1].Nsec))) + } path, managed, errc := f.metadataPath(name) if errc != 0 { f.recordResult("utimens", name, errc) @@ -328,7 +380,7 @@ func (f *fuseFilesystem) Utimens(name string, times []fuse.Timespec) int { result := 0 if !managed { if len(times) != 2 { - result = -int(syscall.EINVAL) + result = fuseResult(syscall.EINVAL) } else { result = setFileTimes(path, times) } @@ -383,13 +435,16 @@ func (f *fuseFilesystem) Removexattr(name string, attribute string) int { } func (f *fuseFilesystem) xattrPath(name string, create bool) (string, int) { + if f.core == nil { + return "", fuseResult(syscall.ENOSYS) + } cleaned := cleanPath(name) if _, _, errno := f.core.sessionForPath(cleaned); errno == 0 { f.core.mu.RLock() root := f.core.nativeRoot f.core.mu.RUnlock() if root == "" { - return "", -int(syscall.ENOTSUP) + return "", fuseResult(syscall.ENOTSUP) } carrier := managedXattrCarrier(root, cleaned) if create { @@ -409,7 +464,7 @@ func (f *fuseFilesystem) xattrPath(name string, create bool) (string, int) { if native, ok := f.core.nativePath(cleaned); ok { return native, 0 } - return "", -int(syscall.ENOENT) + return "", fuseResult(syscall.ENOENT) } func (f *fuseFilesystem) metadataPath(name string) (string, bool, int) { @@ -420,7 +475,7 @@ func (f *fuseFilesystem) metadataPath(name string) (string, bool, int) { if native, ok := f.core.nativePath(cleaned); ok { return native, false, 0 } - return "", false, -int(syscall.ENOENT) + return "", false, fuseResult(syscall.ENOENT) } func unixResult(err error) int { @@ -429,9 +484,9 @@ func unixResult(err error) int { } var errno syscall.Errno if errors.As(err, &errno) { - return -int(errno) + return fuseResult(errno) } - return -int(syscall.EIO) + return fuseResult(syscall.EIO) } func (f *fuseFilesystem) record(operation string) { @@ -491,6 +546,12 @@ func translateOpenFlags(flags int) int { if flags&fuse.O_EXCL != 0 { translated |= os.O_EXCL } + if runtime.GOOS == "windows" && translated&os.O_WRONLY != 0 { + // WinFsp may read boundary blocks through a write handle while + // preparing an append. Windows access checks have already approved + // the caller; the backing handle needs both capabilities. + translated = translated&^os.O_WRONLY | os.O_RDWR + } return translated } @@ -515,11 +576,22 @@ func mountHost(ctx context.Context, options HostOptions) (result error) { if err != nil { return fmt.Errorf("generate mount identity: %w", err) } + statRoot := options.StorageRoot + if statRoot == "" { + statRoot = filepath.Dir(options.MountPoint) + if runtime.GOOS == "windows" && statRoot == options.MountPoint { + return errors.New("Windows drive mounts require a physical storage root") + } + } filesystem := &fuseFilesystem{ - core: options.Filesystem, - recorder: options.OperationRecorder, - mountIdentity: []byte(identity), - statRoot: filepath.Dir(options.MountPoint), + core: options.Filesystem, + backend: options.Backend, + backendBuild: options.BackendBuild, + backendHealthy: options.BackendHealthy, + recorder: options.OperationRecorder, + mountIdentity: []byte(identity), + statRoot: statRoot, + activity: options.Activity, } host := fuse.NewFileSystemHost(filesystem) backing, err := prepareMountedBacking(options.MountPoint) @@ -533,12 +605,28 @@ func mountHost(ctx context.Context, options HostOptions) (result error) { } }() arguments := []string{"-o", "fsname=codexfold", "-o", "default_permissions", "-o", "attr_timeout=0", "-o", "entry_timeout=0", "-o", "negative_timeout=0"} + securityArguments, err := platformMountSecurity(options.NamespaceRoot) + if err != nil { + return err + } + arguments = append(arguments, securityArguments...) if options.Foreground { arguments = append(arguments, "-f") } if runtime.GOOS == "darwin" { arguments = append(arguments, "-o", "backend=nfs", "-o", "volname=CodexFold") } + mountTarget := platformMountTarget(options.MountPoint) + if runtime.GOOS == "windows" && filepath.Dir(options.MountPoint) == options.MountPoint { + arguments = append(arguments, "-o", "volname=CodexFold") + if !strings.HasPrefix(mountTarget, `\\.\`) { + // Foreground previews use a network drive so ordinary clients can + // canonicalize its paths without administrator privileges. SCM + // uses a Mount Manager disk drive, which also supports the NTFS + // junctions in the canonical session namespace. + arguments = append(arguments, "-o", "VolumePrefix="+windowsVolumePrefix(options.NamespaceRoot, options.MountPoint)) + } + } done := make(chan struct{}) go func() { select { @@ -561,7 +649,7 @@ func mountHost(ctx context.Context, options HostOptions) (result error) { } policyDone <- err }() - mounted := host.Mount(options.MountPoint, arguments) + mounted := host.Mount(mountTarget, arguments) cancelPolicy() policyErr := <-policyDone backingErr := backing.Close() diff --git a/internal/mountfs/host_platform_windows.go b/internal/mountfs/host_platform_windows.go index 330a020..ccf56e5 100644 --- a/internal/mountfs/host_platform_windows.go +++ b/internal/mountfs/host_platform_windows.go @@ -37,17 +37,17 @@ func setFileTimes(path string, times []fuse.Timespec) int { } func setExtendedAttribute(string, string, []byte, int) int { - return -int(syscall.ENOSYS) + return fuseResult(syscall.ENOSYS) } func getExtendedAttribute(string, string) (int, []byte) { - return -int(syscall.ENOSYS), nil + return fuseResult(syscall.ENOSYS), nil } func listExtendedAttributes(string) (int, []string) { - return -int(syscall.ENOSYS), nil + return fuseResult(syscall.ENOSYS), nil } func removeExtendedAttribute(string, string) int { - return -int(syscall.ENOSYS) + return fuseResult(syscall.ENOSYS) } diff --git a/internal/mountfs/host_safety_test.go b/internal/mountfs/host_safety_test.go index 497df42..50c345e 100644 --- a/internal/mountfs/host_safety_test.go +++ b/internal/mountfs/host_safety_test.go @@ -3,6 +3,7 @@ package mountfs import ( "os" "path/filepath" + "runtime" "testing" ) @@ -21,6 +22,9 @@ func TestPrepareMountPointRejectsOrdinaryFiles(t *testing.T) { } func TestPrepareMountPointCreatesAndSealsMissingBackingDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("WinFsp requires an absent mount point") + } mountPoint := filepath.Join(t.TempDir(), "missing", "mount") if err := prepareMountPoint(mountPoint); err != nil { t.Fatal(err) @@ -35,6 +39,9 @@ func TestPrepareMountPointCreatesAndSealsMissingBackingDirectory(t *testing.T) { } func TestPrepareMountPointSealsEmptyBackingDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("WinFsp requires an absent mount point") + } mountPoint := filepath.Join(t.TempDir(), "mount") if err := os.MkdirAll(mountPoint, 0o700); err != nil { t.Fatal(err) @@ -63,6 +70,9 @@ func TestPrepareMountPointRejectsSymlink(t *testing.T) { } mountPoint := filepath.Join(root, "mount") if err := os.Symlink(target, mountPoint); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlink prerequisite unavailable: %v", err) + } t.Fatal(err) } diff --git a/internal/mountfs/host_safety_windows_test.go b/internal/mountfs/host_safety_windows_test.go new file mode 100644 index 0000000..e8be36a --- /dev/null +++ b/internal/mountfs/host_safety_windows_test.go @@ -0,0 +1,45 @@ +//go:build windows + +package mountfs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPrepareWinFspMountPointLeavesAbsentDirectory(t *testing.T) { + for _, exists := range []bool{false, true} { + path := filepath.Join(t.TempDir(), "parent", "mount") + if exists { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := prepareMountPoint(path); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("mount point must be absent before WinFsp starts: %v", err) + } + if err := os.WriteFile(filepath.Join(path, "unexpected.jsonl"), []byte("write"), 0o600); err == nil { + t.Fatal("unmounted path accepted a write") + } + if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() { + t.Fatalf("mount parent missing: %v", err) + } + } +} + +func TestPrepareWinFspMountPointPreservesOrdinaryFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "mount") + if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := prepareMountPoint(path); err == nil { + t.Fatal("ordinary file accepted") + } + if contents, err := os.ReadFile(path); err != nil || string(contents) != "keep" { + t.Fatalf("ordinary file changed: %q %v", contents, err) + } +} diff --git a/internal/mountfs/native_append.go b/internal/mountfs/native_append.go index f264bea..860bd22 100644 --- a/internal/mountfs/native_append.go +++ b/internal/mountfs/native_append.go @@ -14,6 +14,8 @@ import ( "strings" "sync" "unicode/utf8" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -659,7 +661,7 @@ func syncDirectory(path string) error { if err != nil { return err } - syncErr := directory.Sync() + syncErr := dirsync.Sync(directory) closeErr := directory.Close() return errors.Join(syncErr, closeErr) } diff --git a/internal/mountfs/native_open_unix.go b/internal/mountfs/native_open_unix.go new file mode 100644 index 0000000..1f933fc --- /dev/null +++ b/internal/mountfs/native_open_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package mountfs + +import "os" + +func openNativeBacking(path string, flags int, mode os.FileMode) (*os.File, error) { + return os.OpenFile(path, flags, mode) +} diff --git a/internal/mountfs/native_open_windows.go b/internal/mountfs/native_open_windows.go new file mode 100644 index 0000000..0b83666 --- /dev/null +++ b/internal/mountfs/native_open_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package mountfs + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func openNativeBacking(path string, flags int, _ os.FileMode) (*os.File, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + access := uint32(windows.GENERIC_READ) + if flags&(os.O_WRONLY|os.O_RDWR) != 0 { + access |= windows.GENERIC_WRITE + } + creation := uint32(windows.OPEN_EXISTING) + if flags&os.O_CREATE != 0 { + creation = windows.OPEN_ALWAYS + if flags&os.O_EXCL != 0 { + creation = windows.CREATE_NEW + } + } + attributes := uint32(windows.FILE_ATTRIBUTE_NORMAL) + if flags&os.O_SYNC != 0 { + attributes |= windows.FILE_FLAG_WRITE_THROUGH + } + // Keep native readers and append handles usable across archive moves. + handle, err := windows.CreateFile(pointer, access, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, creation, attributes, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/internal/mountfs/windows_core_backend.go b/internal/mountfs/windows_core_backend.go new file mode 100644 index 0000000..2dff9ea --- /dev/null +++ b/internal/mountfs/windows_core_backend.go @@ -0,0 +1,105 @@ +//go:build windows + +package mountfs + +import ( + "syscall" + "time" +) + +var _ HostBackend = (*WindowsCoreHost)(nil) +var _ HostMetadataBackend = (*WindowsCoreHost)(nil) + +func (host *WindowsCoreHost) executeLocked(request CoreRequest) CoreResponse { + var reply CoreResponse + if host.closed || host.process == nil || !host.healthy.Load() { + reply.Errno = syscall.EIO + return reply + } + request.Token = host.process.token + if err := host.process.client.Call("Core.Execute", request, &reply); err != nil { + // A lost response, including a write response, is ambiguous. Return an + // error to the caller and never replay the operation on another engine. + host.healthy.Store(false) + reply = CoreResponse{Errno: syscall.EIO} + } + return reply +} + +func (host *WindowsCoreHost) execute(request CoreRequest) CoreResponse { + host.gate.RLock() + defer host.gate.RUnlock() + return host.executeLocked(request) +} + +func (host *WindowsCoreHost) Getattr(name string) (Attr, syscall.Errno) { + reply := host.execute(CoreRequest{Operation: "getattr", Name: name}) + return reply.Attr, reply.Errno +} +func (host *WindowsCoreHost) ReadDir(name string) ([]string, syscall.Errno) { + reply := host.execute(CoreRequest{Operation: "readdir", Name: name}) + return reply.Entries, reply.Errno +} +func (host *WindowsCoreHost) Open(name string, flags int) (uint64, syscall.Errno) { + host.gate.RLock() + defer host.gate.RUnlock() + reply := host.executeLocked(CoreRequest{Operation: "open", Name: name, Flags: flags}) + if reply.Errno == 0 { + host.handlesMu.Lock() + host.handles[reply.Handle] = true + host.handlesMu.Unlock() + } + return reply.Handle, reply.Errno +} +func (host *WindowsCoreHost) Read(handle uint64, destination []byte, offset int64) (int, syscall.Errno) { + if len(destination) > coreMaxIO { + return 0, syscall.EINVAL + } + reply := host.execute(CoreRequest{Operation: "read", Handle: handle, Length: len(destination), Offset: offset}) + if reply.Errno == 0 { + copy(destination, reply.Data) + } + return reply.Count, reply.Errno +} +func (host *WindowsCoreHost) Write(handle uint64, data []byte, offset int64) (int, syscall.Errno) { + if len(data) > coreMaxIO { + return 0, syscall.EINVAL + } + reply := host.execute(CoreRequest{Operation: "write", Handle: handle, Data: data, Offset: offset}) + return reply.Count, reply.Errno +} +func (host *WindowsCoreHost) TruncatePath(name string, size int64) syscall.Errno { + return host.execute(CoreRequest{Operation: "truncate-path", Name: name, Size: size}).Errno +} +func (host *WindowsCoreHost) Truncate(handle uint64, size int64) syscall.Errno { + return host.execute(CoreRequest{Operation: "truncate", Handle: handle, Size: size}).Errno +} +func (host *WindowsCoreHost) Flush(handle uint64) syscall.Errno { + return host.execute(CoreRequest{Operation: "flush", Handle: handle}).Errno +} +func (host *WindowsCoreHost) Fsync(handle uint64) syscall.Errno { + return host.execute(CoreRequest{Operation: "fsync", Handle: handle}).Errno +} +func (host *WindowsCoreHost) Release(handle uint64) syscall.Errno { + host.gate.RLock() + defer host.gate.RUnlock() + errno := host.executeLocked(CoreRequest{Operation: "release", Handle: handle}).Errno + // Release means the frontend caller has closed this handle even if the + // engine has failed. A later cutover still requires a clean engine stop. + host.handlesMu.Lock() + delete(host.handles, handle) + host.handlesMu.Unlock() + return errno +} +func (host *WindowsCoreHost) Mkdir(name string, mode uint32) syscall.Errno { + return host.execute(CoreRequest{Operation: "mkdir", Name: name, Mode: mode}).Errno +} +func (host *WindowsCoreHost) Rename(name, target string) syscall.Errno { + return host.execute(CoreRequest{Operation: "rename", Name: name, Target: target}).Errno +} +func (host *WindowsCoreHost) Unlink(name string) syscall.Errno { + return host.execute(CoreRequest{Operation: "unlink", Name: name}).Errno +} +func (host *WindowsCoreHost) Metadata(operation, name string, mode, uid uint32, access, modified time.Time) syscall.Errno { + return host.execute(CoreRequest{Operation: operation, Name: name, Mode: mode, UID: uid, AccessTime: access, ModTime: modified}).Errno +} diff --git a/internal/mountfs/windows_core_host.go b/internal/mountfs/windows_core_host.go new file mode 100644 index 0000000..e036f9b --- /dev/null +++ b/internal/mountfs/windows_core_host.go @@ -0,0 +1,439 @@ +//go:build windows + +package mountfs + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/rpc" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/Microsoft/go-winio" + "github.com/samekind/codexfold/internal/buildid" + "golang.org/x/sys/windows" +) + +type WindowsCoreOptions struct { + Binary, Root, Store string + Arguments []string + Stdout, Stderr io.Writer + BeforeUpdate func() error + ReadyTimeout time.Duration +} +type coreProcess struct { + command *exec.Cmd + control io.WriteCloser + client *rpc.Client + token, binary, build string + done chan struct{} + err error +} +type WindowsCoreHost struct { + gate sync.RWMutex + handlesMu sync.Mutex + handles map[uint64]bool + process *coreProcess + options WindowsCoreOptions + healthy atomic.Bool + build atomic.Value + closed bool +} +type CoreInfo struct { + Version, HostPID, EnginePID, OpenHandles int + Binary, Build string + Healthy bool +} +type CoreUpdateRequest struct { + Candidate string + Apply bool +} +type CoreUpdateResult struct { + CoreInfo + CandidateSHA256 string + PreviousPID, ReplacementPID int + Changed, DryRun, MountPreserved bool + RecoveryBinary string +} +type coreImage struct { + Version int + SHA256 string +} + +func WindowsCoreControlPipe(store string) string { + digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(store)))) + return `\\.\pipe\codexfold-core-control-` + hex.EncodeToString(digest[:16]) +} + +func NewWindowsCoreHost(ctx context.Context, options WindowsCoreOptions) (*WindowsCoreHost, error) { + for _, path := range []string{options.Binary, options.Root, options.Store} { + if !filepath.IsAbs(path) { + return nil, errors.New("absolute engine binary, image root and store are required") + } + } + if options.ReadyTimeout <= 0 { + options.ReadyTimeout = 2 * time.Minute + } + if err := os.MkdirAll(options.Root, 0700); err != nil { + return nil, err + } + info, err := os.Lstat(options.Root) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("engine image root must be an ordinary directory") + } + binary := options.Binary + data, err := os.ReadFile(filepath.Join(options.Root, "current.json")) + if err == nil { + var image coreImage + if err := json.Unmarshal(data, &image); err != nil || image.Version != WindowsCoreProtocol || !buildid.ValidSHA256(image.SHA256) { + return nil, errors.New("persisted engine binding is invalid") + } + binary = filepath.Join(options.Root, image.SHA256+".exe") + if info, err := os.Lstat(binary); err != nil || !info.Mode().IsRegular() { + return nil, errors.New("persisted engine image is not an ordinary file") + } + if actual, err := buildid.FileSHA256(binary); err != nil || actual != image.SHA256 { + return nil, errors.New("persisted engine image failed verification") + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + host := &WindowsCoreHost{options: options, handles: map[uint64]bool{}} + process, err := host.start(ctx, binary) + if err != nil { + return nil, err + } + host.process = process + host.build.Store(process.build) + host.healthy.Store(true) + return host, nil +} + +func (host *WindowsCoreHost) start(ctx context.Context, binary string) (*coreProcess, error) { + build, err := buildid.FileSHA256(binary) + if err != nil { + return nil, err + } + boot := WindowsCoreBoot{Pipe: `\\.\pipe\codexfold-core-` + rand.Text(), Token: rand.Text() + rand.Text()} + command := exec.Command(binary, host.options.Arguments...) + command.Stdout, command.Stderr = host.options.Stdout, host.options.Stderr + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + control, err := command.StdinPipe() + if err != nil { + return nil, err + } + if err := command.Start(); err != nil { + control.Close() + return nil, err + } + process := &coreProcess{command: command, control: control, token: boot.Token, binary: binary, build: build, done: make(chan struct{})} + go func() { process.err = command.Wait(); close(process.done) }() + if err := json.NewEncoder(control).Encode(boot); err != nil { + _ = host.stop(process) + return nil, err + } + ready, cancel := context.WithTimeout(ctx, host.options.ReadyTimeout) + defer cancel() + for { + connection, err := winio.DialPipeContext(ready, boot.Pipe) + if err == nil { + client := rpc.NewClient(connection) + var reply CoreResponse + call := client.Go("Core.Execute", CoreRequest{Token: boot.Token, Operation: "hello"}, &reply, make(chan *rpc.Call, 1)) + select { + case result := <-call.Done: + if result.Error == nil && reply.Version == WindowsCoreProtocol && reply.PID == command.Process.Pid && reply.Build == build { + process.client = client + return process, nil + } + case <-ready.Done(): + } + _ = client.Close() + } + select { + case <-process.done: + _ = control.Close() + return nil, fmt.Errorf("engine exited before becoming ready: %w", process.err) + case <-ready.Done(): + _ = host.stop(process) + return nil, fmt.Errorf("engine readiness: %w", ready.Err()) + case <-time.After(50 * time.Millisecond): + } + } +} + +func (host *WindowsCoreHost) stop(process *coreProcess) error { + if process == nil { + return nil + } + _ = process.control.Close() + select { + case <-process.done: + if process.client != nil { + _ = process.client.Close() + } + return process.err + case <-time.After(25 * time.Second): + // A graceful shutdown that failed is never treated as a successful + // cutover. Kill only this owned child, then return an explicit failure. + _ = process.command.Process.Kill() + <-process.done + if process.client != nil { + _ = process.client.Close() + } + return errors.New("engine did not finish its graceful shutdown") + } +} + +func (host *WindowsCoreHost) BuildSHA256() string { + value, _ := host.build.Load().(string) + return value +} +func (host *WindowsCoreHost) Healthy() bool { + if !host.healthy.Load() { + return false + } + host.gate.RLock() + defer host.gate.RUnlock() + if host.process == nil { + return false + } + select { + case <-host.process.done: + return false + default: + return true + } +} + +func (host *WindowsCoreHost) infoLocked() CoreInfo { + host.handlesMu.Lock() + handles := len(host.handles) + host.handlesMu.Unlock() + info := CoreInfo{Version: WindowsCoreProtocol, HostPID: os.Getpid(), OpenHandles: handles, Build: host.BuildSHA256(), Healthy: host.healthy.Load()} + if host.process != nil { + info.EnginePID = host.process.command.Process.Pid + info.Binary = host.process.binary + } + return info +} + +func (host *WindowsCoreHost) Update(ctx context.Context, request CoreUpdateRequest) (CoreUpdateResult, error) { + if !filepath.IsAbs(request.Candidate) { + return CoreUpdateResult{}, errors.New("absolute candidate path is required") + } + info, err := os.Lstat(request.Candidate) + if err != nil || !info.Mode().IsRegular() { + return CoreUpdateResult{}, errors.New("candidate must be an ordinary executable file") + } + candidateSHA, err := buildid.FileSHA256(request.Candidate) + if err != nil { + return CoreUpdateResult{}, err + } + host.gate.Lock() + defer host.gate.Unlock() + result := CoreUpdateResult{CoreInfo: host.infoLocked(), CandidateSHA256: candidateSHA, DryRun: !request.Apply, Changed: candidateSHA != host.BuildSHA256()} + if host.closed { + return result, errors.New("resident mount is stopping") + } + if !result.Changed { + result.MountPreserved = true + return result, nil + } + if result.OpenHandles != 0 { + return result, fmt.Errorf("live update waits for %d open file handles to close", result.OpenHandles) + } + if host.options.BeforeUpdate != nil { + if err := host.options.BeforeUpdate(); err != nil { + return result, err + } + } + if !request.Apply { + return result, nil + } + image, err := host.stage(request.Candidate, candidateSHA) + if err != nil { + return result, err + } + previous := host.process + result.PreviousPID, result.RecoveryBinary = previous.command.Process.Pid, previous.binary + host.healthy.Store(false) + if err := host.stop(previous); err != nil { + return result, fmt.Errorf("old engine did not stop cleanly; candidate was not started: %w", err) + } + cutover, cancelCutover := context.WithTimeout(ctx, 45*time.Second) + replacement, updateErr := host.start(cutover, image) + cancelCutover() + if updateErr == nil { + updateErr = host.persist(candidateSHA) + } + if updateErr != nil { + if replacement != nil { + _ = host.stop(replacement) + } + rollback, rollbackErr := host.start(ctx, previous.binary) + if rollbackErr == nil { + host.process = rollback + host.build.Store(rollback.build) + host.healthy.Store(true) + } + return result, errors.Join(fmt.Errorf("candidate failed; previous engine restored when possible: %w", updateErr), rollbackErr) + } + host.process = replacement + host.build.Store(replacement.build) + host.healthy.Store(true) + result.CoreInfo = host.infoLocked() + result.ReplacementPID, result.MountPreserved = replacement.command.Process.Pid, true + return result, nil +} + +func (host *WindowsCoreHost) stage(candidate, sha string) (string, error) { + image := filepath.Join(host.options.Root, sha+".exe") + if existing, err := os.Lstat(image); err == nil { + if !existing.Mode().IsRegular() { + return "", errors.New("engine image is not an ordinary file") + } + if actual, err := buildid.FileSHA256(image); err != nil || actual != sha { + return "", errors.New("existing engine image failed verification") + } + return image, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + source, err := os.Open(candidate) + if err != nil { + return "", err + } + defer source.Close() + temporary, err := os.CreateTemp(host.options.Root, ".engine-*.tmp") + if err != nil { + return "", err + } + name := temporary.Name() + defer os.Remove(name) + _, copyErr := io.Copy(temporary, source) + syncErr := temporary.Sync() + closeErr := temporary.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return "", err + } + if actual, err := buildid.FileSHA256(name); err != nil || actual != sha { + return "", errors.New("candidate changed while being staged") + } + if err := os.Rename(name, image); err != nil { + return "", err + } + return image, nil +} + +func (host *WindowsCoreHost) persist(sha string) error { + data, err := json.Marshal(coreImage{Version: WindowsCoreProtocol, SHA256: sha}) + if err != nil { + return err + } + file, err := os.CreateTemp(host.options.Root, ".current-*.tmp") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + _, writeErr := file.Write(data) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + from, _ := windows.UTF16PtrFromString(name) + to, _ := windows.UTF16PtrFromString(filepath.Join(host.options.Root, "current.json")) + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} + +func (host *WindowsCoreHost) Close() error { + host.gate.Lock() + defer host.gate.Unlock() + if host.closed { + return nil + } + host.closed = true + host.healthy.Store(false) + return host.stop(host.process) +} + +type coreControlServer struct { + host *WindowsCoreHost + ctx context.Context +} + +func (control *coreControlServer) Info(_ struct{}, reply *CoreInfo) error { + control.host.gate.RLock() + defer control.host.gate.RUnlock() + *reply = control.host.infoLocked() + return nil +} +func (control *coreControlServer) Update(request CoreUpdateRequest, reply *CoreUpdateResult) error { + result, err := control.host.Update(control.ctx, request) + *reply = result + return err +} + +func (host *WindowsCoreHost) ServeControl(ctx context.Context) (func(), error) { + security, err := corePipeSecurity() + if err != nil { + return nil, err + } + listener, err := winio.ListenPipe(WindowsCoreControlPipe(host.options.Store), &winio.PipeConfig{SecurityDescriptor: security}) + if err != nil { + return nil, err + } + server := rpc.NewServer() + if err := server.RegisterName("Control", &coreControlServer{host: host, ctx: ctx}); err != nil { + listener.Close() + return nil, err + } + go func() { + for { + connection, err := listener.Accept() + if err != nil { + return + } + go func() { + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(4 * time.Minute)) + server.ServeConn(connection) + }() + } + }() + return func() { _ = listener.Close() }, nil +} + +func CallWindowsCoreControl(ctx context.Context, store, method string, argument, reply any) error { + connection, err := winio.DialPipeContext(ctx, WindowsCoreControlPipe(store)) + if err != nil { + return fmt.Errorf("resident Windows engine control is unavailable; this mount may require one offline upgrade: %w", err) + } + defer connection.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = connection.SetDeadline(deadline) + } + client := rpc.NewClient(connection) + defer client.Close() + call := client.Go("Control."+method, argument, reply, make(chan *rpc.Call, 1)) + select { + case result := <-call.Done: + return result.Error + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/mountfs/windows_core_rpc.go b/internal/mountfs/windows_core_rpc.go new file mode 100644 index 0000000..9541dc2 --- /dev/null +++ b/internal/mountfs/windows_core_rpc.go @@ -0,0 +1,203 @@ +//go:build windows + +package mountfs + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net" + "net/rpc" + "os" + "strings" + "sync" + "syscall" + "time" + + "github.com/Microsoft/go-winio" + "github.com/samekind/codexfold/internal/buildid" + "golang.org/x/sys/windows" +) + +const WindowsCoreProtocol = 1 +const coreMaxIO = 8 << 20 + +type WindowsCoreBoot struct{ Pipe, Token string } +type CoreRequest struct { + Token, Operation, Name, Target string + Handle uint64 + Flags, Length int + Offset, Size int64 + Mode, UID uint32 + AccessTime, ModTime time.Time + Data []byte +} +type CoreResponse struct { + Errno syscall.Errno + Attr Attr + Entries []string + Handle uint64 + Count, Version, PID int + Build string + Data []byte +} + +func corePipeSecurity() (string, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", err + } + // Reject network logons even if they belong to the local Administrators + // group. The SYSTEM host exposes control only to SYSTEM/administrators. + return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + user.User.Sid.String() + ")", nil +} + +func ReadWindowsCoreBoot(reader io.Reader) (WindowsCoreBoot, error) { + var boot WindowsCoreBoot + if err := json.NewDecoder(io.LimitReader(reader, 4096)).Decode(&boot); err != nil { + return boot, err + } + if !strings.HasPrefix(boot.Pipe, `\\.\pipe\codexfold-core-`) || len(boot.Token) < 32 || len(boot.Token) > 128 { + return boot, errors.New("invalid private engine launch binding") + } + return boot, nil +} + +type coreRPCServer struct { + fs *Filesystem + token, build string +} + +func (server *coreRPCServer) Execute(request CoreRequest, reply *CoreResponse) error { + if subtle.ConstantTimeCompare([]byte(request.Token), []byte(server.token)) != 1 { + return errors.New("engine authentication failed") + } + f := server.fs + switch request.Operation { + case "hello": + reply.Version, reply.PID, reply.Build = WindowsCoreProtocol, os.Getpid(), server.build + case "getattr": + reply.Attr, reply.Errno = f.Getattr(request.Name) + case "readdir": + reply.Entries, reply.Errno = f.ReadDir(request.Name) + case "open": + reply.Handle, reply.Errno = f.Open(request.Name, request.Flags) + case "read": + if request.Length < 0 || request.Length > coreMaxIO { + reply.Errno = syscall.EINVAL + break + } + reply.Data = make([]byte, request.Length) + reply.Count, reply.Errno = f.Read(request.Handle, reply.Data, request.Offset) + reply.Data = reply.Data[:reply.Count] + case "write": + if len(request.Data) > coreMaxIO { + reply.Errno = syscall.EINVAL + break + } + reply.Count, reply.Errno = f.Write(request.Handle, request.Data, request.Offset) + case "truncate": + reply.Errno = f.Truncate(request.Handle, request.Size) + case "truncate-path": + reply.Errno = f.TruncatePath(request.Name, request.Size) + case "flush": + reply.Errno = f.Flush(request.Handle) + case "fsync": + reply.Errno = f.Fsync(request.Handle) + case "release": + reply.Errno = f.Release(request.Handle) + case "mkdir": + reply.Errno = f.Mkdir(request.Name, request.Mode) + case "rename": + reply.Errno = f.Rename(request.Name, request.Target) + case "unlink": + reply.Errno = f.Unlink(request.Name) + case "chmod", "chown", "utimens": + path, managed, errno := f.metadataPath(request.Name) + reply.Errno = errno + if errno != 0 || managed { + break + } + var err error + switch request.Operation { + case "chmod": + err = os.Chmod(path, os.FileMode(request.Mode)&os.ModePerm) + case "chown": + err = os.Chown(path, int(request.Mode), int(request.UID)) + case "utimens": + err = os.Chtimes(path, request.AccessTime, request.ModTime) + } + reply.Errno = errnoFor(err) + default: + reply.Errno = syscall.ENOSYS + } + return nil +} + +// ServeWindowsCore reuses the existing storage engine and its recovery logic. +// Closing the parent lifeline cancels this context; RPC calls finish before the +// caller closes session owners and releases the exclusive service lock. +func ServeWindowsCore(ctx context.Context, fs *Filesystem, boot WindowsCoreBoot, onReady func()) error { + security, err := corePipeSecurity() + if err != nil { + return err + } + listener, err := winio.ListenPipe(boot.Pipe, &winio.PipeConfig{SecurityDescriptor: security, InputBufferSize: 65536, OutputBufferSize: 65536}) + if err != nil { + return err + } + defer listener.Close() + build, err := buildid.CurrentSHA256() + if err != nil { + return err + } + server := rpc.NewServer() + if err := server.RegisterName("Core", &coreRPCServer{fs: fs, token: boot.Token, build: build}); err != nil { + return err + } + var mu sync.Mutex + connections := map[net.Conn]bool{} + var calls sync.WaitGroup + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + case <-done: + return + } + _ = listener.Close() + mu.Lock() + for connection := range connections { + _ = connection.Close() + } + mu.Unlock() + }() + if onReady != nil { + onReady() + } + for { + connection, err := listener.Accept() + if err != nil { + calls.Wait() + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + mu.Lock() + connections[connection] = true + mu.Unlock() + calls.Add(1) + go func() { + defer calls.Done() + defer connection.Close() + server.ServeConn(connection) + mu.Lock() + delete(connections, connection) + mu.Unlock() + }() + } +} diff --git a/internal/mountfs/windows_core_test.go b/internal/mountfs/windows_core_test.go new file mode 100644 index 0000000..30cf107 --- /dev/null +++ b/internal/mountfs/windows_core_test.go @@ -0,0 +1,146 @@ +//go:build windows + +package mountfs + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWindowsCoreEngineProcess(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "--core-fixture" { + return + } + root := os.Args[len(os.Args)-1] + boot, err := ReadWindowsCoreBoot(os.Stdin) + if err != nil { + os.Exit(2) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() + filesystem := NewCanonical() + filesystem.SetNativeRoot(root) + err = ServeWindowsCore(ctx, filesystem, boot, nil) + _ = filesystem.CloseSessions() + if err != nil && !errors.Is(err, context.Canceled) { + os.Exit(3) + } + os.Exit(0) +} + +func TestWindowsCoreUpdatePreservesDataAndRejectsOpenHandles(t *testing.T) { + root := t.TempDir() + native := filepath.Join(root, "native") + if err := os.MkdirAll(filepath.Join(native, "sessions"), 0700); err != nil { + t.Fatal(err) + } + binary, _ := os.Executable() + candidate := filepath.Join(root, "candidate.exe") + data, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(candidate, append(data, []byte("core-update-candidate")...), 0700); err != nil { + t.Fatal(err) + } + paused := true + options := WindowsCoreOptions{Binary: binary, Root: filepath.Join(root, "images"), Store: root, + Arguments: []string{"-test.run=TestWindowsCoreEngineProcess", "--", "--core-fixture", native}, ReadyTimeout: 3 * time.Second, + BeforeUpdate: func() error { + if !paused { + return errors.New("worker active") + } + return nil + }} + host, err := NewWindowsCoreHost(context.Background(), options) + if err != nil { + t.Fatal(err) + } + defer host.Close() + ctx := context.Background() + stop, err := host.ServeControl(ctx) + if err != nil { + t.Fatal(err) + } + defer stop() + var before CoreInfo + if err := CallWindowsCoreControl(ctx, root, "Info", struct{}{}, &before); err != nil { + t.Fatal(err) + } + handle, errno := host.Open("/sessions/note.txt", os.O_CREATE|os.O_RDWR) + if errno != 0 { + t.Fatal(errno) + } + if count, errno := host.Write(handle, []byte("verified payload"), 0); errno != 0 || count != 16 { + t.Fatalf("write %d %v", count, errno) + } + if result, err := host.Update(ctx, CoreUpdateRequest{Candidate: candidate, Apply: true}); err == nil || result.OpenHandles != 1 { + t.Fatalf("open-handle guard: %+v %v", result, err) + } + if host.process.command.Process.Pid != before.EnginePID { + t.Fatal("busy update replaced engine") + } + if errno := host.Fsync(handle); errno != 0 { + t.Fatal(errno) + } + if errno := host.Release(handle); errno != 0 { + t.Fatal(errno) + } + paused = false + if _, err := host.Update(ctx, CoreUpdateRequest{Candidate: candidate, Apply: true}); err == nil || !strings.Contains(err.Error(), "worker active") { + t.Fatal("worker guard", err) + } + paused = true + result, err := host.Update(ctx, CoreUpdateRequest{Candidate: candidate, Apply: true}) + if err != nil { + t.Fatal(err) + } + if !result.MountPreserved || result.ReplacementPID == before.EnginePID || result.HostPID != before.HostPID { + t.Fatalf("cutover %+v", result) + } + handle, errno = host.Open("/sessions/note.txt", os.O_RDONLY) + if errno != 0 { + t.Fatal(errno) + } + buffer := make([]byte, 100) + count, errno := host.Read(handle, buffer, 0) + if errno != 0 || string(buffer[:count]) != "verified payload" { + t.Fatal("payload changed", errno) + } + if errno := host.Release(handle); errno != 0 { + t.Fatal(errno) + } + // An invalid candidate must roll back to the last verified engine without + // fabricating a successful update or replaying any writes. + bad := filepath.Join(root, "broken.exe") + if err := os.WriteFile(bad, []byte("bad executable"), 0700); err != nil { + t.Fatal(err) + } + if _, err := host.Update(ctx, CoreUpdateRequest{Candidate: bad, Apply: true}); err == nil { + t.Fatal("invalid executable accepted") + } + if !host.Healthy() || host.BuildSHA256() != result.Build { + t.Fatal("rollback failed") + } + if _, errno := host.Getattr("/sessions/note.txt"); errno != 0 { + t.Fatal(errno) + } + if err := host.Close(); err != nil { + t.Fatal(err) + } + // The committed image survives a restart of the resident host. + restarted, err := NewWindowsCoreHost(ctx, options) + if err != nil { + t.Fatal(err) + } + defer restarted.Close() + if restarted.BuildSHA256() != result.Build { + t.Fatal("committed image was not restored") + } +} diff --git a/internal/mountfs/windows_volume_prefix.go b/internal/mountfs/windows_volume_prefix.go new file mode 100644 index 0000000..b551829 --- /dev/null +++ b/internal/mountfs/windows_volume_prefix.go @@ -0,0 +1,19 @@ +package mountfs + +import ( + "crypto/sha256" + "fmt" + "path/filepath" + "strings" +) + +// This is a WinFsp local namespace, not an SMB share or a remote connection. +func windowsVolumePrefix(namespaceRoot, mountPoint string) string { + digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(namespaceRoot) + "\x00" + filepath.Clean(mountPoint)))) + return fmt.Sprintf("/codexfold/%x", digest[:12]) +} + +// WindowsUNCPath is the stable local WinFsp alias used by Windows realpath. +func WindowsUNCPath(namespaceRoot, mountPoint string) string { + return `\` + strings.ReplaceAll(windowsVolumePrefix(namespaceRoot, mountPoint), "/", `\`) +} diff --git a/internal/pack/accounting.go b/internal/pack/accounting.go new file mode 100644 index 0000000..24bc485 --- /dev/null +++ b/internal/pack/accounting.go @@ -0,0 +1,88 @@ +package pack + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/samekind/codexfold/internal/fold" +) + +// PublishedLogicalBytes reads the immutable manifest archive associated with a +// published pack. Newly folded but not yet packed manifests must not inflate +// its apparent compression saving. This never opens a repairing resolver. +func PublishedLogicalBytes(ctx context.Context, store, generation string) (int64, error) { + if !safeGeneration(generation) { + return 0, errors.New("safe published generation is required") + } + directory := filepath.Join(store, "packs", generation) + if _, err := readPublishedGeneration(directory); err != nil { + return 0, err + } + catalog, err := ReadRecoveryCatalog(directory) + if err != nil { + return 0, err + } + wanted := make(map[string]RecoveryFile) + for _, file := range catalog.Files { + if strings.HasPrefix(file.Path, "manifests/") { + wanted[file.Path] = file + } + } + archive, closeArchive, err := openRecoveryArchive(directory) + if err != nil { + return 0, err + } + defer closeArchive() + var total int64 + seenIDs := make(map[string]bool) + for { + if err := ctx.Err(); err != nil { + return 0, err + } + header, err := archive.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return 0, err + } + identity, ok := wanted[header.Name] + if !ok { + continue + } + if header.Size != identity.Bytes || header.Size < 0 || header.Size > 64<<20 { + return 0, errors.New("invalid accounting manifest size") + } + data, err := io.ReadAll(io.LimitReader(archive, header.Size+1)) + if err != nil { + return 0, err + } + digest := sha256.Sum256(data) + if int64(len(data)) != identity.Bytes || hex.EncodeToString(digest[:]) != identity.SHA256 { + return 0, errors.New("accounting manifest does not match publication proof") + } + manifest, err := fold.DecodeManifest(data) + if err != nil { + return 0, err + } + if seenIDs[manifest.Session.ID] || manifest.Source.Bytes < 0 || manifest.Source.Bytes > maxInt64()-total { + return 0, errors.New("invalid accounting session total") + } + seenIDs[manifest.Session.ID] = true + total += manifest.Source.Bytes + delete(wanted, header.Name) + if len(wanted) == 0 { + break + } + } + if len(wanted) != 0 { + return 0, fmt.Errorf("published archive is missing %d accounting manifests", len(wanted)) + } + return total, nil +} diff --git a/internal/pack/accounting_test.go b/internal/pack/accounting_test.go new file mode 100644 index 0000000..9dee5ea --- /dev/null +++ b/internal/pack/accounting_test.go @@ -0,0 +1,31 @@ +package pack + +import ( + "bytes" + "context" + "testing" +) + +func TestPublishedLogicalBytesExcludeUnpublishedWork(t *testing.T) { + store := t.TempDir() + first := bytes.Repeat([]byte("already-published-session"), 10000) + writeManifest(t, store, "first", putObjects(t, store, first)) + result, err := Build(context.Background(), store, BuildOptions{}) + if err != nil { + t.Fatal(err) + } + second := bytes.Repeat([]byte("still-being-folded"), 10000) + writeManifest(t, store, "second", putObjects(t, store, second)) + logical, err := PublishedLogicalBytes(context.Background(), store, result.Generation) + if err != nil || logical != int64(len(first)) { + t.Fatalf("published basis = %d, %v", logical, err) + } + result, err = Build(context.Background(), store, BuildOptions{}) + if err != nil { + t.Fatal(err) + } + logical, err = PublishedLogicalBytes(context.Background(), store, result.Generation) + if err != nil || logical != int64(len(first)+len(second)) { + t.Fatalf("next publication basis = %d, %v", logical, err) + } +} diff --git a/internal/pack/build.go b/internal/pack/build.go index a3eb284..5aa8fb2 100644 --- a/internal/pack/build.go +++ b/internal/pack/build.go @@ -16,6 +16,7 @@ import ( "time" "github.com/klauspost/compress/zstd" + "github.com/samekind/codexfold/internal/dirsync" "github.com/samekind/codexfold/internal/fold" "github.com/samekind/codexfold/internal/storage" _ "modernc.org/sqlite" @@ -714,7 +715,7 @@ func syncDirectory(path string) error { return fmt.Errorf("open directory for sync %s: %w", path, err) } defer directory.Close() - if err := directory.Sync(); err != nil { + if err := dirsync.Sync(directory); err != nil { return fmt.Errorf("sync directory %s: %w", path, err) } return nil diff --git a/internal/pack/recovery.go b/internal/pack/recovery.go index cc5538d..d09b53c 100644 --- a/internal/pack/recovery.go +++ b/internal/pack/recovery.go @@ -18,6 +18,7 @@ import ( "time" "github.com/klauspost/compress/zstd" + "github.com/samekind/codexfold/internal/dirsync" "github.com/samekind/codexfold/internal/fold" "github.com/samekind/codexfold/internal/storage" "github.com/samekind/codexfold/internal/vfs" @@ -211,10 +212,10 @@ func collectRecoverySources(storeDir string, generationDir string, meta indexV3M return nil } relative, err := filepath.Rel(filepath.Clean(storeDir), path) - if err != nil || !safeRecoveryPath(relative) || !strings.HasPrefix(filepath.ToSlash(relative), "manifests/") { + archivePath := filepath.ToSlash(relative) + if err != nil || !safeRecoveryPath(archivePath) || !strings.HasPrefix(archivePath, "manifests/") { return fmt.Errorf("unsafe recovery manifest path %q", path) } - archivePath := filepath.ToSlash(relative) manifest, loadErr := fold.LoadManifestPath(path) if loadErr != nil { return loadErr @@ -1023,7 +1024,7 @@ func syncRecoveryRootDirectory(root *os.Root, relative string) error { return err } defer directory.Close() - return directory.Sync() + return dirsync.Sync(directory) } func markGenerationPublished(generationDir string, sequence uint64, previousGeneration string) (PublishedGeneration, error) { diff --git a/internal/service/binary_update.go b/internal/service/binary_update.go index 562a914..1cd57e5 100644 --- a/internal/service/binary_update.go +++ b/internal/service/binary_update.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "github.com/samekind/codexfold/internal/buildid" ) @@ -34,7 +35,8 @@ func StageBinaryUpdate(candidate string, target string) (*BinaryUpdate, error) { if err != nil { return nil, err } - if !candidateInfo.Mode().IsRegular() || candidateInfo.Mode().Perm()&0o111 == 0 { + // Windows does not model executability through Unix permission bits. + if !candidateInfo.Mode().IsRegular() || runtime.GOOS != "windows" && candidateInfo.Mode().Perm()&0o111 == 0 { return nil, errors.New("candidate service binary must be a regular executable file") } root := filepath.Dir(target) diff --git a/internal/service/build_status.go b/internal/service/build_status.go index f320aaa..70a8c5e 100644 --- a/internal/service/build_status.go +++ b/internal/service/build_status.go @@ -28,6 +28,13 @@ func InspectBuild(platform Platform, definitionPath string, mountPoint string) B status.Error = err.Error() return status } + if platform == PlatformWindows { + binaryPath, err = configuredWindowsCoreImage(binaryPath) + if err != nil { + status.Error = err.Error() + return status + } + } status.ConfiguredBinaryPath = binaryPath status.ConfiguredBuildSHA256, err = buildid.FileSHA256(binaryPath) if err != nil { @@ -172,6 +179,9 @@ func DefinitionFSKitResource(platform Platform, definitionPath string) (string, // DefinitionMountPoint reports the --mount argument of a native-fskit launchd // definition. It returns an empty string for non-native-fskit definitions. func DefinitionMountPoint(platform Platform, definitionPath string) (string, error) { + if platform == PlatformWindows { + return windowsDefinitionPath(definitionPath, "--mount") + } frontend, err := DefinitionFrontend(platform, definitionPath) if err != nil { return "", err @@ -200,6 +210,9 @@ func DefinitionMountPoint(platform Platform, definitionPath string) (string, err } func DefinitionStore(platform Platform, definitionPath string) (string, error) { + if platform == PlatformWindows { + return windowsDefinitionPath(definitionPath, "--store") + } if !filepath.IsAbs(definitionPath) { return "", errors.New("absolute service definition path is required") } @@ -231,6 +244,9 @@ func DefinitionStore(platform Platform, definitionPath string) (string, error) { // native FSKit is the macOS frontend and other service definitions do not yet // expose an equivalent introspection contract. func DefinitionNativeRoot(platform Platform, definitionPath string) (string, error) { + if platform == PlatformWindows { + return windowsDefinitionPath(definitionPath, "--native-root") + } if !filepath.IsAbs(definitionPath) { return "", errors.New("absolute service definition path is required") } diff --git a/internal/service/mount_probe_windows.go b/internal/service/mount_probe_windows.go index 6b9597c..e66de46 100644 --- a/internal/service/mount_probe_windows.go +++ b/internal/service/mount_probe_windows.go @@ -3,13 +3,35 @@ package service import ( + "errors" + "fmt" "os" "path/filepath" + "strings" "github.com/samekind/codexfold/internal/mountid" + "golang.org/x/sys/windows" ) func defaultMountProbe(path string) error { + present, err := MountPresent(path) + if err != nil { + return err + } + if !present { + return errors.New("path is not a filesystem mount root") + } + name, err := windows.UTF16PtrFromString(filepath.Clean(path) + `\`) + if err != nil { + return err + } + var filesystem [256]uint16 + if err := windows.GetVolumeInformation(name, nil, 0, nil, nil, nil, &filesystem[0], uint32(len(filesystem))); err != nil { + return err + } + if !strings.EqualFold(windows.UTF16ToString(filesystem[:]), "FUSE") { + return errors.New("path is not a WinFsp FUSE mount") + } value, err := os.ReadFile(filepath.Join(path, mountid.Path)) if err != nil { return err @@ -17,4 +39,41 @@ func defaultMountProbe(path string) error { return mountid.Validate(value) } -func MountPresent(string) (bool, error) { return false, nil } +func MountPresent(path string) (bool, error) { + name, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return false, err + } + handle, err := windows.CreateFile(name, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, nil + } + if err != nil { + return false, err + } + defer windows.CloseHandle(handle) + var resolved [32768]uint16 + const volumeNameNT = 2 + n, err := windows.GetFinalPathNameByHandle(handle, &resolved[0], uint32(len(resolved)), volumeNameNT) + if err != nil { + return false, err + } + if n >= uint32(len(resolved)) { + return false, fmt.Errorf("resolved mount path exceeds Windows path limit") + } + // NT volume roots resolve to \Device\\; a directory below that + // volume has additional components, even when reached through a junction. + return windowsMountRoot(windows.UTF16ToString(resolved[:n])), nil +} + +func windowsMountRoot(resolved string) bool { + parts := strings.Split(strings.Trim(resolved, `\`), `\`) + if len(parts) == 2 && strings.EqualFold(parts[0], "Device") { + return true + } + // WinFsp network volumes resolve through MUP to a UNC share root. The + // filesystem type and mount identity are checked separately. + return len(parts) == 4 && strings.EqualFold(parts[0], "Device") && + strings.EqualFold(parts[1], "Mup") && strings.EqualFold(parts[2], "codexfold") && parts[3] != "" +} diff --git a/internal/service/mount_probe_windows_test.go b/internal/service/mount_probe_windows_test.go new file mode 100644 index 0000000..44c19e0 --- /dev/null +++ b/internal/service/mount_probe_windows_test.go @@ -0,0 +1,40 @@ +//go:build windows + +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/mountid" +) + +func TestWindowsMountProbeRejectsOrdinaryDirectory(t *testing.T) { + path := t.TempDir() + if present, err := MountPresent(path); err != nil || present { + t.Fatalf("ordinary directory: present=%v err=%v", present, err) + } + if present, err := MountPresent(filepath.Join(path, "missing")); err != nil || present { + t.Fatalf("missing directory: present=%v err=%v", present, err) + } + if err := os.WriteFile(filepath.Join(path, mountid.Path), []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + if err := defaultMountProbe(path); err == nil { + t.Fatal("marker in an ordinary directory accepted as a mounted filesystem") + } +} + +func TestWindowsMountRootRecognizesLocalUNCAndRejectsSubdirectories(t *testing.T) { + for _, path := range []string{`\Device\Volume{test}\`, `\Device\Mup\codexfold\test\`} { + if !windowsMountRoot(path) { + t.Fatalf("mount root rejected: %s", path) + } + } + for _, path := range []string{`\Device\HarddiskVolume3\Users\guo`, `\Device\Mup\codexfold\test\sessions`, `\Device\Mup\other\test\`} { + if windowsMountRoot(path) { + t.Fatalf("ordinary path accepted as mount root: %s", path) + } + } +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 650ecd8..1ab6f31 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -304,13 +304,14 @@ func TestWindowsManagerInstallsStartsStopsAndReportsSCMState(t *testing.T) { if err := manager.Start(context.Background(), "com.codexfold.fs"); err != nil { t.Fatalf("Start: %v", err) } - if err := manager.Stop(context.Background(), "com.codexfold.fs"); err != nil { - t.Fatalf("Stop: %v", err) - } status := manager.Status(context.Background(), "com.codexfold.fs", filepath.Join(t.TempDir(), "mount")) if !status.DaemonRunning || !status.MountHealthy { t.Fatalf("Windows service status = %#v", status) } + statusRunner.outputs["sc.exe queryex com.codexfold.fs"] = []byte("STATE : 1 STOPPED\n") + if err := manager.Stop(context.Background(), "com.codexfold.fs"); err != nil { + t.Fatalf("Stop: %v", err) + } } func TestStatusDoesNotTreatLoadedExitedJobAsRunning(t *testing.T) { diff --git a/internal/service/windows.go b/internal/service/windows.go index 3d67769..2dd1ee9 100644 --- a/internal/service/windows.go +++ b/internal/service/windows.go @@ -14,6 +14,7 @@ import ( const windowsConfigVersion = 1 var windowsRunningState = regexp.MustCompile(`(?m)STATE\s*:\s*4\s+RUNNING\b`) +var windowsStoppedState = regexp.MustCompile(`(?m)STATE\s*:\s*1\s+STOPPED\b`) type WindowsConfig struct { Version int `json:"version"` @@ -88,9 +89,38 @@ func (m WindowsManager) Install(ctx context.Context, name string, binaryPath str if output, err := m.runner().Run(ctx, "sc.exe", "description", name, "CodexFold transparent Codex session filesystem"); err != nil { return commandFailure("sc.exe description", output, err) } - if output, err := m.runner().Run(ctx, "sc.exe", "failure", name, "reset=", "86400", "actions=", "restart/5000/restart/15000/\"\"/0"); err != nil { + if output, err := m.runner().Run(ctx, "sc.exe", "failure", name, "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"); err != nil { return commandFailure("sc.exe failure", output, err) } + if output, err := m.runner().Run(ctx, "sc.exe", "failureflag", name, "1"); err != nil { + return commandFailure("sc.exe failureflag", output, err) + } + return nil +} + +func (m WindowsManager) InstallEnrollment(ctx context.Context, name, binary, definition string) error { + if !safeLabel(name) || !absoluteWindowsServicePath(binary) || !absoluteWindowsServicePath(definition) { + return errors.New("safe enrollment service name and absolute installation paths are required") + } + command := strings.Join([]string{quoteWindowsCommandLineArgument(binary), "fs", "enroll", "service", "run", "--definition", quoteWindowsCommandLineArgument(definition)}, " ") + _, existsErr := m.runner().Run(ctx, "sc.exe", "query", name) + operation := "create" + args := []string{operation, name, "binPath=", command, "start=", "delayed-auto", "DisplayName=", "CodexFold Automatic Enrollment"} + if existsErr == nil { + args[0] = "config" + } + if output, err := m.runner().Run(ctx, "sc.exe", args...); err != nil { + return commandFailure("sc.exe enrollment registration", output, err) + } + if output, err := m.runner().Run(ctx, "sc.exe", "description", name, "CodexFold persistent automatic session enrollment"); err != nil { + return commandFailure("sc.exe description", output, err) + } + if output, err := m.runner().Run(ctx, "sc.exe", "failure", name, "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"); err != nil { + return commandFailure("sc.exe failure", output, err) + } + if output, err := m.runner().Run(ctx, "sc.exe", "failureflag", name, "1"); err != nil { + return commandFailure("sc.exe failureflag", output, err) + } return nil } @@ -113,7 +143,24 @@ func (m WindowsManager) Stop(ctx context.Context, name string) error { if err != nil { return commandFailure("sc.exe stop", output, err) } - return nil + deadline := time.Now().Add(30 * time.Second) + for { + output, err = m.runner().Run(ctx, "sc.exe", "queryex", name) + if err != nil { + return commandFailure("sc.exe queryex", output, err) + } + if windowsStoppedState.Match(output) { + return nil + } + if time.Now().After(deadline) { + return errors.New("Windows filesystem service did not finish stopping") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } } func (m WindowsManager) Status(ctx context.Context, name string, mountPoint string) Status { diff --git a/internal/service/windows_core_image.go b/internal/service/windows_core_image.go new file mode 100644 index 0000000..aa0da94 --- /dev/null +++ b/internal/service/windows_core_image.go @@ -0,0 +1,67 @@ +package service + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/samekind/codexfold/internal/buildid" +) + +func windowsDefinitionPath(definitionPath, flag string) (string, error) { + if !filepath.IsAbs(definitionPath) { + return "", errors.New("absolute service definition path is required") + } + data, err := os.ReadFile(definitionPath) + if err != nil { + return "", err + } + config, err := ParseWindowsConfig(data) + if err != nil { + return "", err + } + value := "" + for index, argument := range config.Arguments { + if argument == flag && index+1 < len(config.Arguments) { + value = config.Arguments[index+1] + } + if strings.HasPrefix(argument, flag+"=") { + value = strings.TrimPrefix(argument, flag+"=") + } + } + if !filepath.IsAbs(value) && !absoluteWindowsServicePath(value) { + return "", errors.New("Windows definition requires an absolute " + flag + " argument") + } + return filepath.Clean(value), nil +} + +// The SCM binary owns the resident frontend. A verified, administrator-owned +// image binding selects the separately replaceable storage engine. +func configuredWindowsCoreImage(binary string) (string, error) { + root := filepath.Join(filepath.Dir(binary), "Core") + data, err := os.ReadFile(filepath.Join(root, "current.json")) + if errors.Is(err, os.ErrNotExist) { + return binary, nil + } + if err != nil { + return "", err + } + var image struct { + Version int + SHA256 string + } + if json.Unmarshal(data, &image) != nil || image.Version != 1 || !buildid.ValidSHA256(image.SHA256) { + return "", errors.New("configured Windows engine binding is invalid") + } + path := filepath.Join(root, image.SHA256+".exe") + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return "", errors.New("configured Windows engine is not an ordinary file") + } + if actual, err := buildid.FileSHA256(path); err != nil || actual != image.SHA256 { + return "", errors.New("configured Windows engine hash does not match its binding") + } + return path, nil +} diff --git a/internal/service/windows_core_image_test.go b/internal/service/windows_core_image_test.go new file mode 100644 index 0000000..64c1912 --- /dev/null +++ b/internal/service/windows_core_image_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "encoding/json" + "github.com/samekind/codexfold/internal/buildid" + "os" + "path/filepath" + "testing" +) + +func TestWindowsCoreImageBinding(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "codexfold.exe") + if err := os.WriteFile(base, []byte("frontend"), 0600); err != nil { + t.Fatal(err) + } + if path, err := configuredWindowsCoreImage(base); err != nil || path != base { + t.Fatal(path, err) + } + core := filepath.Join(root, "Core") + _ = os.Mkdir(core, 0700) + image := filepath.Join(root, "new.exe") + _ = os.WriteFile(image, []byte("new-engine"), 0600) + sha, _ := buildid.FileSHA256(image) + target := filepath.Join(core, sha+".exe") + _ = os.Rename(image, target) + binding, _ := json.Marshal(struct { + Version int + SHA256 string + }{1, sha}) + _ = os.WriteFile(filepath.Join(core, "current.json"), binding, 0600) + if path, err := configuredWindowsCoreImage(base); err != nil || path != target { + t.Fatal(path, err) + } + _ = os.WriteFile(target, []byte("changed"), 0600) + if _, err := configuredWindowsCoreImage(base); err == nil { + t.Fatal("unverified image accepted") + } +} + +func TestWindowsCoreDefinitionPaths(t *testing.T) { + root := t.TempDir() + definition := filepath.Join(root, "service.json") + data, _ := json.Marshal(WindowsConfig{Version: 1, ServiceName: "com.codexfold.fs", BinaryPath: filepath.Join(root, "core.exe"), + Arguments: []string{"fs", "serve", "--store=" + root, "--mount", filepath.Join(root, "mount"), "--native-root", filepath.Join(root, "native")}, + StdoutPath: filepath.Join(root, "out.log"), StderrPath: filepath.Join(root, "err.log")}) + if err := os.WriteFile(definition, data, 0600); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + load func(Platform, string) (string, error) + want string + }{{DefinitionStore, root}, {DefinitionMountPoint, filepath.Join(root, "mount")}, {DefinitionNativeRoot, filepath.Join(root, "native")}} { + value, err := item.load(PlatformWindows, definition) + if err != nil || value != item.want { + t.Fatal(value, err) + } + } +} + +func TestWindowsCoreOfflineUpdateRestoresEngineBinding(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "codexfold.exe") + core := filepath.Join(root, "Core") + _ = os.Mkdir(core, 0700) + _ = os.WriteFile(base, []byte("old-engine"), 0600) + oldSHA, _ := buildid.FileSHA256(base) + _ = os.WriteFile(filepath.Join(core, oldSHA+".exe"), []byte("old-engine"), 0600) + previous, _ := json.Marshal(struct { + Version int + SHA256 string + }{1, oldSHA}) + _ = os.WriteFile(filepath.Join(core, "current.json"), previous, 0600) + _ = os.WriteFile(base, []byte("new-engine"), 0600) + rollback, err := PrepareWindowsCoreOfflineUpdate(base) + if err != nil { + t.Fatal(err) + } + selected, err := configuredWindowsCoreImage(base) + if err != nil { + t.Fatal(err) + } + newSHA, _ := buildid.FileSHA256(selected) + if newSHA == oldSHA { + t.Fatal("offline update kept old engine") + } + if err := rollback(); err != nil { + t.Fatal(err) + } + selected, err = configuredWindowsCoreImage(base) + if err != nil { + t.Fatal(err) + } + restored, _ := buildid.FileSHA256(selected) + if restored != oldSHA { + t.Fatal("rollback changed old image") + } +} diff --git a/internal/service/windows_core_offline.go b/internal/service/windows_core_offline.go new file mode 100644 index 0000000..10ec6b0 --- /dev/null +++ b/internal/service/windows_core_offline.go @@ -0,0 +1,76 @@ +package service + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + + "github.com/samekind/codexfold/internal/buildid" +) + +// An offline frontend update must also select its new engine image. Retain the +// previous binding so the existing binary-update rollback can restore both. +// The caller has already stopped the owned Windows service. +func PrepareWindowsCoreOfflineUpdate(binary string) (func() error, error) { + root := filepath.Join(filepath.Dir(binary), "Core") + bindingPath := filepath.Join(root, "current.json") + previous, err := os.ReadFile(bindingPath) + if errors.Is(err, os.ErrNotExist) { + return func() error { return nil }, nil + } + if err != nil { + return nil, err + } + if _, err := configuredWindowsCoreImage(binary); err != nil { + return nil, err + } + sha, err := buildid.FileSHA256(binary) + if err != nil { + return nil, err + } + image := filepath.Join(root, sha+".exe") + if info, err := os.Lstat(image); err == nil { + if !info.Mode().IsRegular() { + return nil, errors.New("offline engine image is not an ordinary file") + } + if actual, err := buildid.FileSHA256(image); err != nil || actual != sha { + return nil, errors.New("offline engine image failed verification") + } + } else if errors.Is(err, os.ErrNotExist) { + source, err := os.Open(binary) + if err != nil { + return nil, err + } + defer source.Close() + target, err := os.CreateTemp(root, ".offline-engine-*.tmp") + if err != nil { + return nil, err + } + name := target.Name() + defer os.Remove(name) + _, copyErr := io.Copy(target, source) + syncErr := target.Sync() + closeErr := target.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return nil, err + } + if actual, err := buildid.FileSHA256(name); err != nil || actual != sha { + return nil, errors.New("offline engine source changed") + } + if err := os.Rename(name, image); err != nil { + return nil, err + } + } else { + return nil, err + } + data, _ := json.Marshal(struct { + Version int + SHA256 string + }{1, sha}) + if _, err := WriteDefinition(bindingPath, data, true); err != nil { + return nil, err + } + return func() error { _, err := WriteDefinition(bindingPath, previous, true); return err }, nil +} diff --git a/internal/service/windows_enrollment.go b/internal/service/windows_enrollment.go new file mode 100644 index 0000000..0846398 --- /dev/null +++ b/internal/service/windows_enrollment.go @@ -0,0 +1,59 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "strings" +) + +const WindowsEnrollmentServiceName = "com.codexfold.enroll" + +// This administrator-owned binding contains no executable arguments from the +// writable user policy. Every data location is explicit, even under SYSTEM. +type WindowsEnrollmentConfig struct { + Version int `json:"version"` + ServiceName string `json:"service_name"` + BinaryPath string `json:"binary_path"` + OwnerSID string `json:"owner_sid"` + CodexHome string `json:"codex_home"` + Store string `json:"store"` + Mount string `json:"mount"` + NativeRoot string `json:"native_root"` + StdoutPath string `json:"stdout_path"` + StderrPath string `json:"stderr_path"` +} + +func ParseWindowsEnrollmentConfig(data []byte) (WindowsEnrollmentConfig, error) { + var config WindowsEnrollmentConfig + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&config); err != nil { + return config, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return config, errors.New("unexpected data after enrollment service binding") + } + if config.Version != 1 { + return config, errors.New("unsupported enrollment service binding version") + } + if !safeLabel(config.ServiceName) || (config.ServiceName != WindowsEnrollmentServiceName && !strings.HasPrefix(config.ServiceName, WindowsEnrollmentServiceName+".test-")) { + return config, errors.New("unexpected enrollment service name") + } + if config.OwnerSID == "" { + return config, errors.New("enrollment service owner SID is required") + } + for name, path := range map[string]string{"binary": config.BinaryPath, "home": config.CodexHome, "store": config.Store, "mount": config.Mount, "native": config.NativeRoot, "stdout": config.StdoutPath, "stderr": config.StderrPath} { + if !absoluteWindowsServicePath(path) { + return config, fmt.Errorf("absolute enrollment service %s path is required", name) + } + } + if !strings.EqualFold(filepath.Clean(config.Store), filepath.Join(filepath.Clean(config.CodexHome), "fold-store")) || + !strings.EqualFold(filepath.Clean(config.NativeRoot), filepath.Join(filepath.Clean(config.CodexHome), "fold-native")) { + return config, errors.New("enrollment service store and native root must belong to its bound home") + } + return config, nil +} diff --git a/internal/service/windows_enrollment_test.go b/internal/service/windows_enrollment_test.go new file mode 100644 index 0000000..d9e6bf7 --- /dev/null +++ b/internal/service/windows_enrollment_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package service + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" +) + +func TestWindowsEnrollmentBindingRejectsAmbiguousRoots(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + valid := WindowsEnrollmentConfig{Version: 1, ServiceName: WindowsEnrollmentServiceName, BinaryPath: filepath.Join(home, "bin.exe"), OwnerSID: "S-1-5-21-1-2-3-1001", CodexHome: home, Store: filepath.Join(home, "fold-store"), NativeRoot: filepath.Join(home, "fold-native"), Mount: `V:\`, StdoutPath: filepath.Join(home, "out.log"), StderrPath: filepath.Join(home, "err.log")} + for _, mutate := range []func(*WindowsEnrollmentConfig){nil, + func(c *WindowsEnrollmentConfig) { c.CodexHome = "" }, + func(c *WindowsEnrollmentConfig) { c.Store = filepath.Join(home, "other-store") }, + func(c *WindowsEnrollmentConfig) { c.NativeRoot = filepath.Dir(home) }, + func(c *WindowsEnrollmentConfig) { c.OwnerSID = "" }, + func(c *WindowsEnrollmentConfig) { c.ServiceName = "unrelated" }, + func(c *WindowsEnrollmentConfig) { c.Version = 2 }, + } { + candidate := valid + if mutate != nil { + mutate(&candidate) + } + data, err := json.Marshal(candidate) + if err != nil { + t.Fatal(err) + } + _, err = ParseWindowsEnrollmentConfig(data) + if (err == nil) != (mutate == nil) { + t.Fatalf("binding %#v: %v", candidate, err) + } + } +} + +func TestWindowsEnrollmentManagerRegistersRecoveryWithoutFilesystemRestart(t *testing.T) { + runner := &recordingRunner{errors: map[string]error{"sc.exe query " + WindowsEnrollmentServiceName: errors.New("not installed")}} + manager := WindowsManager{Runner: runner} + if err := manager.InstallEnrollment(context.Background(), WindowsEnrollmentServiceName, `C:\Program Files\CodexFold\Enrollment\codexfold-enroll.exe`, `C:\ProgramData\CodexFold\Enrollment\service.json`); err != nil { + t.Fatal(err) + } + actual := strings.Join(runner.calls, "\n") + for _, want := range []string{"fs enroll service run --definition", "start= delayed-auto", "actions= restart/5000/restart/15000/restart/60000", "failureflag " + WindowsEnrollmentServiceName + " 1"} { + if !strings.Contains(actual, want) { + t.Fatalf("missing %s: %s", want, actual) + } + } + if strings.Contains(actual, "com.codexfold.fs") { + t.Fatal("enrollment registration touched filesystem service") + } +} diff --git a/internal/sessionns/activation.go b/internal/sessionns/activation.go index a2bedb1..ebc63a7 100644 --- a/internal/sessionns/activation.go +++ b/internal/sessionns/activation.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -51,7 +53,7 @@ func Inspect(options Options) (Result, error) { if err != nil { return Result{}, fmt.Errorf("inspect %s: %w", homePath, err) } - if info.Mode()&os.ModeSymlink != 0 { + if isNamespaceLink(info) { target, err := os.Readlink(homePath) if err != nil || filepath.Clean(target) != filepath.Join(options.Mount, name) { return Result{}, fmt.Errorf("unexpected namespace link %s", homePath) @@ -132,7 +134,7 @@ func Activate(options Options) (Result, error) { if err := os.Rename(homePath, nativePath); err != nil { return rollbackAfterError(options, err) } - if err := os.Symlink(filepath.Join(options.Mount, name), homePath); err != nil { + if err := createNamespaceLink(filepath.Join(options.Mount, name), homePath); err != nil { return rollbackAfterError(options, err) } } @@ -200,7 +202,7 @@ func Recover(options Options) (Result, error) { for _, name := range sessionDirectories { homePath := filepath.Join(options.Home, name) nativePath := filepath.Join(options.NativeRoot, name) - if info, err := os.Lstat(homePath); err == nil && info.Mode()&os.ModeSymlink != 0 { + if info, err := os.Lstat(homePath); err == nil && isNamespaceLink(info) { if err := os.Remove(homePath); err != nil { return Result{}, err } @@ -327,5 +329,5 @@ func syncDirectory(path string) error { return err } defer directory.Close() - return directory.Sync() + return dirsync.Sync(directory) } diff --git a/internal/sessionns/link_unix.go b/internal/sessionns/link_unix.go new file mode 100644 index 0000000..f11909e --- /dev/null +++ b/internal/sessionns/link_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package sessionns + +import "os" + +func isNamespaceLink(info os.FileInfo) bool { return info.Mode()&os.ModeSymlink != 0 } +func createNamespaceLink(target, link string) error { return os.Symlink(target, link) } diff --git a/internal/sessionns/link_windows.go b/internal/sessionns/link_windows.go new file mode 100644 index 0000000..50b4e27 --- /dev/null +++ b/internal/sessionns/link_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package sessionns + +import ( + "encoding/binary" + "errors" + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +func isNamespaceLink(info os.FileInfo) bool { + // Go 1.23+ reports junctions as irregular name-surrogate reparse points. + // Readlink and exact target validation still reject unrelated reparse data. + return info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 +} + +func createNamespaceLink(target, link string) (result error) { + if !filepath.IsAbs(target) || filepath.VolumeName(target) == "" { + return errors.New("absolute local Windows junction target is required") + } + substitute, err := windows.UTF16FromString(`\??\` + filepath.Clean(target)) + if err != nil { + return err + } + printed, err := windows.UTF16FromString(filepath.Clean(target)) + if err != nil { + return err + } + data := make([]byte, 16+2*(len(substitute)+len(printed))) + if len(data) > 16384 { + return errors.New("Windows junction target exceeds reparse buffer limit") + } + binary.LittleEndian.PutUint32(data[0:4], windows.IO_REPARSE_TAG_MOUNT_POINT) + binary.LittleEndian.PutUint16(data[4:6], uint16(len(data)-8)) + binary.LittleEndian.PutUint16(data[10:12], uint16(2*(len(substitute)-1))) + binary.LittleEndian.PutUint16(data[12:14], uint16(2*len(substitute))) + binary.LittleEndian.PutUint16(data[14:16], uint16(2*(len(printed)-1))) + for i, value := range append(substitute, printed...) { + binary.LittleEndian.PutUint16(data[16+2*i:18+2*i], value) + } + // Only create a fresh empty directory; never replace a pre-existing path. + if err := os.Mkdir(link, 0o700); err != nil { + return err + } + defer func() { + if result != nil { + _ = os.Remove(link) + } + }() + name, err := windows.UTF16PtrFromString(link) + if err != nil { + return err + } + handle, err := windows.CreateFile(name, windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + var returned uint32 + return windows.DeviceIoControl(handle, windows.FSCTL_SET_REPARSE_POINT, + &data[0], uint32(len(data)), nil, 0, &returned, nil) +} diff --git a/internal/sessionns/routing_guard.go b/internal/sessionns/routing_guard.go index 6a94797..b9fc49b 100644 --- a/internal/sessionns/routing_guard.go +++ b/internal/sessionns/routing_guard.go @@ -7,8 +7,11 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "github.com/samekind/codexfold/internal/mountfs" + _ "modernc.org/sqlite" ) @@ -95,29 +98,46 @@ func normalizeExistingRoutesStatement(options Options) string { } func routeGuardCase(options Options, value string) string { - activeMount := filepath.Join(options.Mount, "sessions") + string(filepath.Separator) - archiveMount := filepath.Join(options.Mount, "archived_sessions") + string(filepath.Separator) - activeHome := filepath.Join(options.Home, "sessions") + string(filepath.Separator) - archiveHome := filepath.Join(options.Home, "archived_sessions") + string(filepath.Separator) - activeMountSQL := quoteSQLString(activeMount) - archiveMountSQL := quoteSQLString(archiveMount) - return fmt.Sprintf( - `case when substr(%s, 1, length(%s)) = %s then %s || substr(%s, length(%s) + 1) else %s || substr(%s, length(%s) + 1) end`, - value, activeMountSQL, activeMountSQL, quoteSQLString(activeHome), value, activeMountSQL, - quoteSQLString(archiveHome), value, archiveMountSQL, - ) + var result strings.Builder + result.WriteString("case ") + for _, prefix := range routePrefixes(options) { + from := quoteSQLString(prefix.mount) + fmt.Fprintf(&result, "when %s then %s || substr(%s, length(%s) + 1) ", routePrefixCondition(value, from), quoteSQLString(prefix.home), value, from) + } + fmt.Fprintf(&result, "else %s end", value) + return result.String() } func routeGuardCondition(options Options, value string) string { - activeMount := filepath.Join(options.Mount, "sessions") + string(filepath.Separator) - archiveMount := filepath.Join(options.Mount, "archived_sessions") + string(filepath.Separator) - activeMountSQL := quoteSQLString(activeMount) - archiveMountSQL := quoteSQLString(archiveMount) - return fmt.Sprintf( - `substr(%s, 1, length(%s)) = %s or substr(%s, 1, length(%s)) = %s`, - value, activeMountSQL, activeMountSQL, - value, archiveMountSQL, archiveMountSQL, - ) + var conditions []string + for _, prefix := range routePrefixes(options) { + conditions = append(conditions, routePrefixCondition(value, quoteSQLString(prefix.mount))) + } + return strings.Join(conditions, " or ") +} + +type routePrefix struct{ mount, home string } + +func routePrefixes(options Options) []routePrefix { + roots := []string{options.Mount} + if runtime.GOOS == "windows" && len(filepath.Clean(options.Mount)) == 3 && filepath.Dir(options.Mount) == options.Mount { + unc := mountfs.WindowsUNCPath(options.Home, options.Mount) + roots = append(roots, `\\?\`+filepath.Clean(options.Mount), unc, `\\?\UNC\`+strings.TrimPrefix(unc, `\\`)) + } + var prefixes []routePrefix + for _, root := range roots { + for _, namespace := range sessionDirectories { + prefixes = append(prefixes, routePrefix{filepath.Join(root, namespace) + string(filepath.Separator), filepath.Join(options.Home, namespace) + string(filepath.Separator)}) + } + } + return prefixes +} + +func routePrefixCondition(value, prefix string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("lower(substr(%s, 1, length(%s))) = lower(%s)", value, prefix, prefix) + } + return fmt.Sprintf("substr(%s, 1, length(%s)) = %s", value, prefix, prefix) } func quoteSQLString(value string) string { diff --git a/internal/sessionns/routing_guard_windows_test.go b/internal/sessionns/routing_guard_windows_test.go new file mode 100644 index 0000000..02b3e30 --- /dev/null +++ b/internal/sessionns/routing_guard_windows_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package sessionns + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/mountfs" +) + +func TestWindowsRouteGuardNormalizesDriveAndUNCAliases(t *testing.T) { + home := t.TempDir() + options := Options{Home: home, Mount: `T:\`, NativeRoot: filepath.Join(home, "native")} + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`create table threads (id text primary key, rollout_path text); insert into threads values ('thread','native')`); err != nil { + t.Fatal(err) + } + if err := installRouteGuard(options); err != nil { + t.Fatal(err) + } + unc := mountfs.WindowsUNCPath(home, options.Mount) + for _, root := range []string{`t:\`, `\\?\T:\`, unc, `\\?\UNC\` + unc[2:]} { + for _, namespace := range sessionDirectories { + alias := filepath.Join(root, namespace, "2026", "rollout.jsonl") + if _, err := db.Exec(`update threads set rollout_path=? where id='thread'`, alias); err != nil { + t.Fatal(err) + } + var actual string + if err := db.QueryRow(`select rollout_path from threads where id='thread'`).Scan(&actual); err != nil { + t.Fatal(err) + } + expected := filepath.Join(home, namespace, "2026", "rollout.jsonl") + if actual != expected { + t.Fatalf("alias %q normalized to %q, wanted %q", alias, actual, expected) + } + } + } + foreign := `\\unrelated\share\sessions\rollout.jsonl` + if _, err := db.Exec(`update threads set rollout_path=? where id='thread'`, foreign); err != nil { + t.Fatal(err) + } + var actual string + if err := db.QueryRow(`select rollout_path from threads where id='thread'`).Scan(&actual); err != nil { + t.Fatal(err) + } + if actual != foreign { + t.Fatal("unrelated UNC route changed") + } +} diff --git a/internal/storage/gc.go b/internal/storage/gc.go index 6498bf9..19a5dc5 100644 --- a/internal/storage/gc.go +++ b/internal/storage/gc.go @@ -15,6 +15,8 @@ import ( "strconv" "strings" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) type CandidateKind string @@ -513,7 +515,7 @@ func syncGCRootDirectory(root *os.Root, relative string) error { return err } defer directory.Close() - return directory.Sync() + return dirsync.Sync(directory) } func (b *gcBuilder) add(path string, kind CandidateKind) error { diff --git a/internal/tray/accounting.go b/internal/tray/accounting.go new file mode 100644 index 0000000..7f6bd58 --- /dev/null +++ b/internal/tray/accounting.go @@ -0,0 +1,96 @@ +package tray + +import ( + "context" + "errors" + "path/filepath" + "sync" + "time" + + "github.com/samekind/codexfold/internal/pack" + "github.com/samekind/codexfold/internal/storage" +) + +type spaceAccounting struct { + Logical, Physical, CompressionLogical, Compressed, Pending int64 + UpdatedAt time.Time +} + +type accountingCache struct { + sync.Mutex + busy bool + next time.Time + value *spaceAccounting + err error +} + +func (m *Monitor) RequestAccountingRefresh() { + m.space.Lock() + defer m.space.Unlock() + if !m.space.busy { + m.space.next = time.Time{} + } +} + +func readSpaceAccounting(ctx context.Context, store string) (spaceAccounting, error) { + generation, err := pack.CurrentGeneration(store) + if err != nil { + return spaceAccounting{}, err + } + inventory, err := storage.Scan(ctx, storage.Options{StoreDir: store, AllowMetadataIssues: true}) + if err != nil { + return spaceAccounting{}, err + } + if inventory.IssueCount != 0 { + return spaceAccounting{}, errors.New("space inventory has unresolved metadata issues") + } + logical, err := pack.PublishedLogicalBytes(ctx, store, generation) + if err != nil { + return spaceAccounting{}, err + } + // Scan the published generation separately: reused pack parts can be hard + // linked from an older generation, and whole-store category attribution + // depends on traversal order. Compression cost must include the full pack. + core, err := storage.Scan(ctx, storage.Options{StoreDir: filepath.Join(store, "packs", generation)}) + if err != nil { + return spaceAccounting{}, err + } + current, err := pack.CurrentGeneration(store) + if err != nil || current != generation { + return spaceAccounting{}, errors.New("pack changed during space accounting") + } + // These retained representations are separate from current pack storage. + // Their removal remains subject to the worker's normal verification gates. + pending := max(int64(0), inventory.TotalPhysicalBytes-core.TotalPhysicalBytes-inventory.Metadata.PhysicalBytes-inventory.ActiveDeltas.PhysicalBytes-inventory.WritableBackings.PhysicalBytes) + return spaceAccounting{Logical: inventory.LogicalSessionBytes, Physical: inventory.TotalPhysicalBytes, + CompressionLogical: logical, Compressed: core.TotalPhysicalBytes, Pending: pending, UpdatedAt: time.Now().UTC()}, nil +} + +// File enumeration is off the UI/poll thread, bounded to one scan at a time. +// The tray reads metadata only; it never repairs, retires or deletes storage. +func (m *Monitor) accounting(now time.Time) (*spaceAccounting, error) { + cache := &m.space + cache.Lock() + defer cache.Unlock() + if !cache.busy && !now.Before(cache.next) { + cache.busy = true + cache.next = now.Add(time.Minute) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + value, err := readSpaceAccounting(ctx, m.Store) + cache.Lock() + defer cache.Unlock() + cache.busy = false + cache.err = err + if err == nil { + cache.value = &value + } + }() + } + if cache.value != nil && now.Sub(cache.value.UpdatedAt) <= 5*time.Minute { + copy := *cache.value + return ©, cache.err + } + return nil, cache.err +} diff --git a/internal/tray/accounting_test.go b/internal/tray/accounting_test.go new file mode 100644 index 0000000..1455caa --- /dev/null +++ b/internal/tray/accounting_test.go @@ -0,0 +1,70 @@ +package tray + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/pack" + "github.com/samekind/codexfold/internal/storage" +) + +func TestSpaceAccountingSeparatesCompressionAndUnpublishedCopies(t *testing.T) { + oldLimits := storage.DefaultLimits + storage.DefaultLimits.FreeSpaceReserveBytes = 1 << 20 + t.Cleanup(func() { storage.DefaultLimits = oldLimits }) + store := t.TempDir() + native := t.TempDir() + objects := fold.NewObjectStore(store) + write := func(id string, payload []byte) { + t.Helper() + ref, _, err := objects.Put(payload, true) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(payload) + original := filepath.Join(native, id+".jsonl") + if err := os.WriteFile(original, payload, 0o600); err != nil { + t.Fatal(err) + } + manifest := fold.Manifest{Version: fold.ManifestVersion, Kind: fold.ManifestKind, Session: fold.ManifestSession{ID: id, RolloutPath: original}, Parts: []fold.Part{{Kind: fold.PartResidual, Object: ref}}} + manifest.Source.Bytes = int64(len(payload)) + manifest.Source.SHA256 = hex.EncodeToString(digest[:]) + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(store, "manifests"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(store, "manifests", id+".json"), data, 0o600); err != nil { + t.Fatal(err) + } + } + first := bytes.Repeat([]byte("published-compressed-data"), 20000) + write("first", first) + if _, err := pack.Build(context.Background(), store, pack.BuildOptions{}); err != nil { + t.Fatal(err) + } + before, err := readSpaceAccounting(context.Background(), store) + if err != nil { + t.Fatal(err) + } + write("pending", bytes.Repeat([]byte("unpublished-extra-data"), 20000)) + after, err := readSpaceAccounting(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if before.CompressionLogical != int64(len(first)) || after.CompressionLogical != before.CompressionLogical || after.Compressed != before.Compressed { + t.Fatalf("unpublished work changed compression: before=%+v after=%+v", before, after) + } + if after.Physical <= before.Physical || after.Pending <= before.Pending { + t.Fatalf("extra copies not exposed: before=%+v after=%+v", before, after) + } +} diff --git a/internal/tray/dashboard.html b/internal/tray/dashboard.html new file mode 100644 index 0000000..0fa8f14 --- /dev/null +++ b/internal/tray/dashboard.html @@ -0,0 +1,580 @@ + + + + + + +CodexFold + + + + + + + + + + + + + + + + + + + + + + + + + + CodexFold未连接 + + + 检查状态 + 查看诊断 + + + + 打开存储目录 + 文件服务日志 + 自动折叠日志 + 复制诊断 + 导出诊断 + 检查状态 + + 退出托盘 + + + + + + + + + 压缩减少— + 压缩比例— + 会话数据— + 磁盘占用— + + + 自动折叠未连接启用 + 正在读取后台状态… + + 检查间隔1 分钟5 分钟30 分钟1 小时2 小时 + 会话空闲时间1 小时6 小时1 天 + 折叠范围仅归档会话归档和空闲会话 + 每批会话数自动调整1 个5 个10 个25 个50 个100 个200 个 + + + 修复设置并保持暂停 + + 待回收占用— + 当前净节省— + + + + + 空间变化 + + 1 小时 + 24 小时 + 7 天 + 30 天 + + + 原始大小当前占用 + + + + + + + 暂无记录 + + 24 小时前现在 + + + + + + 读写速度 + 读取 —写入 — + + + + + + + 暂无记录 + + 10 分钟前现在 + + 故障记录暂无持续故障记录 + + 运行状态托管会话:— + + 文件服务未连接 + 会话管理未连接 + + 技术详情 + + + + + 诊断 + + + 诊断包含运行状态、设置和汇总记录,不含会话内容。 + 查看折叠日志复制诊断导出 JSON关闭 + + + + diff --git a/internal/tray/diagnostics.go b/internal/tray/diagnostics.go new file mode 100644 index 0000000..15496cd --- /dev/null +++ b/internal/tray/diagnostics.go @@ -0,0 +1,42 @@ +package tray + +import ( + "encoding/json" + "runtime" + "runtime/debug" + "time" +) + +type DiagnosticReport struct { + Version int + GeneratedAt string + Platform string + GoVersion string + SourceRevision string + SourceModified bool + SessionContent bool + Snapshot View +} + +func Diagnostics(view View) DiagnosticReport { + report := DiagnosticReport{Version: 1, GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), + Platform: runtime.GOOS + "/" + runtime.GOARCH, GoVersion: runtime.Version(), Snapshot: view} + // An operation acknowledgement is transient UI state, not a diagnostic. + report.Snapshot.Operation = nil + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + report.SourceRevision = setting.Value + case "vcs.modified": + report.SourceModified = setting.Value == "true" + } + } + } + return report +} + +func DiagnosticBytes(view View) ([]byte, error) { + return json.MarshalIndent(Diagnostics(view), "", " ") +} +func ExportDiagnostics(path string, view View) error { return writeJSONFile(path, Diagnostics(view)) } diff --git a/internal/tray/diagnostics_windows.go b/internal/tray/diagnostics_windows.go new file mode 100644 index 0000000..a98f475 --- /dev/null +++ b/internal/tray/diagnostics_windows.go @@ -0,0 +1,128 @@ +//go:build windows + +package tray + +import ( + "errors" + "fmt" + "time" + utf16codec "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +type openFileName struct { + Size uint32 + Owner uintptr + Instance uintptr + Filter *uint16 + CustomFilter *uint16 + MaxCustom uint32 + FilterIndex uint32 + File *uint16 + MaxFile uint32 + FileTitle *uint16 + MaxFileTitle uint32 + InitialDir *uint16 + Title *uint16 + Flags uint32 + FileOffset uint16 + FileExtension uint16 + DefaultExt *uint16 + CustomData uintptr + Hook uintptr + Template *uint16 + Reserved uintptr + ReservedWord uint32 + FlagsEx uint32 +} + +func chooseDiagnosticFile(owner uintptr) (string, error) { + buffer := make([]uint16, 32768) + name, _ := windows.UTF16FromString("codexfold-diagnostics-" + time.Now().Format("20060102-150405") + ".json") + copy(buffer, name) + filter := utf16codec.Encode([]rune("JSON 诊断文件\x00*.json\x00所有文件\x00*.*\x00\x00")) + dialog := openFileName{Owner: owner, Filter: &filter[0], FilterIndex: 1, File: &buffer[0], MaxFile: uint32(len(buffer)), + Title: utf16("导出 CodexFold 诊断"), DefaultExt: utf16("json"), Flags: 0x2 | 0x8 | 0x800 | 0x80000} + dialog.Size = uint32(unsafe.Sizeof(dialog)) + common := windows.NewLazySystemDLL("comdlg32.dll") + if ok, _, _ := common.NewProc("GetSaveFileNameW").Call(uintptr(unsafe.Pointer(&dialog))); ok == 0 { + code, _, _ := common.NewProc("CommDlgExtendedError").Call() + if code != 0 { + return "", fmt.Errorf("保存窗口错误 0x%x", code) + } + return "", nil + } + return windows.UTF16ToString(buffer), nil +} + +func copyDiagnosticText(owner uintptr, value string) error { + text, err := windows.UTF16FromString(value) + if err != nil { + return err + } + kernel := windows.NewLazySystemDLL("kernel32.dll") + if ok, _, _ := user32.NewProc("OpenClipboard").Call(owner); ok == 0 { + return errors.New("剪贴板正被其他程序占用,请稍后重试") + } + defer user32.NewProc("CloseClipboard").Call() + memory, _, _ := kernel.NewProc("GlobalAlloc").Call(2, uintptr(len(text)*2)) + if memory == 0 { + return errors.New("无法分配剪贴板内存") + } + owned := true + defer func() { + if owned { + kernel.NewProc("GlobalFree").Call(memory) + } + }() + address, _, _ := kernel.NewProc("GlobalLock").Call(memory) + if address == 0 { + return errors.New("无法写入剪贴板") + } + moveMemory.Call(address, uintptr(unsafe.Pointer(&text[0])), uintptr(len(text)*2)) + kernel.NewProc("GlobalUnlock").Call(memory) + if ok, _, _ := user32.NewProc("EmptyClipboard").Call(); ok == 0 { + return errors.New("无法清空剪贴板") + } + if ok, _, _ := user32.NewProc("SetClipboardData").Call(13, memory); ok == 0 { // CF_UNICODETEXT + return errors.New("复制失败,请稍后重试") + } + owned = false // Windows owns the allocation after SetClipboardData. + return nil +} + +func (a *application) reply(result ActionResult) { + data, err := marshalActionResult(result) + if err == nil { + a.browser.Eval("window.showOperationResult && window.showOperationResult(" + string(data) + ")") + } +} + +func (a *application) exportDiagnostics() { + path, err := chooseDiagnosticFile(a.window) + if err == nil && path == "" { + return + } + if err == nil { + err = ExportDiagnostics(path, a.view) + } + result := ActionResult{ID: "export-diagnostics", OK: err == nil, Message: "诊断已导出:" + path} + if err != nil { + result.Message = "导出失败:" + err.Error() + } + a.reply(result) +} + +func (a *application) copyDiagnostics() { + data, err := DiagnosticBytes(a.view) + if err == nil { + err = copyDiagnosticText(a.window, string(data)) + } + result := ActionResult{ID: "copy-diagnostics", OK: err == nil, Message: "诊断已复制,不含会话内容。"} + if err != nil { + result.Message = "复制失败:" + err.Error() + } + a.reply(result) +} diff --git a/internal/tray/files_other.go b/internal/tray/files_other.go new file mode 100644 index 0000000..bb935d5 --- /dev/null +++ b/internal/tray/files_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package tray + +import "os" + +func openSharedFile(path string) (*os.File, error) { return os.Open(path) } +func replaceUIFile(source, destination string) error { return os.Rename(source, destination) } diff --git a/internal/tray/files_windows.go b/internal/tray/files_windows.go new file mode 100644 index 0000000..f0013ad --- /dev/null +++ b/internal/tray/files_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package tray + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func openSharedFile(path string) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(handle), path), nil +} + +func replaceUIFile(source, destination string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/tray/history.go b/internal/tray/history.go new file mode 100644 index 0000000..3b02e41 --- /dev/null +++ b/internal/tray/history.go @@ -0,0 +1,272 @@ +package tray + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" +) + +const ( + historyRetention = 30 * 24 * time.Hour + historyMaxBytes = 4 << 20 + incidentDelay = 10 * time.Second +) + +type HistorySample struct { + T int64 `json:"t"` + Logical *int64 `json:"l,omitempty"` + Physical *int64 `json:"p,omitempty"` + Read *float64 `json:"r,omitempty"` + Write *float64 `json:"w,omitempty"` + Health string `json:"health"` +} + +type Incident struct { + ID string `json:"id"` + Source string `json:"source"` + Reason string `json:"reason"` + Impact string `json:"impact"` + Recommendations []string `json:"recommendations"` + Detail string `json:"detail"` + StartedAt int64 `json:"started_at"` + RecoveredAt *int64 `json:"recovered_at,omitempty"` +} + +type runtimeIssue struct { + Source, Reason, Impact, Detail string + Recommendations []string +} + +type historyArchive struct { + Version int `json:"version"` + Samples []HistorySample `json:"samples"` + Incidents []Incident `json:"incidents"` +} + +type historyState struct { + archive historyArchive + path string + errorText string + readError bool + dirty bool + lastSave time.Time + pending *runtimeIssue + since time.Time +} + +func loadHistory(store string, now time.Time) historyState { + h := historyState{path: filepath.Join(store, "enrollment", "ui-history-v1.json"), archive: historyArchive{Version: 1}} + file, err := openSharedFile(h.path) + if errors.Is(err, os.ErrNotExist) { + return h + } + if err == nil { + defer file.Close() + var data []byte + data, err = io.ReadAll(io.LimitReader(file, historyMaxBytes+1)) + if err == nil && len(data) > historyMaxBytes { + err = errors.New("history file exceeds its size limit") + } + if err == nil { + err = json.Unmarshal(data, &h.archive) + } + if err == nil && (h.archive.Version != 1 || len(h.archive.Samples) > 5000 || len(h.archive.Incidents) > 200) { + err = errors.New("history version or record count is invalid") + } + } + if err != nil { + // Preserve an unreadable archive. An empty GUI history is not permission + // to replace existing evidence with a new empty file. + h.archive = historyArchive{Version: 1} + h.errorText, h.readError = "历史文件无法读取,原文件已保留;新记录暂未写入。", true + return h + } + h.compact(now) + return h +} + +func (h *historyState) record(view View, issue *runtimeIssue, now time.Time) { + if view.LogicalBytes != nil || view.PhysicalBytes != nil || view.ReadRate != nil || view.WriteRate != nil { + sample := HistorySample{T: now.UnixMilli(), Logical: view.LogicalBytes, Physical: view.PhysicalBytes, + Read: view.ReadRate, Write: view.WriteRate, Health: view.Health} + minute := sample.T / 60000 + count := len(h.archive.Samples) + if count > 0 && h.archive.Samples[count-1].T/60000 == minute { + h.archive.Samples[count-1] = sample + } else { + h.archive.Samples = append(h.archive.Samples, sample) + h.dirty = true + } + } + incidentChanged := h.recoverIncidents(view, now) + before := len(h.archive.Incidents) + h.recordIncident(issue, false, now) + incidentChanged = incidentChanged || len(h.archive.Incidents) != before + if h.dirty && (incidentChanged || h.lastSave.IsZero() || now.Sub(h.lastSave) >= time.Minute) { + h.flush(now) + } +} + +func (h *historyState) recoverIncidents(view View, now time.Time) bool { + changed := false + for index := range h.archive.Incidents { + item := &h.archive.Incidents[index] + if item.RecoveredAt != nil { + continue + } + healthy := item.Source == "filesystem" && view.DaemonState == "运行正常" || + item.Source == "managed" && view.ManagedState == "运行正常" + if item.Source == "enrollment" { + switch view.EnrollmentState { + case "等待下次检查", "正在检查", "正在折叠", "正在打包", "正在迁移", "正在回收", "等待回收", "已关闭": + healthy = view.Progress != nil && view.Progress.Fresh + } + } + if healthy { + at := now.UnixMilli() + item.RecoveredAt, h.dirty, changed = &at, true, true + } + } + return changed +} + +func (h *historyState) recordIncident(issue *runtimeIssue, healthy bool, now time.Time) { + if issue == nil { + h.pending, h.since = nil, time.Time{} + if healthy { + for index := range h.archive.Incidents { + if h.archive.Incidents[index].RecoveredAt == nil { + at := now.UnixMilli() + h.archive.Incidents[index].RecoveredAt = &at + h.dirty = true + } + } + } + return + } + if h.pending == nil || h.pending.Source != issue.Source { + copy := *issue + h.pending, h.since = ©, now + } + var active *Incident + for index := range h.archive.Incidents { + item := &h.archive.Incidents[index] + if item.RecoveredAt == nil && item.Source == issue.Source { + active = item + break + } + } + if active == nil && now.Sub(h.since) >= incidentDelay { + h.archive.Incidents = append(h.archive.Incidents, Incident{ID: fmt.Sprintf("%s-%d", issue.Source, h.since.UnixNano()), + Source: issue.Source, StartedAt: h.since.UnixMilli()}) + active = &h.archive.Incidents[len(h.archive.Incidents)-1] + h.dirty = true + } + if active != nil { + if active.Reason != issue.Reason || active.Detail != issue.Detail { + h.dirty = true + } + active.Reason, active.Impact, active.Detail = issue.Reason, issue.Impact, issue.Detail + active.Recommendations = append([]string(nil), issue.Recommendations...) + } +} + +func historyResolution(age time.Duration) time.Duration { + if age <= 6*time.Hour { + return time.Minute + } + if age <= 7*24*time.Hour { + return 15 * time.Minute + } + return time.Hour +} + +func (h *historyState) compact(now time.Time) { + cutoff, future := now.Add(-historyRetention).UnixMilli(), now.Add(time.Minute).UnixMilli() + buckets := make(map[[2]int64]HistorySample) + for _, sample := range h.archive.Samples { + if sample.T < cutoff || sample.T > future || sample.T <= 0 { + continue + } + resolution := historyResolution(now.Sub(time.UnixMilli(sample.T))).Milliseconds() + key := [2]int64{resolution, sample.T / resolution} + if previous, exists := buckets[key]; !exists || sample.T > previous.T { + buckets[key] = sample + } + } + h.archive.Samples = make([]HistorySample, 0, len(buckets)) + for _, sample := range buckets { + h.archive.Samples = append(h.archive.Samples, sample) + } + sort.Slice(h.archive.Samples, func(i, j int) bool { return h.archive.Samples[i].T < h.archive.Samples[j].T }) + incidents := h.archive.Incidents[:0] + for _, item := range h.archive.Incidents { + last := item.StartedAt + if item.RecoveredAt != nil { + last = *item.RecoveredAt + } + if last >= cutoff && item.StartedAt <= future { + incidents = append(incidents, item) + } + } + sort.Slice(incidents, func(i, j int) bool { return incidents[i].StartedAt > incidents[j].StartedAt }) + if len(incidents) > 200 { + incidents = incidents[:200] + } + h.archive.Incidents = incidents +} + +func (h *historyState) flush(now time.Time) { + if h.readError { + return + } + h.compact(now) + h.lastSave = now + if err := writeJSONFile(h.path, h.archive); err != nil { + h.errorText = "历史记录未保存,请检查目录权限;系统将稍后重试。" + return + } + h.dirty, h.errorText = false, "" +} + +func writeJSONFile(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if len(data) > historyMaxBytes { + return errors.New("diagnostic/history payload exceeds its size limit") + } + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + file, err := os.CreateTemp(directory, ".codexfold-ui-*.tmp") + if err != nil { + return err + } + temporary := file.Name() + defer os.Remove(temporary) + if err := file.Chmod(0o600); err != nil { + file.Close() + return err + } + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return replaceUIFile(temporary, path) +} diff --git a/internal/tray/history_test.go b/internal/tray/history_test.go new file mode 100644 index 0000000..3e4d1fc --- /dev/null +++ b/internal/tray/history_test.go @@ -0,0 +1,108 @@ +package tray + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func TestHistorySurvivesRestartAndRetainsOnlyObservedThirtyDays(t *testing.T) { + store := t.TempDir() + now := time.Now().UTC().Truncate(time.Minute) + h := loadHistory(store, now) + logical, physical, read := int64(2000), int64(800), float64(32) + view := View{LogicalBytes: &logical, PhysicalBytes: &physical, ReadRate: &read, Health: "运行正常", DaemonState: "运行正常", ManagedState: "运行正常", Healthy: true} + for minute := 31 * 24 * 60; minute >= 0; minute-- { + h.archive.Samples = append(h.archive.Samples, HistorySample{T: now.Add(-time.Duration(minute) * time.Minute).UnixMilli(), Logical: &logical, Physical: &physical}) + } + h.record(view, nil, now) + h.flush(now) + if h.errorText != "" { + t.Fatal(h.errorText) + } + loaded := loadHistory(store, now) + if len(loaded.archive.Samples) == 0 || len(loaded.archive.Samples) > 2000 || loaded.archive.Samples[0].T < now.Add(-historyRetention).UnixMilli() { + t.Fatalf("history retention/downsampling failed: %d", len(loaded.archive.Samples)) + } + before := len(loaded.archive.Samples) + loaded.record(view, nil, now.Add(10*time.Second)) + if len(loaded.archive.Samples) != before { + t.Fatal("one minute created duplicate samples") + } + loaded.record(view, nil, now.Add(10*time.Minute)) + found := false + for _, sample := range loaded.archive.Samples { + if sample.T > now.Add(10*time.Second).UnixMilli() && sample.T < now.Add(10*time.Minute).UnixMilli() { + t.Fatal("unobserved gap was filled with invented samples") + } + if sample.T == now.Add(10*time.Minute).UnixMilli() { + found = true + } + } + if !found { + t.Fatal("new observation was not recorded") + } +} + +func TestIncidentsConfirmAfterTenSecondsAndRecoverAtObservation(t *testing.T) { + now := time.Now().UTC() + h := loadHistory(t.TempDir(), now) + issue := &runtimeIssue{Source: "filesystem", Reason: "心跳停止", Impact: "访问待确认", Recommendations: []string{"检查状态"}} + bad := View{Health: "需要关注", DaemonState: "状态不可用"} + h.record(bad, issue, now) + h.record(bad, issue, now.Add(9*time.Second)) + if len(h.archive.Incidents) != 0 { + t.Fatal("transient incident persisted") + } + h.record(bad, issue, now.Add(10*time.Second)) + if len(h.archive.Incidents) != 1 || h.archive.Incidents[0].StartedAt != now.UnixMilli() { + t.Fatal("sustained incident lost original observation") + } + loaded := loadHistory(filepath.Dir(filepath.Dir(h.path)), now.Add(time.Minute)) + if len(loaded.archive.Incidents) != 1 || loaded.archive.Incidents[0].RecoveredAt != nil { + t.Fatal("active incident did not survive restart") + } + // A different component can still be failing when this source recovers. + other := &runtimeIssue{Source: "managed", Reason: "管理状态中断"} + loaded.record(View{DaemonState: "运行正常", ManagedState: "状态不可用"}, other, now.Add(time.Minute)) + if at := loaded.archive.Incidents[0].RecoveredAt; at == nil || *at != now.Add(time.Minute).UnixMilli() { + t.Fatal("recovery was delayed behind unrelated failure") + } + loaded.flush(now.Add(time.Minute)) + again := loadHistory(filepath.Dir(filepath.Dir(h.path)), now.Add(2*time.Minute)) + if len(again.archive.Incidents) != 1 || again.archive.Incidents[0].RecoveredAt == nil { + t.Fatal("recovery did not persist") + } +} + +func TestUnreadableHistoryIsPreservedAndDiagnosticsAreAggregateOnly(t *testing.T) { + store := t.TempDir() + path := filepath.Join(store, "enrollment", "ui-history-v1.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + original := []byte(`{"version":99,"session_text":"private sentinel"}`) + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + h := loadHistory(store, time.Now()) + h.flush(time.Now()) + data, err := os.ReadFile(path) + if err != nil || !bytes.Equal(data, original) || h.errorText == "" { + t.Fatal("unreadable history was overwritten") + } + view := NewMonitor(store).Refresh(time.Now()) + diagnostics, err := DiagnosticBytes(view) + if err != nil || bytes.Contains(diagnostics, []byte("private sentinel")) { + t.Fatal("diagnostics read or leaked session content") + } + output := filepath.Join(t.TempDir(), "diagnostics.json") + if err := ExportDiagnostics(output, view); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(output); err != nil || info.Size() == 0 { + t.Fatal("diagnostics were not written") + } +} diff --git a/internal/tray/logs.go b/internal/tray/logs.go new file mode 100644 index 0000000..7ba42ba --- /dev/null +++ b/internal/tray/logs.go @@ -0,0 +1,42 @@ +package tray + +import ( + "io" + "os" + "path/filepath" + "strings" + + "github.com/samekind/codexfold/internal/service" +) + +func logDirectories(store string) map[string]string { + locations := map[string]string{"filesystem": filepath.Join(store, "fs"), "enrollment": filepath.Join(store, "enrollment")} + root := os.Getenv("ProgramData") + if root == "" { + return locations + } + root = filepath.Join(root, "CodexFold") + read := func(path string) []byte { + file, err := openSharedFile(path) + if err != nil { + return nil + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, 65537)) + if err != nil || len(data) > 65536 { + return nil + } + return data + } + if config, err := service.ParseWindowsConfig(read(filepath.Join(root, "service.json"))); err == nil && config.ServiceName == "com.codexfold.fs" { + for index, arg := range config.Arguments { + if arg == "--store" && index+1 < len(config.Arguments) && strings.EqualFold(filepath.Clean(config.Arguments[index+1]), filepath.Clean(store)) { + locations["filesystem"] = filepath.Dir(config.StderrPath) + } + } + } + if config, err := service.ParseWindowsEnrollmentConfig(read(filepath.Join(root, "Enrollment", "service.json"))); err == nil && config.ServiceName == "com.codexfold.enroll" && strings.EqualFold(filepath.Clean(config.Store), filepath.Clean(store)) { + locations["enrollment-service"] = filepath.Dir(config.StderrPath) + } + return locations +} diff --git a/internal/tray/policy.go b/internal/tray/policy.go new file mode 100644 index 0000000..364e124 --- /dev/null +++ b/internal/tray/policy.go @@ -0,0 +1,172 @@ +package tray + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/samekind/codexfold/internal/enroll" +) + +type PolicySettings struct { + Available bool + Repairable bool + Revision string + Enabled bool + Interval string + StableFor string + ArchivedOnly bool + BatchSize int + Error string +} + +type PolicyChange struct { + Action string `json:"action"` + ID string `json:"id"` + Revision string `json:"revision"` + Field string `json:"field"` + Value json.RawMessage `json:"value"` +} + +type ActionResult struct { + ID string + OK bool + Message string +} + +func marshalActionResult(result ActionResult) ([]byte, error) { return json.Marshal(result) } + +func decodePolicyChange(message string) (PolicyChange, error) { + if len(message) > 4096 { + return PolicyChange{}, errors.New("设置请求过长") + } + var request PolicyChange + decoder := json.NewDecoder(bytes.NewBufferString(message)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return request, errors.New("设置请求格式有误") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) || request.Action != "set-policy" || request.ID == "" || len(request.ID) > 64 { + return request, errors.New("设置请求格式有误") + } + return request, nil +} + +func (m *Monitor) policy() (string, enroll.Control, PolicySettings, error) { + path := enroll.WorkerControlPath(m.Store) + control, revision, err := enroll.LoadControlRevision(path) + _, workerProgressErr := os.Lstat(enroll.WorkerProgressPath(m.Store)) + if revision == "" && err == nil && errors.Is(workerProgressErr, os.ErrNotExist) { + path = enroll.ControlPath(m.Store) + control, revision, err = enroll.LoadControlRevision(path) + } + settings := PolicySettings{Available: revision != "", Revision: revision, + Enabled: control.Enabled, Interval: control.Interval.String(), StableFor: control.StableFor.String(), + ArchivedOnly: control.ArchivedOnly, BatchSize: control.BatchSize} + if err != nil { + settings.Error = "自动折叠设置无法读取;可修复为暂停状态,再重新设置。" + settings.Repairable = revision != "" + } else if !settings.Available { + settings.Error = "尚未找到自动折叠设置,请先安装后台服务。" + progressPath := enroll.ProgressPath(m.Store) + if path == enroll.WorkerControlPath(m.Store) { + progressPath = enroll.WorkerProgressPath(m.Store) + } + if _, progressErr := os.Lstat(progressPath); progressErr == nil { + settings.Repairable = true + settings.Error = "自动折叠设置缺失,可修复为暂停状态。" + if path == enroll.ControlPath(m.Store) { + settings.Error = "后台使用启动参数;可建立策略文件并保持暂停。" + } + } + } + return path, control, settings, err +} + +// ApplyPolicy changes one selected field. Custom values in the other fields +// survive GUI edits, and a stale GUI cannot overwrite newly installed settings. +func (m *Monitor) ApplyPolicy(request PolicyChange) ActionResult { + result := ActionResult{ID: request.ID} + path, control, settings, _ := m.policy() + if request.Revision != settings.Revision || !(settings.Available && request.Revision != "" || request.Field == "reset" && settings.Repairable) { + result.Message = "设置已变化,请等待刷新后重试。" + return result + } + if settings.Error != "" && request.Field != "reset" { + result.Message = settings.Error + return result + } + var err error + if request.Field != "reset" && (len(request.Value) == 0 || bytes.Equal(bytes.TrimSpace(request.Value), []byte("null"))) { + result.Message = "未保存:设置值不能为空。" + return result + } + switch request.Field { + case "enabled": + err = json.Unmarshal(request.Value, &control.Enabled) + if err == nil && control.Enabled && control.Interval < 30*time.Second { + err = errors.New("请先把检查间隔设为至少 30 秒") + } + case "interval", "stable_for": + var raw string + var value time.Duration + err = json.Unmarshal(request.Value, &raw) + if err == nil { + value, err = time.ParseDuration(raw) + } + if request.Field == "interval" { + if err == nil && (value < 30*time.Second || value > 24*time.Hour) { + err = errors.New("检查间隔须在 30 秒到 24 小时之间") + } + control.Interval = value + } else { + if err == nil && (value < time.Minute || value > 30*24*time.Hour) { + err = errors.New("空闲时间须在 1 分钟到 30 天之间") + } + control.StableFor = value + } + case "archived_only": + err = json.Unmarshal(request.Value, &control.ArchivedOnly) + case "batch_size": + err = json.Unmarshal(request.Value, &control.BatchSize) + if err == nil && (control.BatchSize < 0 || control.BatchSize > 1000) { + err = errors.New("每批会话数须为自动或 1 到 1000") + } + case "reset": + if settings.Error == "" { + err = errors.New("当前设置有效,无需修复") + } else { + control = enroll.Control{Present: true, Interval: 5 * time.Minute, StableFor: time.Hour, ArchivedOnly: true} + } + default: + err = errors.New("不支持的设置项") + } + if err != nil { + result.Message = "未保存:" + err.Error() + return result + } + // Check once more immediately before the atomic replacement. The worker only + // reads this file; changes from another UI or installer remain visible here. + _, latestRevision, loadErr := enroll.LoadControlRevision(path) + if latestRevision != request.Revision || loadErr != nil && request.Field != "reset" { + result.Message = "设置已变化,请等待刷新后重试。" + return result + } + if err := enroll.SaveControl(path, control); err != nil { + result.Message = fmt.Sprintf("设置未保存,请检查目录权限:%v", err) + return result + } + result.OK = true + result.Message = "已保存,新设置将由后台自动应用。" + if request.Field == "enabled" && !control.Enabled { + result.Message = "已请求暂停,等待当前操作安全退出。" + } + if request.Field == "reset" { + result.Message = "设置已修复,自动折叠保持暂停。" + } + return result +} diff --git a/internal/tray/policy_test.go b/internal/tray/policy_test.go new file mode 100644 index 0000000..52371d4 --- /dev/null +++ b/internal/tray/policy_test.go @@ -0,0 +1,104 @@ +package tray + +import ( + "bytes" + "encoding/json" + "os" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" +) + +func TestPolicyEditsPreserveCustomValuesAndRejectStaleRevision(t *testing.T) { + store := t.TempDir() + path := enroll.WorkerControlPath(store) + original := enroll.Control{Enabled: true, Interval: 37 * time.Minute, StableFor: 2 * time.Hour, BatchSize: 17} + if err := enroll.SaveControl(path, original); err != nil { + t.Fatal(err) + } + m := NewMonitor(store) + _, _, settings, _ := m.policy() + result := m.ApplyPolicy(PolicyChange{ID: "one", Revision: settings.Revision, Field: "archived_only", Value: json.RawMessage(`true`)}) + if !result.OK { + t.Fatal(result.Message) + } + got, err := enroll.LoadControl(path) + if err != nil || !got.Enabled || !got.ArchivedOnly || got.BatchSize != 17 || got.Interval != original.Interval || got.StableFor != original.StableFor { + t.Fatalf("custom settings changed: %#v, %v", got, err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result = m.ApplyPolicy(PolicyChange{ID: "stale", Revision: settings.Revision, Field: "enabled", Value: json.RawMessage(`false`)}) + if result.OK { + t.Fatal("stale page overwrote current settings") + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("rejected request changed policy") + } + _, _, settings, _ = m.policy() + for _, value := range []string{`null`, `"true"`} { + if result := m.ApplyPolicy(PolicyChange{Revision: settings.Revision, Field: "enabled", Value: json.RawMessage(value)}); result.OK { + t.Fatalf("invalid value accepted: %s", value) + } + } +} + +func TestPolicyRepairIsExplicitPausedAndDoesNotEnableBuiltinLoop(t *testing.T) { + store := t.TempDir() + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled}); err != nil { + t.Fatal(err) + } + m := NewMonitor(store) + path, _, settings, _ := m.policy() + if path != enroll.WorkerControlPath(store) || !settings.Repairable || settings.Available { + t.Fatalf("missing worker policy fell back to builtin: %s, %#v", path, settings) + } + if result := m.ApplyPolicy(PolicyChange{Field: "enabled", Value: json.RawMessage(`true`)}); result.OK { + t.Fatal("missing policy revived defaults") + } + if result := m.ApplyPolicy(PolicyChange{Field: "reset", Revision: settings.Revision}); !result.OK { + t.Fatal(result.Message) + } + for _, path := range []string{enroll.WorkerControlPath(store), enroll.ControlPath(store)} { + control, err := enroll.LoadControl(path) + if err != nil || control.Enabled { + t.Fatalf("repair enabled policy %s: %#v, %v", path, control, err) + } + } + if err := os.WriteFile(enroll.WorkerControlPath(store), []byte(`broken`), 0o600); err != nil { + t.Fatal(err) + } + _, _, settings, _ = m.policy() + if !settings.Repairable || settings.Revision == "" { + t.Fatal("malformed file has no repair revision") + } + if result := m.ApplyPolicy(PolicyChange{Field: "reset", Revision: settings.Revision}); !result.OK { + t.Fatal(result.Message) + } + control, err := enroll.LoadControl(enroll.WorkerControlPath(store)) + if err != nil || control.Enabled || control.BatchSize != 0 || !control.ArchivedOnly { + t.Fatalf("unsafe repair: %#v, %v", control, err) + } +} + +func TestDashboardPolicyRequestOnlyAcceptsStructuredSettings(t *testing.T) { + for _, message := range []string{ + `{"action":"exec","id":"one","field":"enabled","value":true}`, + `{"action":"set-policy","id":"one","command":"calc.exe"}`, + `{"action":"set-policy","id":"one"} {}`, + } { + if _, err := decodePolicyChange(message); err == nil { + t.Fatalf("unsafe request accepted: %s", message) + } + } + if _, err := decodePolicyChange(`{"action":"set-policy","id":"one","revision":"r","field":"enabled","value":false}`); err != nil { + t.Fatal(err) + } +} diff --git a/internal/tray/status.go b/internal/tray/status.go new file mode 100644 index 0000000..9718777 --- /dev/null +++ b/internal/tray/status.go @@ -0,0 +1,316 @@ +// Package tray implements the Windows status and automatic-folding companion. +package tray + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/fskitstatus" +) + +type View struct { + Health, Storage, Activity, Sessions, Detail string + Healthy bool + StorePath string + LogicalBytes, PhysicalBytes *int64 + CompressionLogicalBytes, CompressedBytes, PendingBytes *int64 + ReadRate, WriteRate *float64 + DaemonState, ManagedState, EnrollmentState string + Settings PolicySettings + Progress *EnrollmentProgress + HistorySamples []HistorySample + Incidents []Incident + HistoryError string + Operation *ActionResult + LogDirectories map[string]string +} + +type EnrollmentProgress struct { + Phase string + ManagedCount, WaitingCount int + WaitingKnown bool + CycleDone, CycleTotal int + NextCheckAt, UpdatedAt string + ErrorKind, LastError string + Fresh bool +} + +type observation struct { + snapshot fskitstatus.Snapshot + advanced time.Time +} + +type Monitor struct { + Store string + last map[string]observation + connected bool + space accountingCache + history historyState + logs map[string]string +} + +func NewMonitor(store string) *Monitor { + return &Monitor{Store: store, last: make(map[string]observation), history: loadHistory(store, time.Now()), logs: logDirectories(store)} +} + +func (m *Monitor) read(component string, now time.Time, maxAge time.Duration) (fskitstatus.Snapshot, error) { + s, err := fskitstatus.Read(filepath.Join(m.Store, "fs", "status", component+".json")) + if err != nil { + return s, err + } + if s.Component != component { + return s, errors.New("状态组件不匹配") + } + updated, err := time.Parse(time.RFC3339Nano, s.UpdatedAt) + if err != nil || updated.After(now.Add(5*time.Second)) || now.Sub(updated) > maxAge { + return s, errors.New("状态已过期,等待后台更新") + } + if component != "storage" { + if s.PublisherInstanceID == "" || s.BackendID == "" || s.ObservationSequence == 0 { + return s, errors.New("状态缺少运行实例或心跳序号") + } + previous := m.last[component] + if s.PublisherInstanceID == previous.snapshot.PublisherInstanceID { + if s.BackendID != previous.snapshot.BackendID || s.ObservationSequence < previous.snapshot.ObservationSequence { + return s, errors.New("状态序号或后台标识发生异常变化") + } + if s.ObservationSequence == previous.snapshot.ObservationSequence { + if now.Sub(previous.advanced) > maxAge { + return s, errors.New("后台心跳已停止") + } + return s, nil + } + } + m.last[component] = observation{s, now} + } + return s, nil +} + +func (m *Monitor) Refresh(now time.Time) View { + return m.refresh(now, true) +} + +// DiagnosticSnapshot reads aggregate status without competing with the resident +// tray for ownership of the persistent history archive. +func (m *Monitor) DiagnosticSnapshot(now time.Time) View { + return m.refresh(now, false) +} + +func (m *Monitor) refresh(now time.Time, recordHistory bool) View { + v := View{Health: "未连接", Storage: "空间统计:暂无数据", Activity: "读取 — 写入 —", Sessions: "托管会话:—", StorePath: m.Store, LogDirectories: m.logs} + previous := m.last["daemon"].snapshot + daemon, daemonErr := m.read("daemon", now, 15*time.Second) + managed, managedErr := m.read("managed", now, 15*time.Second) + v.DaemonState = componentState(daemon, daemonErr) + v.ManagedState = componentState(managed, managedErr) + v.EnrollmentState = "未连接" + policyPath, policy, settings, policyErr := m.policy() + v.Settings = settings + usingWorker := policyPath == enroll.WorkerControlPath(m.Store) + if settings.Available || settings.Repairable || policyErr != nil || daemonErr == nil && daemon.State == "healthy" { + progressPath := enroll.ProgressPath(m.Store) + progressMaxAge := 15 * time.Minute + if usingWorker { + progressPath = enroll.WorkerProgressPath(m.Store) + progressMaxAge = 30 * time.Second + } + progress, err := enroll.LoadProgress(progressPath) + if policyErr != nil { + err = policyErr + } + fresh := err == nil && !progress.UpdatedAt.IsZero() && !progress.UpdatedAt.After(now.Add(5*time.Second)) && now.Sub(progress.UpdatedAt) <= progressMaxAge + v.Progress = &EnrollmentProgress{Phase: progress.Phase, ManagedCount: progress.ManagedCount, + WaitingCount: progress.WaitingCount, WaitingKnown: progress.WaitingKnown, CycleDone: progress.CycleDone, + CycleTotal: progress.CycleTotal, ErrorKind: progress.ErrorKind, LastError: progress.LastError, Fresh: fresh} + if !progress.UpdatedAt.IsZero() { + v.Progress.UpdatedAt = progress.UpdatedAt.Format(time.RFC3339Nano) + } + if !progress.NextCheckAt.IsZero() { + v.Progress.NextCheckAt = progress.NextCheckAt.Format(time.RFC3339Nano) + } + switch { + case policyErr != nil: + v.EnrollmentState = "设置有误" + case err != nil: + v.EnrollmentState = "状态不可用" + case progress.UpdatedAt.IsZero(): + v.EnrollmentState = "暂无状态" + case progress.UpdatedAt.After(now.Add(5*time.Second)) || now.Sub(progress.UpdatedAt) > progressMaxAge: + v.EnrollmentState = "状态已过期" + case progress.Phase == enroll.PhaseConfigInvalid: + v.EnrollmentState = "设置有误" + case progress.Phase == enroll.PhaseStopped: + v.EnrollmentState = "后台已停止" + case progress.Phase == enroll.PhaseWaitingFilesystem: + v.EnrollmentState = "等待文件系统" + case settings.Available && !policy.Enabled && progress.Enabled: + v.EnrollmentState = "正在暂停" + case progress.LastError != "": + v.EnrollmentState = "需要关注" + case !progress.Enabled: + v.EnrollmentState = "已关闭" + case daemonErr != nil || daemon.State != "healthy": + v.EnrollmentState = "等待文件系统" + default: + v.EnrollmentState = map[string]string{ + enroll.PhaseIdle: "等待下次检查", enroll.PhaseChecking: "正在检查", + enroll.PhaseFolding: "正在折叠", enroll.PhasePacking: "正在打包", + enroll.PhaseMigrating: "正在迁移", enroll.PhaseReclaiming: "正在回收", + enroll.PhaseWaitingReclaim: "等待回收", + }[progress.Phase] + if v.EnrollmentState == "" { + v.EnrollmentState = "状态未知" + } + } + } + var details []string + if errors.Is(daemonErr, os.ErrNotExist) && errors.Is(managedErr, os.ErrNotExist) && !m.connected { + details = append(details, "尚未发现后台状态。请先在隔离目录中启动 WinFsp 文件服务。") + } else { + m.connected = true + v.Health = "需要关注" + if daemonErr == nil && managedErr == nil && daemon.State == "healthy" && managed.State == "healthy" { + v.Health, v.Healthy = "运行正常", true + } else if daemonErr == nil && (daemon.State == "starting" || daemon.State == "stopped") { + v.Health = map[string]string{"starting": "正在启动", "stopped": "已停止"}[daemon.State] + } + for _, channel := range []struct { + name string + snapshot fskitstatus.Snapshot + err error + }{ + {"后台", daemon, daemonErr}, {"会话", managed, managedErr}, + } { + if channel.err != nil { + details = append(details, channel.name+":"+channel.err.Error()) + } else { + details = append(details, channel.name+":"+channel.snapshot.State) + if channel.snapshot.Detail != "" { + details = append(details, channel.snapshot.Detail) + } + } + } + } + if managedErr == nil && managed.ManagedSessions != nil { + v.Sessions = fmt.Sprintf("托管会话:%d", *managed.ManagedSessions) + } + if daemonErr == nil && daemon.State == "healthy" && previous.State == "healthy" && + previous.PublisherInstanceID == daemon.PublisherInstanceID && previous.BackendID == daemon.BackendID && + daemon.ObservationSequence > previous.ObservationSequence && + daemon.ReadBytesTotal != nil && daemon.WrittenBytesTotal != nil && previous.ReadBytesTotal != nil && previous.WrittenBytesTotal != nil && + *daemon.ReadBytesTotal >= *previous.ReadBytesTotal && *daemon.WrittenBytesTotal >= *previous.WrittenBytesTotal { + before, _ := time.Parse(time.RFC3339Nano, previous.UpdatedAt) + after, _ := time.Parse(time.RFC3339Nano, daemon.UpdatedAt) + if seconds := after.Sub(before).Seconds(); seconds > 0 && seconds <= 15 { + readRate := float64(*daemon.ReadBytesTotal-*previous.ReadBytesTotal) / seconds + writeRate := float64(*daemon.WrittenBytesTotal-*previous.WrittenBytesTotal) / seconds + v.ReadRate, v.WriteRate = &readRate, &writeRate + v.Activity = fmt.Sprintf("读取 %s/s 写入 %s/s", bytesText(readRate), bytesText(writeRate)) + } + } + storage, storageErr := m.read("storage", now, 15*time.Minute) + if storageErr == nil && storage.State == "healthy" && storage.LogicalBytes != nil && storage.PhysicalBytes != nil && *storage.LogicalBytes >= 0 && *storage.PhysicalBytes >= 0 { + logical, physical := float64(*storage.LogicalBytes), float64(*storage.PhysicalBytes) + v.LogicalBytes, v.PhysicalBytes = storage.LogicalBytes, storage.PhysicalBytes + saving := "暂无可比较数据" + if logical > 0 { + if physical <= logical { + saving = fmt.Sprintf("净节省 %.1f%%", (logical-physical)/logical*100) + } else { + saving = "物理占用高于逻辑数据量" + } + } + v.Storage = fmt.Sprintf("逻辑数据 %s 物理占用 %s\r\n%s", bytesText(logical), bytesText(physical), saving) + details = append(details, "空间统计包含存储、保留副本和恢复文件;更新时间:"+storage.UpdatedAt) + } else if storageErr != nil && !errors.Is(storageErr, os.ErrNotExist) { + details = append(details, "空间统计:"+storageErr.Error()) + } + if measured, err := m.accounting(now); measured != nil { + v.LogicalBytes, v.PhysicalBytes = &measured.Logical, &measured.Physical + v.CompressionLogicalBytes, v.CompressedBytes, v.PendingBytes = &measured.CompressionLogical, &measured.Compressed, &measured.Pending + details = append(details, "压缩统计依据已发布压缩包;待回收占用包含保留原件、散对象、旧版本及恢复文件,实际回收需通过校验。更新时间:"+measured.UpdatedAt.Format(time.RFC3339)) + if err != nil { + details = append(details, "空间明细正在重试,暂显示最近一次有效统计。") + } + } + if daemon.MountPoint != "" { + details = append(details, "挂载目录:"+daemon.MountPoint) + } + details = append(details, "存储目录:"+m.Store, "Windows 预览版 · 退出此界面后后台服务继续运行。") + v.Detail = strings.Join(details, "\r\n\r\n") + var issue *runtimeIssue + if m.connected || settings.Available || settings.Repairable { + switch { + case daemonErr != nil || daemon.State != "healthy": + issue = &runtimeIssue{Source: "filesystem", Reason: "文件服务尚未就绪", Impact: "托管会话的访问可能暂时受影响。", + Recommendations: []string{"等待 Windows 服务恢复后点击“检查状态”。", "持续未恢复时查看文件服务日志并导出诊断。"}} + if daemonErr != nil { + issue.Detail = daemonErr.Error() + } else { + issue.Detail = daemon.Detail + } + case managedErr != nil || managed.State != "healthy": + issue = &runtimeIssue{Source: "managed", Reason: "会话管理状态异常", Impact: "托管会话的当前健康状态尚未得到确认。", + Recommendations: []string{"稍后重新检查状态。", "持续异常时查看服务日志并导出诊断。"}, Detail: managed.Detail} + if managedErr != nil { + issue.Detail = managedErr.Error() + } + case policyErr != nil || usingWorker && settings.Repairable && settings.Error != "": + issue = &runtimeIssue{Source: "enrollment", Reason: "自动折叠设置无效", Impact: "新的自动折叠工作暂停。", + Recommendations: []string{"点击“修复设置”,然后选择所需参数。"}, Detail: settings.Error} + if policyErr != nil { + issue.Detail = policyErr.Error() + } + case settings.Enabled && (v.EnrollmentState == "状态已过期" || v.EnrollmentState == "后台已停止" || v.EnrollmentState == "需要关注" || v.EnrollmentState == "状态不可用" || v.EnrollmentState == "等待文件系统"): + issue = &runtimeIssue{Source: "enrollment", Reason: "自动折叠需要关注", Impact: "新会话的压缩或回收可能需要稍后重试。", + Recommendations: []string{"查看自动折叠日志。", "需要排查时复制或导出诊断。"}, Detail: v.EnrollmentState} + if v.Progress != nil { + issue.Detail += ";阶段:" + v.Progress.Phase + ";错误类型:" + v.Progress.ErrorKind + if v.Progress.ErrorKind == "budget" { + issue.Reason = "自动折叠的磁盘空间预算不足" + issue.Recommendations = []string{"释放磁盘空间后等待后台重试。", "原始数据和保留副本须在校验通过后才能回收。"} + } + } + } + } + if issue != nil && issue.Source == "enrollment" { + v.Health, v.Healthy = "需要关注", false + } + if recordHistory { + m.history.record(v, issue, now) + } + v.HistorySamples = append([]HistorySample(nil), m.history.archive.Samples...) + v.Incidents = append([]Incident(nil), m.history.archive.Incidents...) + v.HistoryError = m.history.errorText + return v +} + +func (m *Monitor) FlushHistory() { m.history.flush(time.Now()) } + +func componentState(snapshot fskitstatus.Snapshot, err error) string { + if errors.Is(err, os.ErrNotExist) { + return "未连接" + } + if err != nil { + return "状态不可用" + } + if text, ok := map[string]string{"healthy": "运行正常", "starting": "正在启动", "stopped": "已停止", "degraded": "需要关注", "error": "需要关注"}[snapshot.State]; ok { + return text + } + return "状态未知" +} + +func bytesText(value float64) string { + units := []string{"B", "KiB", "MiB", "GiB", "TiB"} + i := 0 + for value >= 1024 && i < len(units)-1 { + value /= 1024 + i++ + } + return fmt.Sprintf("%.1f %s", value, units[i]) +} diff --git a/internal/tray/status_test.go b/internal/tray/status_test.go new file mode 100644 index 0000000..bc9897c --- /dev/null +++ b/internal/tray/status_test.go @@ -0,0 +1,99 @@ +package tray + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/fskitstatus" +) + +func TestMonitorMissingHealthyStaleAndRestart(t *testing.T) { + store := t.TempDir() + m := NewMonitor(store) + now := time.Now().UTC() + if got := m.Refresh(now); got.Health != "未连接" || got.Healthy || got.DaemonState != "未连接" || got.EnrollmentState != "未连接" { + t.Fatalf("missing: %+v", got) + } + write := func(component string, sequence, read uint64, instance string, at time.Time) { + s := fskitstatus.Snapshot{Component: component, State: "healthy", UpdatedAt: at.Format(time.RFC3339Nano), PublisherInstanceID: instance, BackendID: "test", ObservationSequence: sequence, MountPoint: store, ResourcePath: store, ReadBytesTotal: &read, WrittenBytesTotal: &read} + if err := fskitstatus.Write(filepath.Join(store, "fs", "status", component+".json"), s); err != nil { + t.Fatal(err) + } + } + write("daemon", 1, 0, "one", now) + write("managed", 1, 0, "one", now) + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{StorePath: store, Enabled: true, Phase: enroll.PhaseFolding, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if got := m.Refresh(now); !got.Healthy || got.DaemonState != "运行正常" || got.ManagedState != "运行正常" || got.EnrollmentState != "正在折叠" { + t.Fatalf("healthy: %+v", got) + } + if err := enroll.SaveControl(enroll.WorkerControlPath(store), enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 1}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Enabled: true, Phase: enroll.PhasePacking, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if got := m.Refresh(now); got.EnrollmentState != "正在打包" { + t.Fatalf("worker status was not selected: %+v", got) + } + write("daemon", 2, 2048, "one", now.Add(2*time.Second)) + write("managed", 2, 0, "one", now.Add(2*time.Second)) + if got := m.Refresh(now.Add(2 * time.Second)); !strings.Contains(got.Activity, "1.0 KiB/s") { + t.Fatalf("rate: %+v", got) + } + if got := m.Refresh(now.Add(20 * time.Second)); got.Healthy || got.Health != "需要关注" || got.DaemonState != "状态不可用" || got.EnrollmentState != "等待文件系统" { + t.Fatalf("stale: %+v", got) + } + write("daemon", 1, 50, "two", now.Add(21*time.Second)) + write("managed", 1, 0, "two", now.Add(21*time.Second)) + if got := m.Refresh(now.Add(21 * time.Second)); !got.Healthy || got.Activity != "读取 — 写入 —" { + t.Fatalf("restart: %+v", got) + } +} + +func TestMonitorPersistentWorkerStatesWithoutMount(t *testing.T) { + store := t.TempDir() + now := time.Now() + if err := enroll.SaveControl(enroll.WorkerControlPath(store), enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5}); err != nil { + t.Fatal(err) + } + for _, state := range []struct { + phase string + enabled bool + lastError, want string + }{ + {enroll.PhaseWaitingFilesystem, true, "heartbeat is stale", "等待文件系统"}, + {enroll.PhaseStopped, true, "", "后台已停止"}, + {enroll.PhaseConfigInvalid, false, "invalid policy", "设置有误"}, + {enroll.PhaseDisabled, false, "", "已关闭"}, + } { + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{Enabled: state.enabled, Phase: state.phase, LastError: state.lastError, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if got := NewMonitor(store).Refresh(now); got.EnrollmentState != state.want { + t.Fatalf("%s: %+v", state.phase, got) + } + } +} + +func TestMonitorRejectsRewrittenTimestampWithoutProgress(t *testing.T) { + store := t.TempDir() + m := NewMonitor(store) + now := time.Now().UTC() + for _, at := range []time.Time{now, now.Add(20 * time.Second)} { + for _, component := range []string{"daemon", "managed"} { + s := fskitstatus.Snapshot{Component: component, State: "healthy", UpdatedAt: at.Format(time.RFC3339Nano), PublisherInstanceID: "one", BackendID: "one", ObservationSequence: 1, MountPoint: store, ResourcePath: store} + if err := fskitstatus.Write(filepath.Join(store, "fs", "status", component+".json"), s); err != nil { + t.Fatal(err) + } + } + got := m.Refresh(at) + if at.After(now) && got.Healthy { + t.Fatal("timestamp rewrite accepted as heartbeat") + } + } +} diff --git a/internal/tray/testdata/windows-smoke.cjs b/internal/tray/testdata/windows-smoke.cjs new file mode 100644 index 0000000..db590ff --- /dev/null +++ b/internal/tray/testdata/windows-smoke.cjs @@ -0,0 +1,231 @@ +// Run after building the tray: node internal/tray/testdata/windows-smoke.cjs [screenshots-dir] [live-store] +// Exercises the real embedded WebView2 through a temporary loopback debug port. +// No desktop automation, extra npm dependencies, or production store is used. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const net = require('node:net'); +const path = require('node:path'); +const {spawn} = require('node:child_process'); + +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function until(action, label) { + const deadline = Date.now() + 15000; + let last; + while (Date.now() < deadline) { + try { const result = await action(); if (result) return result; } catch (error) { last = error; } + await pause(150); + } + throw new Error(label + ' timed out' + (last ? ': ' + last.message : '')); +} +async function freePort() { + const server = net.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} +async function connect(port) { + const target = await until(async () => { + const response = await fetch('http://127.0.0.1:' + port + '/json/list', {signal: AbortSignal.timeout(1000)}); + return (await response.json()).find(item => item.title === 'CodexFold'); + }, 'WebView2 page'); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + let serial = 0; + const pending = new Map(), exceptions = []; + socket.onmessage = event => { + const result = JSON.parse(event.data); + if (result.method === 'Runtime.exceptionThrown') exceptions.push(result.params.exceptionDetails); + const request = pending.get(result.id); + if (!request) return; + pending.delete(result.id); clearTimeout(request.timer); + result.error ? request.reject(new Error(JSON.stringify(result.error))) : request.resolve(result.result); + }; + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timed out')); }, 8000); + pending.set(id, {resolve, reject, timer}); socket.send(JSON.stringify({id, method, params})); + }); + const evaluate = async expression => { + const result = await call('Runtime.evaluate', {expression, returnByValue: true, awaitPromise: true}); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await call('Runtime.enable'); + return {call, evaluate, exceptions, close: () => socket.close()}; +} +async function main() { + assert.equal(process.platform, 'win32', 'This test requires Windows and WebView2 Runtime'); + assert.ok(process.argv[2], 'Provide the built tray executable'); + const executable = path.resolve(process.argv[2]); + const screenshots = process.argv[3] && path.resolve(process.argv[3]); + const temporaryRoot = path.resolve(process.env.CODEXFOLD_TEST_TEMP || path.join(process.cwd(), '.tmp')); + await fs.mkdir(temporaryRoot, {recursive:true}); + const root = await fs.mkdtemp(path.join(temporaryRoot, 'codexfold-tray-smoke-')); + const port = await freePort(); + const liveStore = process.argv[4] && path.resolve(process.argv[4]); + const store = liveStore || path.join(root, 'store'); + if (!liveStore) { + await fs.mkdir(path.join(store,'enrollment'), {recursive:true}); + await fs.writeFile(path.join(store,'enrollment/ui-history-v1.json'), JSON.stringify({version:1,samples:[{t:Date.now()-20*86400000,l:10000000,p:6000000,health:'运行正常'}],incidents:[]})); + } + const env = {...process.env, WEBVIEW2_USER_DATA_FOLDER: path.join(root, 'webview'), WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: '--remote-debugging-port=' + port}; + // windowsHide supplies STARTF_USESHOWWINDOW/SW_HIDE, the failing launcher condition. + const child = spawn(executable, ['--store', store], {env, windowsHide: true, stdio: 'ignore'}); + let client; + try { + child.on('error', error => { console.error(error); }); + client = await connect(port); + if (liveStore) { + await until(() => client.evaluate("document.visibilityState === 'visible' && document.getElementById('health')?.textContent === '运行正常'"), 'Live filesystem dashboard'); + const status = await client.evaluate("({health:document.getElementById('health').textContent,sessions:document.getElementById('session-count').textContent,logical:document.getElementById('logical').textContent,physical:document.getElementById('physical').textContent,daemon:document.getElementById('daemon-state').textContent,managed:document.getElementById('managed-state').textContent,enrollment:document.getElementById('fold-status').textContent,overflow:document.documentElement.scrollWidth > innerWidth})"); + assert.equal(status.daemon, '运行正常'); + assert.equal(status.managed, '运行正常'); + assert.match(status.sessions, /托管会话:\d+/); + assert.notEqual(status.logical, '—'); + assert.notEqual(status.physical, '—'); + assert.equal(status.overflow, false); + if (process.env.CODEXFOLD_EXPECT_ACCOUNTING === '1') { + const end = Date.now()+60000; + while (await client.evaluate("document.getElementById('saved').textContent === '—'") && Date.now() child.exitCode !== null, 'Live tray exit'); + assert.equal(child.exitCode, 0); + return; + } + await until(() => client.evaluate("document.readyState === 'complete' && document.visibilityState === 'visible' && document.getElementById('health')?.textContent === '未连接'"), 'First-launch visible dashboard'); + console.log('PASS: first launch renders with a hidden launcher; native status bridge responds'); + const enrollment = path.join(store, 'enrollment'); + await fs.mkdir(enrollment, {recursive:true}); + await fs.writeFile(path.join(enrollment,'worker-policy.json'),JSON.stringify({version:1,enabled:true,interval:'1m',stable_for:'1h',archived_only:false,batch_size:5})); + for (const [phase,enabled,label] of [['waiting-filesystem',true,'等待文件系统'],['stopped',true,'后台已停止'],['config-invalid',false,'设置有误'],['disabled',false,'已关闭']]) { + await fs.writeFile(path.join(enrollment,'worker-status.json'),JSON.stringify({version:1,store_path:store,enabled,phase,interval:'1m',stable_for:'1h',archived_only:false,managed_count:0,waiting_count:0,waiting_known:false,cycle_total:0,cycle_done:0,updated_at:new Date().toISOString()})); + await client.evaluate("window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('fold-status').textContent === "+JSON.stringify(label)),label); + } + // Keep the longer waiting label visible in all existing size checks. + await fs.writeFile(path.join(enrollment,'worker-status.json'),JSON.stringify({version:1,store_path:store,enabled:true,phase:'waiting-filesystem',managed_count:0,waiting_count:0,waiting_known:false,cycle_total:0,cycle_done:0,updated_at:new Date().toISOString()})); + await client.evaluate("window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('fold-status').textContent === '等待文件系统'"),'Waiting label'); + console.log('PASS: persistent worker waiting, stopped, invalid and user-paused states render without a mount'); + assert.ok(await client.evaluate('samples.some(item=>Date.now()-item.t>7*86400000)'), 'Persisted history must survive loading'); + assert.equal(await client.evaluate("getComputedStyle(document.getElementById('policy-enabled')).width"), '64px'); + await until(()=>client.evaluate("getComputedStyle(document.querySelector('.glass')).backdropFilter.includes('url(')"), 'First visible glass lens'); + await client.evaluate("document.getElementById('policy-batch').value='25';document.getElementById('policy-batch').dispatchEvent(new Event('change',{bubbles:true}))"); + await until(()=>client.evaluate('!pendingPolicy'), 'Native policy save'); + let savedPolicy=JSON.parse(await fs.readFile(path.join(enrollment,'worker-policy.json'),'utf8')); + assert.equal(savedPolicy.batch_size,25);assert.equal(savedPolicy.interval,'1m0s');assert.equal(savedPolicy.stable_for,'1h0m0s'); + await client.evaluate("document.getElementById('policy-enabled').click()"); + await until(()=>client.evaluate('!pendingPolicy && !document.getElementById("policy-enabled").checked'), 'Pause setting'); + savedPolicy=JSON.parse(await fs.readFile(path.join(enrollment,'worker-policy.json'),'utf8'));assert.equal(savedPolicy.enabled,false); + console.log('PASS: policy edits preserve other fields; pause persists; 30-day history loads; glass lens and switch proportions render'); + // Only this isolated test page uses metric fixtures. Shipping status still + // arrives from native read-only accounting; no production files are changed. + const metrics = {LogicalBytes:916715330,PhysicalBytes:1698632426,CompressionLogicalBytes:916715330,CompressedBytes:604913040,PendingBytes:1087218322}; + await client.evaluate(`(() => { + const nativeUpdate = window.updateStatus; + window.metricFixture = ${JSON.stringify(metrics)}; + window.nativeUpdates = 0; + window.updateStatus = view => { window.nativeUpdates++; nativeUpdate({...view,...window.metricFixture}); }; + })()`); + await client.evaluate("window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('saved').textContent === '311.8 MB'"),'Compression saving'); + let metricState = await client.evaluate("({saved:document.getElementById('saved').textContent,pending:document.getElementById('pending').textContent,label:document.getElementById('net-label').textContent,net:document.getElementById('net-saved').textContent})"); + assert.deepEqual(metricState,{saved:'311.8 MB',pending:'1.1 GB',label:'暂多占',net:'781.9 MB'}); + await client.evaluate("window.metricFixture.PhysicalBytes = 650000000; window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('net-label').textContent === '当前净节省'"),'Positive net saving'); + assert.equal(await client.evaluate("document.getElementById('saved').textContent"),'311.8 MB'); + await client.evaluate("window.metricFixture.PhysicalBytes = 1698632426; window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('net-label').textContent === '暂多占'"),'Negative net saving'); + console.log('PASS: compression stays visible while temporary storage changes net saving'); + for (const [width, height] of [[1060, 730], [540, 420], [390, 730]]) { + await client.call('Emulation.setDeviceMetricsOverride', {width, height, deviceScaleFactor: 1, mobile: false}); + const layout = await client.evaluate(`({width:innerWidth,scroll:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('.metrics')).gridTemplateColumns.split(' ').length,heading:document.querySelector('h1').textContent})`); + assert.ok(layout.scroll <= layout.width, 'Horizontal overflow at ' + width); + assert.equal(layout.columns, width > 720 ? 4 : 2); + assert.equal(layout.heading, 'CodexFold'); + const scroll = await client.evaluate(`(() => { + const content = document.getElementById('content-scroll'); + const headerTop = document.querySelector('.toolbar').getBoundingClientRect().top; + content.scrollTo(0, content.scrollHeight); + const result = {top:content.scrollTop,root:document.scrollingElement.scrollTop,headerTop,headerAfter:document.querySelector('.toolbar').getBoundingClientRect().top,scrollbar:getComputedStyle(content,'::-webkit-scrollbar').width,overflow:content.scrollWidth > content.clientWidth}; + content.scrollTo(0,0); return result; + })()`); + assert.ok(scroll.top > 0, 'Content must remain scrollable'); + assert.equal(scroll.root, 0, 'Window itself must not scroll'); + assert.equal(scroll.headerAfter, scroll.headerTop, 'Toolbar moved with content'); + assert.equal(scroll.scrollbar, '10px'); + assert.equal(scroll.overflow, false, 'Horizontal content overflow'); + if (screenshots && width !== 540) { + await fs.mkdir(screenshots, {recursive: true}); + const capture = await client.call('Page.captureScreenshot', {format: 'png', captureBeyondViewport: false}); + await fs.writeFile(path.join(screenshots, 'dashboard-' + width + '.png'), Buffer.from(capture.data, 'base64')); + } + } + await client.call('Emulation.clearDeviceMetricsOverride'); + const wheelPoint = await client.evaluate("({x:innerWidth/2,y:document.querySelector('.toolbar').getBoundingClientRect().bottom+80})"); + await client.call('Input.dispatchMouseEvent', {type:'mouseWheel', ...wheelPoint, deltaX:0, deltaY:360}); + await until(() => client.evaluate("document.getElementById('content-scroll').scrollTop > 0"), 'Mouse wheel scrolling'); + await client.evaluate("document.getElementById('content-scroll').scrollTo(0,0); document.getElementById('content-scroll').focus()"); + await client.call('Input.dispatchKeyEvent', {type:'rawKeyDown', key:'PageDown', code:'PageDown', windowsVirtualKeyCode:34}); + await client.call('Input.dispatchKeyEvent', {type:'keyUp', key:'PageDown', code:'PageDown', windowsVirtualKeyCode:34}); + await until(() => client.evaluate("document.getElementById('content-scroll').scrollTop > 0"), 'Keyboard scrolling'); + await client.evaluate("document.getElementById('content-scroll').scrollTo(0,0)"); + console.log('PASS: actual mouse wheel and PageDown scroll the content region'); + assert.equal(await client.evaluate("document.querySelector('[data-hours=\"168\"]').click(); document.getElementById('range-start').textContent"), '7 天前'); + assert.equal(await client.evaluate("document.getElementById('show-diagnostics').click(); document.getElementById('diagnostics').open"), true); + assert.equal(await client.evaluate("document.getElementById('close-diagnostics').click(); document.getElementById('diagnostics').open"), false); + assert.equal(await client.evaluate("document.getElementById('more').click(); document.getElementById('menu').hidden"), false); + assert.equal(await client.evaluate("document.querySelector('main').click(); document.getElementById('menu').hidden"), true); + console.log('PASS: desktop/narrow/short layout, fixed toolbar, slim content scrollbar, time range, diagnostics and menu'); + const before = await client.evaluate('window.nativeUpdates'); + await client.evaluate("document.querySelector('[data-command=\"refresh\"]').click()"); + await until(async () => await client.evaluate('window.nativeUpdates') > before, 'Native refresh'); + assert.deepEqual(client.exceptions, []); + console.log('PASS: refresh reaches native host; no JavaScript exceptions'); + await client.evaluate("window.chrome.webview.postMessage('exit')"); + await until(() => child.exitCode !== null, 'Native exit'); + assert.equal(child.exitCode, 0); + client.close(); client = null; + console.log('PASS: exit button shuts down the tray process'); + + // Also check a background start, followed by exactly one existing-instance open. + const background = spawn(executable, ['--background', '--store', store], {env, windowsHide: true, stdio: 'ignore'}); + try { + client = await connect(port); + assert.equal(await client.evaluate('document.visibilityState'), 'hidden'); + const opener = spawn(executable, ['--store', store], {env, windowsHide: true, stdio: 'ignore'}); + await until(() => opener.exitCode !== null, 'Existing-instance opener'); + assert.equal(opener.exitCode, 0); + await until(() => client.evaluate("document.visibilityState === 'visible'"), 'Background window restore'); + const quitter = spawn(executable, ['--quit', '--store', store], {env, windowsHide: true, stdio: 'ignore'}); + await until(() => quitter.exitCode !== null, 'Tray-only quit command'); + assert.equal(quitter.exitCode, 0); + await until(() => background.exitCode !== null, 'Background process exit'); + assert.equal(background.exitCode, 0); + console.log('PASS: background start stays hidden; one open restores the dashboard'); + } finally { if (background.exitCode === null) background.kill(); } + } finally { + client?.close(); + if (child.exitCode === null) child.kill(); + // Remove only the exact temporary directory created by this test. + assert.equal(path.dirname(path.resolve(root)), temporaryRoot); + assert.ok(path.basename(root).startsWith('codexfold-tray-smoke-')); + await fs.rm(root, {recursive: true, force: true, maxRetries: 20, retryDelay: 250}); + } +} +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/internal/tray/window_windows.go b/internal/tray/window_windows.go new file mode 100644 index 0000000..c3426ad --- /dev/null +++ b/internal/tray/window_windows.go @@ -0,0 +1,642 @@ +//go:build windows + +package tray + +import ( + "crypto/sha256" + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/jchv/go-webview2/pkg/edge" + "golang.org/x/sys/windows" +) + +//go:embed dashboard.html +var dashboardHTML string + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + shell32 = windows.NewLazySystemDLL("shell32.dll") + registerClass = user32.NewProc("RegisterClassExW") + createWindow = user32.NewProc("CreateWindowExW") + defWindowProc = user32.NewProc("DefWindowProcW") + showWindow = user32.NewProc("ShowWindow") + setForeground = user32.NewProc("SetForegroundWindow") + postMessage = user32.NewProc("PostMessageW") + notifyIcon = shell32.NewProc("Shell_NotifyIconW") + moveMemory = windows.NewLazySystemDLL("kernel32.dll").NewProc("RtlMoveMemory") +) + +const ( + wmTray = 0x8001 + wmShow = 0x8002 + wmRefresh = 0x8003 + idOpen = 100 + idStore = 101 + idLogs = 102 + idRefresh = 103 + idExit = 104 + idWorkerLogs = 105 + idCopyDiagnostics = 106 + idExportDiagnostics = 107 + idWindowMinimize = 108 + idWindowMaximize = 109 + idWindowHide = 110 + idWindowDrag = 111 + // rsrc emits the manifest as 1, then each group followed by its nine images. + appIconResource = 2 + attentionIconResource = 12 +) + +type point struct{ X, Y int32 } +type rect struct{ Left, Top, Right, Bottom int32 } +type message struct { + Window uintptr + ID uint32 + WParam, LParam uintptr + Time uint32 + Point point + Private uint32 +} +type windowClass struct { + Size, Style uint32 + Procedure uintptr + ClassExtra, WindowExtra int32 + Instance, Icon, Cursor, Background uintptr + MenuName, ClassName *uint16 + SmallIcon uintptr +} +type iconData struct { + Size uint32 + Window uintptr + ID, Flags, Callback uint32 + Icon uintptr + Tip [128]uint16 + State, StateMask uint32 + Info [256]uint16 + Timeout uint32 + InfoTitle [64]uint16 + InfoFlags uint32 + GUID windows.GUID + BalloonIcon uintptr +} + +type application struct { + window uintptr + monitor *Monitor + browser *edge.Chromium + icon iconData + largeIcon, smallIcon, warningIcon uintptr + instance windows.Handle + taskbarMessage uint32 + scale float64 + view View + results chan View + refresh chan struct{} + stop chan struct{} + pollDone chan struct{} + actions chan PolicyChange + notifiedIncidents map[string]bool +} + +func utf16(value string) *uint16 { + p, _ := windows.UTF16PtrFromString(strings.ReplaceAll(value, "\x00", "")) + return p +} +func pointer(value *uint16) uintptr { return uintptr(unsafe.Pointer(value)) } +func (a *application) px(value int) uintptr { return uintptr(int(float64(value) * a.scale)) } + +func ShowError(err error) { + user32.NewProc("MessageBoxW").Call(0, pointer(utf16(err.Error())), pointer(utf16("CodexFold")), 0x10) +} + +func RequestExit(store string) error { + key := fmt.Sprintf("CodexFold.Tray.%x", sha256.Sum256([]byte(strings.ToLower(filepath.Clean(store))))) + window, _, _ := user32.NewProc("FindWindowW").Call(pointer(utf16(key)), 0) + if window == 0 { + return nil + } + if sent, _, err := postMessage.Call(window, 0x111, idExit, 0); sent == 0 { + return fmt.Errorf("request tray exit: %w", err) + } + return nil +} + +func Run(store string, background bool) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + // The executable manifest sets PerMonitorV2 before any COM windows exist. + // Keep a fallback for hosts that call this package without that manifest. + if proc := user32.NewProc("SetProcessDpiAwarenessContext"); proc.Find() == nil { + proc.Call(^uintptr(3)) // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4) + } else { + user32.NewProc("SetProcessDPIAware").Call() + } + key := fmt.Sprintf("CodexFold.Tray.%x", sha256.Sum256([]byte(strings.ToLower(filepath.Clean(store))))) + mutex, err := windows.CreateMutex(nil, false, utf16("Local\\"+key)) + if mutex != 0 { + defer windows.CloseHandle(mutex) + } + if err == windows.ERROR_ALREADY_EXISTS { + window, _, _ := user32.NewProc("FindWindowW").Call(pointer(utf16(key)), 0) + if window != 0 { + postMessage.Call(window, wmShow, 0, 0) + } + return nil + } + if err != nil { + return err + } + a := &application{monitor: NewMonitor(store), scale: 1, results: make(chan View, 1), refresh: make(chan struct{}, 1), stop: make(chan struct{}), pollDone: make(chan struct{}), actions: make(chan PolicyChange, 1), notifiedIncidents: make(map[string]bool)} + if proc := user32.NewProc("GetDpiForSystem"); proc.Find() == nil { + if dpi, _, _ := proc.Call(); dpi != 0 { + a.scale = float64(dpi) / 96 + } + } + cursor, _, _ := user32.NewProc("LoadCursorW").Call(0, 32512) + var instance windows.Handle + err = windows.GetModuleHandleEx(0, nil, &instance) + if err != nil { + return err + } + a.instance = instance + if err := a.loadIcons(); err != nil { + return err + } + class := windowClass{Procedure: syscall.NewCallback(a.procedure), Instance: uintptr(instance), Icon: a.largeIcon, Cursor: cursor, Background: 16, ClassName: utf16(key), SmallIcon: a.smallIcon} + class.Size = uint32(unsafe.Sizeof(class)) + if value, _, err := registerClass.Call(uintptr(unsafe.Pointer(&class))); value == 0 { + return fmt.Errorf("register status window: %w", err) + } + // Clip child surfaces so repainting the native frame cannot cover WebView2. + bounds := fitWindow(primaryWorkArea(), a.scale) + window, _, err := createWindow.Call(0, pointer(class.ClassName), pointer(utf16("CodexFold")), 0x02cf0000, uintptr(bounds.Left), uintptr(bounds.Top), uintptr(bounds.Right-bounds.Left), uintptr(bounds.Bottom-bounds.Top), 0, 0, uintptr(instance), 0) + if window == 0 { + return fmt.Errorf("create status window: %w", err) + } + a.window = window + defer user32.NewProc("DestroyWindow").Call(window) + a.browser = edge.NewChromium() + cache, err := os.UserCacheDir() + if err != nil { + return err + } + a.browser.DataPath = filepath.Join(cache, "CodexFold", "WebView2") + a.browser.SetGlobalPermission(edge.CoreWebView2PermissionStateDeny) + a.browser.MessageCallback = func(action string) { + // Only fixed actions from the embedded page cross the native boundary. + // No path, command line, or arbitrary code is accepted from JavaScript. + switch action { + case "ready": + a.update(a.view) + case "store": + postMessage.Call(window, 0x111, idStore, 0) + case "logs": + postMessage.Call(window, 0x111, idLogs, 0) + case "refresh": + postMessage.Call(window, 0x111, idRefresh, 0) + case "logs-worker": + postMessage.Call(window, 0x111, idWorkerLogs, 0) + case "copy-diagnostics": + postMessage.Call(window, 0x111, idCopyDiagnostics, 0) + case "export-diagnostics": + postMessage.Call(window, 0x111, idExportDiagnostics, 0) + case "exit": + postMessage.Call(window, 0x111, idExit, 0) + case "window-minimize": + postMessage.Call(window, 0x111, idWindowMinimize, 0) + case "window-maximize": + postMessage.Call(window, 0x111, idWindowMaximize, 0) + case "window-hide": + postMessage.Call(window, 0x111, idWindowHide, 0) + case "window-drag": + postMessage.Call(window, 0x111, idWindowDrag, 0) + default: + if strings.HasPrefix(action, "{") { + request, err := decodePolicyChange(action) + if err != nil { + a.reply(ActionResult{ID: request.ID, Message: err.Error()}) + return + } + select { + case a.actions <- request: + default: + a.reply(ActionResult{ID: request.ID, Message: "正在保存上一项设置,请稍后重试。"}) + } + } + } + } + if !a.browser.Embed(window) { + return fmt.Errorf("无法初始化 Microsoft Edge WebView2 Runtime,请安装后重新打开") + } + settings, err := a.browser.GetSettings() + if err != nil { + return err + } + if err := settings.PutAreDefaultContextMenusEnabled(false); err != nil { + return err + } + if err := settings.PutAreDevToolsEnabled(false); err != nil { + return err + } + a.browser.NavigateToString(dashboardHTML) + // Use the Windows 11 rounded frame when available; older Windows ignores it. + dwm := windows.NewLazySystemDLL("dwmapi.dll").NewProc("DwmSetWindowAttribute") + corner, border := uint32(2), uint32(0xfffffffe) + dwm.Call(window, 33, uintptr(unsafe.Pointer(&corner)), 4) + dwm.Call(window, 34, uintptr(unsafe.Pointer(&border)), 4) + // Recalculate the client area after the custom glass caption is ready. + user32.NewProc("SetWindowPos").Call(window, 0, 0, 0, 0, 0, 0x37) // FRAMECHANGED | NOMOVE | NOSIZE | NOZORDER | NOACTIVATE + registered, _, _ := user32.NewProc("RegisterWindowMessageW").Call(pointer(utf16("TaskbarCreated"))) + a.taskbarMessage = uint32(registered) + a.icon = iconData{Window: window, ID: 1, Flags: 7, Callback: wmTray, Icon: a.smallIcon} + a.icon.Size = uint32(unsafe.Sizeof(a.icon)) + a.setTip("CodexFold · 正在读取状态") + if result, _, err := notifyIcon.Call(0, uintptr(unsafe.Pointer(&a.icon))); result == 0 { + return fmt.Errorf("add notification icon: %w", err) + } + defer notifyIcon.Call(2, uintptr(unsafe.Pointer(&a.icon))) + a.layout() + a.update(View{Health: "正在读取状态", Storage: "空间统计:暂无数据", Activity: "读取 — 写入 —", Sessions: "托管会话:—", Detail: "正在读取本地后台状态…"}) + go a.poll() + defer func() { + close(a.stop) + select { + case <-a.pollDone: + case <-time.After(3 * time.Second): + } + }() + if !background { + a.show() + } + var msg message + for { + result, _, err := user32.NewProc("GetMessageW").Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + if int32(result) == -1 { + return err + } + if result == 0 { + return nil + } + if handled, _, _ := user32.NewProc("IsDialogMessageW").Call(window, uintptr(unsafe.Pointer(&msg))); handled != 0 { + continue + } + user32.NewProc("TranslateMessage").Call(uintptr(unsafe.Pointer(&msg))) + user32.NewProc("DispatchMessageW").Call(uintptr(unsafe.Pointer(&msg))) + } +} + +func (a *application) poll() { + defer close(a.pollDone) + defer a.monitor.FlushHistory() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + var operation *ActionResult + for { + view := a.monitor.Refresh(time.Now()) + view.Operation = operation + select { + case a.results <- view: + default: + select { + case <-a.results: + default: + } + select { + case a.results <- view: + default: + } + } + postMessage.Call(a.window, wmRefresh, 0, 0) + select { + case <-a.stop: + return + case <-ticker.C: + case <-a.refresh: + case request := <-a.actions: + result := a.monitor.ApplyPolicy(request) + operation = &result + } + } +} + +func (a *application) update(view View) { + a.view = view + if a.browser != nil { + payload, err := json.Marshal(view) + if err == nil { + a.browser.Eval("window.updateStatus && window.updateStatus(" + string(payload) + ")") + } + } + a.icon.Icon = a.smallIcon + if view.Health == "需要关注" { + a.icon.Icon = a.warningIcon + } + a.setTip("CodexFold · " + view.Health + "\n" + view.Activity) + notifyIcon.Call(1, uintptr(unsafe.Pointer(&a.icon))) + for _, incident := range view.Incidents { + if incident.RecoveredAt == nil && !a.notifiedIncidents[incident.ID] { + a.notifiedIncidents[incident.ID] = true + a.icon.Flags = 0x10 // NIF_INFO: request one native notification per incident. + a.icon.InfoTitle, a.icon.Info = [64]uint16{}, [256]uint16{} + copy(a.icon.InfoTitle[:], windows.StringToUTF16("CodexFold · 需要关注")) + copy(a.icon.Info[:], windows.StringToUTF16(incident.Reason+"。打开状态窗口查看详情。")) + a.icon.InfoFlags = 2 + notifyIcon.Call(1, uintptr(unsafe.Pointer(&a.icon))) + a.icon.Flags = 7 + break + } + } +} + +func (a *application) setTip(value string) { + a.icon.Tip = [128]uint16{} + encoded, _ := windows.UTF16FromString(value) + if len(encoded) > 127 { + encoded = encoded[:127] + if encoded[126] >= 0xd800 && encoded[126] <= 0xdbff { + encoded = encoded[:126] + } + } + copy(a.icon.Tip[:], encoded) +} + +func (a *application) layout() { + if a.browser != nil && a.browser.GetController() != nil { + a.browser.Resize() + } +} + +func primaryWorkArea() rect { + var work rect + if ok, _, _ := user32.NewProc("SystemParametersInfoW").Call(0x30, 0, uintptr(unsafe.Pointer(&work)), 0); ok != 0 { + return work + } + width, _, _ := user32.NewProc("GetSystemMetrics").Call(0) + height, _, _ := user32.NewProc("GetSystemMetrics").Call(1) + return rect{Right: int32(width), Bottom: int32(height)} +} + +func windowWorkArea(window uintptr) rect { + var info struct { + Size uint32 + Monitor, Work rect + Flags uint32 + } + info.Size = uint32(unsafe.Sizeof(info)) + monitor, _, _ := user32.NewProc("MonitorFromWindow").Call(window, 2) + if ok, _, _ := user32.NewProc("GetMonitorInfoW").Call(monitor, uintptr(unsafe.Pointer(&info))); ok != 0 { + return info.Work + } + return primaryWorkArea() +} + +func fitWindow(work rect, scale float64) rect { + margin := int32(16 * scale) + width := min(int32(1000*scale), max(1, work.Right-work.Left-2*margin)) + height := min(int32(780*scale), max(1, work.Bottom-work.Top-2*margin)) + left := work.Left + (work.Right-work.Left-width)/2 + top := work.Top + (work.Bottom-work.Top-height)/2 + return rect{left, top, left + width, top + height} +} + +func frameResizeHit(bounds rect, cursor point, edge int32) uintptr { + left, right := cursor.X < bounds.Left+edge, cursor.X >= bounds.Right-edge + top, bottom := cursor.Y < bounds.Top+edge, cursor.Y >= bounds.Bottom-edge + switch { + case top && left: + return 13 + case top && right: + return 14 + case bottom && left: + return 16 + case bottom && right: + return 17 + case left: + return 10 + case right: + return 11 + case top: + return 12 + case bottom: + return 15 + default: + return 0 + } +} + +func (a *application) loadIcons() error { + load := func(resource uintptr, size int) (uintptr, error) { + icon, _, err := user32.NewProc("LoadImageW").Call(uintptr(a.instance), resource, 1, a.px(size), a.px(size), 0x8000) // IMAGE_ICON, LR_SHARED + if icon == 0 { + return 0, fmt.Errorf("load application icon %d: %w", resource, err) + } + return icon, nil + } + var err error + if a.largeIcon, err = load(appIconResource, 32); err != nil { + return err + } + if a.smallIcon, err = load(appIconResource, 16); err != nil { + return err + } + a.warningIcon, err = load(attentionIconResource, 16) + return err +} + +func (a *application) show() { + showWindow.Call(a.window, 9) // restore a minimized window + // STARTF_USESHOWWINDOW can override the first ShowWindow call with SW_HIDE. + // SetWindowPos applies our explicit open request even for a hidden launcher. + user32.NewProc("SetWindowPos").Call(a.window, 0, 0, 0, 0, 0, 0x47) // NOSIZE | NOMOVE | NOZORDER | SHOWWINDOW + a.syncBrowserVisibility(true) + a.layout() + if a.browser != nil { + _ = a.browser.NotifyParentWindowPositionChanged() + a.browser.Focus() + } + user32.NewProc("UpdateWindow").Call(a.window) + setForeground.Call(a.window) +} + +func (a *application) syncBrowserVisibility(visible bool) { + if a.browser == nil || a.browser.GetController() == nil { + return + } + if visible { + _ = a.browser.Show() + } else { + _ = a.browser.Hide() + } +} + +func (a *application) openFolder(path string) { + if info, err := os.Stat(path); err != nil || !info.IsDir() { + user32.NewProc("MessageBoxW").Call(a.window, pointer(utf16("该目录尚未创建:\n"+path)), pointer(utf16("CodexFold")), 0x40) + return + } + result, _, _ := shell32.NewProc("ShellExecuteW").Call(a.window, pointer(utf16("open")), pointer(utf16(path)), 0, 0, 1) + if result <= 32 { + ShowError(fmt.Errorf("无法打开目录 (%d): %s", result, path)) + } +} + +func (a *application) command(id uintptr) { + switch id { + case idOpen: + a.show() + case idStore: + a.openFolder(a.monitor.Store) + case idLogs: + path := a.view.LogDirectories["filesystem"] + if path == "" { + path = filepath.Join(a.monitor.Store, "fs") + } + a.openFolder(path) + case idWorkerLogs: + a.openFolder(filepath.Join(a.monitor.Store, "enrollment")) + case idCopyDiagnostics: + a.copyDiagnostics() + case idExportDiagnostics: + a.exportDiagnostics() + case idRefresh: + a.monitor.RequestAccountingRefresh() + select { + case a.refresh <- struct{}{}: + default: + } + case idExit: + user32.NewProc("PostQuitMessage").Call(0) + case idWindowMinimize: + showWindow.Call(a.window, 6) + case idWindowMaximize: + if maximized, _, _ := user32.NewProc("IsZoomed").Call(a.window); maximized != 0 { + showWindow.Call(a.window, 9) + } else { + showWindow.Call(a.window, 3) + } + case idWindowHide: + postMessage.Call(a.window, 0x10, 0, 0) + case idWindowDrag: + user32.NewProc("ReleaseCapture").Call() + postMessage.Call(a.window, 0xa1, 2, 0) // WM_NCLBUTTONDOWN / HTCAPTION + } +} + +func (a *application) popup() { + a.show() + if a.browser != nil { + a.browser.Eval("window.openGlassMenu && window.openGlassMenu()") + } +} + +func (a *application) procedure(window uintptr, msg uint32, wparam, lparam uintptr) uintptr { + if a.taskbarMessage != 0 && msg == a.taskbarMessage { + notifyIcon.Call(0, uintptr(unsafe.Pointer(&a.icon))) + return 0 + } + switch msg { + case 0x83: // WM_NCCALCSIZE: use the web caption while retaining the native sizing frame. + if wparam != 0 { + var bounds rect + // LPARAM belongs to Windows. Copy through the native API rather + // than retaining an unmanaged address as a Go pointer. + moveMemory.Call(uintptr(unsafe.Pointer(&bounds)), lparam, unsafe.Sizeof(bounds)) + if maximized, _, _ := user32.NewProc("IsZoomed").Call(window); maximized != 0 { + bounds = windowWorkArea(window) + } else { + // Keep a native sizing rim outside the WebView child so the + // child cannot intercept pointer hit tests at the window edges. + rim := int32(a.px(6)) + bounds.Left += rim + bounds.Top += rim + bounds.Right -= rim + bounds.Bottom -= rim + } + moveMemory.Call(lparam, uintptr(unsafe.Pointer(&bounds)), unsafe.Sizeof(bounds)) + return 0 + } + case 0x84: // WM_NCHITTEST: signed coordinates also work on left-hand monitors. + if maximized, _, _ := user32.NewProc("IsZoomed").Call(window); maximized == 0 { + var bounds rect + user32.NewProc("GetWindowRect").Call(window, uintptr(unsafe.Pointer(&bounds))) + cursor := point{int32(int16(lparam & 0xffff)), int32(int16((lparam >> 16) & 0xffff))} + if hit := frameResizeHit(bounds, cursor, int32(a.px(6))); hit != 0 { + return hit + } + } + return 1 // HTCLIENT: the caption drag regions are sent explicitly by the page. + case wmShow: + a.show() + return 0 + case wmRefresh: + select { + case view := <-a.results: + a.update(view) + default: + } + return 0 + case wmTray: + switch uint32(lparam) { + case 0x202, 0x203: + a.show() + case 0x205: + a.popup() + } + return 0 + case 0x111: + a.command(wparam & 0xffff) + return 0 + case 0x10: + showWindow.Call(window, 0) + return 0 + case 0x18: // WM_SHOWWINDOW: keep the controller in sync with the tray window + a.syncBrowserVisibility(wparam != 0) + return 0 + case 0x5: + visible, _, _ := user32.NewProc("IsWindowVisible").Call(window) + a.syncBrowserVisibility(wparam != 1 && visible != 0) // SIZE_MINIMIZED + a.layout() + return 0 + case 0x3, 0x216: // WM_MOVE, WM_MOVING + if a.browser != nil { + _ = a.browser.NotifyParentWindowPositionChanged() + } + case 0x2e0: // WM_DPICHANGED + a.scale = float64(wparam&0xffff) / 96 + if a.instance != 0 { + if err := a.loadIcons(); err == nil { + user32.NewProc("SendMessageW").Call(window, 0x80, 1, a.largeIcon) + user32.NewProc("SendMessageW").Call(window, 0x80, 0, a.smallIcon) + if a.icon.Size != 0 { + a.update(a.view) + } + } + } + var bounds rect + moveMemory.Call(uintptr(unsafe.Pointer(&bounds)), lparam, unsafe.Sizeof(bounds)) + user32.NewProc("SetWindowPos").Call(window, 0, uintptr(bounds.Left), uintptr(bounds.Top), uintptr(bounds.Right-bounds.Left), uintptr(bounds.Bottom-bounds.Top), 0x14) // NOZORDER | NOACTIVATE + a.layout() + return 0 + case 0x24: // minimum size, in physical pixels + var limits [5]point + moveMemory.Call(uintptr(unsafe.Pointer(&limits)), lparam, unsafe.Sizeof(limits)) + work := windowWorkArea(window) + limits[3] = point{min(int32(a.px(540)), work.Right-work.Left), min(int32(a.px(420)), work.Bottom-work.Top)} + moveMemory.Call(lparam, uintptr(unsafe.Pointer(&limits)), unsafe.Sizeof(limits)) + return 0 + case 0x2: + user32.NewProc("PostQuitMessage").Call(0) + return 0 + } + result, _, _ := defWindowProc.Call(window, uintptr(msg), wparam, lparam) + return result +} diff --git a/internal/tray/window_windows_test.go b/internal/tray/window_windows_test.go new file mode 100644 index 0000000..0142d0d --- /dev/null +++ b/internal/tray/window_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows + +package tray + +import "testing" + +func TestInitialWindowFitsWorkArea(t *testing.T) { + for _, work := range []rect{{0, 0, 1920, 1040}, {0, 0, 1366, 728}, {-1280, 0, 0, 984}} { + for _, scale := range []float64{1, 1.25, 1.5, 2, 3} { + bounds := fitWindow(work, scale) + if bounds.Left < work.Left || bounds.Top < work.Top || bounds.Right > work.Right || bounds.Bottom > work.Bottom || bounds.Left >= bounds.Right || bounds.Top >= bounds.Bottom { + t.Fatalf("work=%+v scale=%v: window %+v does not fit", work, scale, bounds) + } + } + } +} + +func TestGlassFrameResizeOnNegativeMonitorCoordinates(t *testing.T) { + bounds := rect{-1280, 100, -280, 880} + for _, test := range []struct { + cursor point + want uintptr + }{ + {point{-1279, 101}, 13}, {point{-281, 101}, 14}, {point{-1279, 879}, 16}, {point{-281, 879}, 17}, + {point{-1279, 400}, 10}, {point{-281, 400}, 11}, {point{-800, 101}, 12}, {point{-800, 879}, 15}, {point{-800, 400}, 0}, + } { + if got := frameResizeHit(bounds, test.cursor, 6); got != test.want { + t.Fatalf("cursor=%+v got=%d want=%d", test.cursor, got, test.want) + } + } +} diff --git a/internal/vfs/initial_publish.go b/internal/vfs/initial_publish.go index d7af9fa..2e9ffcc 100644 --- a/internal/vfs/initial_publish.go +++ b/internal/vfs/initial_publish.go @@ -88,7 +88,7 @@ func publishInitialSession(ctx context.Context, options SessionOptions, view *Vi var reservedLease *os.File if reserveWriter { - reservedLease, err = acquireWriterLease(filepath.Join(staging, "writer.lease")) + reservedLease, err = acquireInitialWriterLease(staging, options.Manifest.Session.ID) if err != nil { return SessionState{}, nil, err } diff --git a/internal/vfs/initial_publish_windows_test.go b/internal/vfs/initial_publish_windows_test.go new file mode 100644 index 0000000..ea111c1 --- /dev/null +++ b/internal/vfs/initial_publish_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package vfs + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestInitialWriterLeaseSurvivesWindowsPublication(t *testing.T) { + if path := os.Getenv("CODEXFOLD_TEST_PUBLISHED_LEASE"); path != "" { + lease, err := acquireWriterLease(path) + if !errors.Is(err, ErrWriterBusy) { + if lease != nil { + _ = unlockWriterFile(lease) + _ = lease.Close() + } + t.Fatalf("published writer lease did not exclude child process: %v", err) + } + return + } + parent := t.TempDir() + staging := filepath.Join(parent, "staging") + if err := os.Mkdir(staging, 0o700); err != nil { + t.Fatal(err) + } + lease, err := acquireInitialWriterLease(staging, "session-1") + if err != nil { + t.Fatal(err) + } + defer lease.Close() + final := filepath.Join(parent, "session-1") + if err := os.Rename(staging, final); err != nil { + t.Fatal(err) + } + leasePath := filepath.Join(final, "writer.lease") + child := exec.Command(os.Args[0], "-test.run=^TestInitialWriterLeaseSurvivesWindowsPublication$") + child.Env = append(os.Environ(), "CODEXFOLD_TEST_PUBLISHED_LEASE="+leasePath) + if output, err := child.CombinedOutput(); err != nil { + t.Fatalf("child writer exclusion: %v\n%s", err, output) + } + if err := unlockWriterFile(lease); err != nil { + t.Fatal(err) + } + if err := lease.Close(); err != nil { + t.Fatal(err) + } + reopened, err := acquireWriterLease(leasePath) + if err != nil { + t.Fatalf("writer after release: %v", err) + } + _ = unlockWriterFile(reopened) + _ = reopened.Close() + if _, err := os.Stat(filepath.Join(parent, initialSessionLockName("session-1")+".writer")); !os.IsNotExist(err) { + t.Fatalf("temporary writer name remains: %v", err) + } +} + +func TestPublishedWriterLeaseSurvivesWindowsRetirement(t *testing.T) { + parent := t.TempDir() + directory := filepath.Join(parent, "session") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "writer.lease") + lease, err := acquireWriterLease(path) + if err != nil { + t.Fatal(err) + } + defer lease.Close() + retired := filepath.Join(parent, "retired-session") + if err := os.Rename(directory, retired); err != nil { + t.Fatalf("retire locked session: %v", err) + } + guard, acquired, err := TryAcquireWriterLeaseGuardAtPath(filepath.Join(retired, "writer.lease")) + if err != nil || acquired { + if guard != nil { + _ = guard.Close() + } + t.Fatalf("retired lease lost exclusion: acquired=%v err=%v", acquired, err) + } + if err := unlockWriterFile(lease); err != nil { + t.Fatal(err) + } + if err := lease.Close(); err != nil { + t.Fatal(err) + } + guard, acquired, err = TryAcquireWriterLeaseGuardAtPath(filepath.Join(retired, "writer.lease")) + if err != nil || !acquired { + t.Fatalf("guard after retirement: acquired=%v err=%v", acquired, err) + } + defer guard.Close() + if err := os.Rename(retired, directory); err != nil { + t.Fatalf("restore guarded session: %v", err) + } +} diff --git a/internal/vfs/native_retirement.go b/internal/vfs/native_retirement.go index 5916d62..81046ae 100644 --- a/internal/vfs/native_retirement.go +++ b/internal/vfs/native_retirement.go @@ -13,6 +13,8 @@ import ( "slices" "sort" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -421,7 +423,7 @@ func syncNativeRetirementDirectory(root *os.Root, relative string) error { if err != nil { return err } - if err := directory.Sync(); err != nil { + if err := dirsync.Sync(directory); err != nil { _ = directory.Close() return err } diff --git a/internal/vfs/session.go b/internal/vfs/session.go index 81c5489..a83c77c 100644 --- a/internal/vfs/session.go +++ b/internal/vfs/session.go @@ -346,7 +346,7 @@ func (s *Session) OpenWriter() (*WriteHandle, error) { } func acquireWriterLease(leasePath string) (*os.File, error) { - lease, err := os.OpenFile(leasePath, os.O_CREATE|os.O_RDWR, 0o600) + lease, err := openWriterLeaseFile(leasePath, true) if err != nil { return nil, fmt.Errorf("create writer lease: %w", err) } @@ -385,7 +385,7 @@ func TryAcquireWriterLeaseGuard(root string, sessionID string) (*WriterLeaseGuar return nil, false, errors.New("session root and safe session ID are required") } leasePath := filepath.Join(filepath.Clean(root), "fs", "sessions", sessionID, "writer.lease") - file, err := os.OpenFile(leasePath, os.O_CREATE|os.O_RDWR, 0o600) + file, err := openWriterLeaseFile(leasePath, true) if err != nil { return nil, false, fmt.Errorf("open writer lease guard: %w", err) } diff --git a/internal/vfs/session_deletion_lease_other.go b/internal/vfs/session_deletion_lease_other.go new file mode 100644 index 0000000..09faaf7 --- /dev/null +++ b/internal/vfs/session_deletion_lease_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package vfs + +import "os" + +func openDeletionWriterLease(store *sessionDeletionStoreRoot, relative, _ string) (*os.File, error) { + return store.root.OpenFile(relative, os.O_RDWR, 0) +} diff --git a/internal/vfs/session_deletion_lease_windows.go b/internal/vfs/session_deletion_lease_windows.go new file mode 100644 index 0000000..a9dcb9d --- /dev/null +++ b/internal/vfs/session_deletion_lease_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package vfs + +import "os" + +func openDeletionWriterLease(_ *sessionDeletionStoreRoot, _ string, path string) (*os.File, error) { + // Pin the same verified file through its store-side hard-link alias; + // a locked handle opened inside the state tree blocks NTFS directory moves. + return openWriterLeaseFile(path, false) +} diff --git a/internal/vfs/session_deletion_object_other.go b/internal/vfs/session_deletion_object_other.go index d4bfa4a..931a0b4 100644 --- a/internal/vfs/session_deletion_object_other.go +++ b/internal/vfs/session_deletion_object_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux +//go:build !darwin && !linux && !windows package vfs diff --git a/internal/vfs/session_deletion_object_windows.go b/internal/vfs/session_deletion_object_windows.go new file mode 100644 index 0000000..624c450 --- /dev/null +++ b/internal/vfs/session_deletion_object_windows.go @@ -0,0 +1,111 @@ +//go:build windows + +package vfs + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "os" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +var deletionVolumeInformation = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetVolumeInformationByHandleW") +var deletionQueryEA = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtQueryEaFile") + +func sessionDeletionObjectFromFile(file *os.File, info os.FileInfo) (SessionDeletionPurgeObject, error) { + if file == nil || info == nil { + return SessionDeletionPurgeObject{}, errors.New("missing Windows deletion object") + } + var filesystem [32]uint16 + result, _, callErr := deletionVolumeInformation.Call(file.Fd(), 0, 0, 0, 0, 0, uintptr(unsafe.Pointer(&filesystem[0])), uintptr(len(filesystem))) + if result == 0 { + return SessionDeletionPurgeObject{}, fmt.Errorf("identify deletion filesystem: %w", callErr) + } + if !strings.EqualFold(windows.UTF16ToString(filesystem[:]), "NTFS") { + return SessionDeletionPurgeObject{}, errors.New("exact Windows session deletion requires NTFS") + } + var native windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &native); err != nil { + return SessionDeletionPurgeObject{}, err + } + reference := uint64(native.FileIndexHigh)<<32 | uint64(native.FileIndexLow) + if native.VolumeSerialNumber == 0 || reference == 0 || reference>>48 == 0 || native.CreationTime.Nanoseconds() <= 0 { + return SessionDeletionPurgeObject{}, errors.New("NTFS object has no proven sequence and creation time") + } + descriptor, err := windows.GetSecurityInfo(windows.Handle(file.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return SessionDeletionPurgeObject{}, fmt.Errorf("capture deletion security descriptor: %w", err) + } + security := sha256.Sum256([]byte(descriptor.String())) + return SessionDeletionPurgeObject{IdentityProof: sessionDeletionObjectProofWindows, GenerationSource: sessionDeletionGenerationSourceWindows, BirthtimeSource: sessionDeletionBirthtimeSourceWindows, Device: uint64(native.VolumeSerialNumber), Inode: reference, Generation: reference >> 48, BirthtimeUnixNano: native.CreationTime.Nanoseconds(), SecuritySHA256: hex.EncodeToString(security[:]), Attributes: native.FileAttributes}, nil +} + +func captureSessionDeletionXattrs(file *os.File, info os.FileInfo, path string) (SessionDeletionPurgeXattrs, error) { + var native windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &native); err != nil { + return SessionDeletionPurgeXattrs{}, err + } + const ordinaryAttributes = windows.FILE_ATTRIBUTE_ARCHIVE | windows.FILE_ATTRIBUTE_DIRECTORY | windows.FILE_ATTRIBUTE_READONLY | windows.FILE_ATTRIBUTE_NORMAL | windows.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED + if native.FileAttributes&^uint32(ordinaryAttributes) != 0 { + return SessionDeletionPurgeXattrs{}, fmt.Errorf("unproved Windows attributes prevent deletion purge: %s", path) + } + if err := requireNoDeletionStreams(file); err != nil { + return SessionDeletionPurgeXattrs{}, err + } + buffer := make([]byte, 65536) + var status windows.IO_STATUS_BLOCK + result, _, _ := deletionQueryEA.Call(file.Fd(), uintptr(unsafe.Pointer(&status)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)), 0, 0, 0, 0, 1) + runtime.KeepAlive(file) + runtime.KeepAlive(buffer) + const statusNoEAsOnFile = uint32(0xc0000052) + if uint32(result) != statusNoEAsOnFile { + return SessionDeletionPurgeXattrs{}, fmt.Errorf("unproved Windows extended attributes prevent deletion purge (status=%#x): %s", uint32(result), path) + } + empty := sha256.Sum256(nil) + return SessionDeletionPurgeXattrs{SHA256: hex.EncodeToString(empty[:])}, nil +} + +func requireNoDeletionStreams(file *os.File) error { + buffer := make([]byte, 65536) + const fileStreamInfo = 7 + if err := windows.GetFileInformationByHandleEx(windows.Handle(file.Fd()), fileStreamInfo, &buffer[0], uint32(len(buffer))); err != nil { + if errors.Is(err, windows.ERROR_HANDLE_EOF) { + return nil + } + return fmt.Errorf("enumerate deletion object streams: %w", err) + } + for offset := 0; ; { + if offset < 0 || offset+24 > len(buffer) { + return errors.New("invalid deletion stream metadata") + } + next := int(binary.LittleEndian.Uint32(buffer[offset:])) + length := int(binary.LittleEndian.Uint32(buffer[offset+4:])) + if length == 0 && next == 0 { + return nil + } + if length <= 0 || length%2 != 0 || offset+24+length > len(buffer) { + return errors.New("invalid deletion stream name") + } + name := make([]uint16, length/2) + for index := range name { + name[index] = binary.LittleEndian.Uint16(buffer[offset+24+index*2:]) + } + if windows.UTF16ToString(name) != "::$DATA" { + return errors.New("alternate streams prevent exact session deletion purge") + } + if next == 0 { + return nil + } + if next < 24+length { + return errors.New("invalid deletion stream offset") + } + offset += next + } +} diff --git a/internal/vfs/session_deletion_object_windows_test.go b/internal/vfs/session_deletion_object_windows_test.go new file mode 100644 index 0000000..ad3ba49 --- /dev/null +++ b/internal/vfs/session_deletion_object_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package vfs + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestWindowsDeletionObjectRejectsAlternateStreams(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + if _, err := captureSessionDeletionXattrs(file, info, path); err != nil { + t.Fatal(err) + } + object, err := sessionDeletionObjectFromFile(file, info) + if err != nil || !validSessionDeletionPurgeObject(object) { + t.Fatalf("invalid NTFS proof: %+v, %v", object, err) + } + if err := os.WriteFile(path+":extra", []byte("keep this"), 0600); err != nil { + t.Fatal(err) + } + if _, err := captureSessionDeletionXattrs(file, info, path); err == nil { + t.Fatal("alternate stream was eligible for purge") + } +} + +func TestWindowsDeletionObjectCapturesPermissionChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + before, err := sessionDeletionObjectFromFile(file, info) + if err != nil { + t.Fatal(err) + } + tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;" + tokenUser.User.Sid.String() + ")") + if err != nil { + t.Fatal(err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } + after, err := sessionDeletionObjectFromFile(file, info) + if err != nil { + t.Fatal(err) + } + if before.Inode != after.Inode || before.SecuritySHA256 == after.SecuritySHA256 { + t.Fatal("changed ACL was not distinguished from the original deletion object") + } +} diff --git a/internal/vfs/session_deletion_purge.go b/internal/vfs/session_deletion_purge.go index c794530..361325b 100644 --- a/internal/vfs/session_deletion_purge.go +++ b/internal/vfs/session_deletion_purge.go @@ -22,13 +22,16 @@ const ( sessionDeletionPurgeVersion = 3 sessionDeletionPurgeKind = "managed-session-deletion-purge" - sessionDeletionObjectProofDarwin = "darwin-stat-generation-birthtime-v1" - sessionDeletionObjectProofLinux = "linux-statx-btime-fs-ioc-getversion-v1" + sessionDeletionObjectProofDarwin = "darwin-stat-generation-birthtime-v1" + sessionDeletionObjectProofLinux = "linux-statx-btime-fs-ioc-getversion-v1" + sessionDeletionObjectProofWindows = "windows-ntfs-file-reference-creation-time-v1" - sessionDeletionGenerationSourceDarwin = "stat.st_gen" - sessionDeletionBirthtimeSourceDarwin = "stat.st_birthtimespec" - sessionDeletionGenerationSourceLinux = "FS_IOC_GETVERSION" - sessionDeletionBirthtimeSourceLinux = "statx.AT_EMPTY_PATH.STATX_BTIME" + sessionDeletionGenerationSourceDarwin = "stat.st_gen" + sessionDeletionBirthtimeSourceDarwin = "stat.st_birthtimespec" + sessionDeletionGenerationSourceLinux = "FS_IOC_GETVERSION" + sessionDeletionBirthtimeSourceLinux = "statx.AT_EMPTY_PATH.STATX_BTIME" + sessionDeletionGenerationSourceWindows = "NTFS.file-reference.sequence" + sessionDeletionBirthtimeSourceWindows = "BY_HANDLE_FILE_INFORMATION.CreationTime" sessionDeletionPurgePrepared = "prepared" sessionDeletionPurgeRenamed = "renamed" @@ -66,6 +69,8 @@ type SessionDeletionPurgeObject struct { BirthtimeUnixNano int64 `json:"birthtime_unix_nano"` UID uint32 `json:"uid"` GID uint32 `json:"gid"` + SecuritySHA256 string `json:"security_sha256,omitempty"` + Attributes uint32 `json:"attributes,omitempty"` } type SessionDeletionPurgeXattrs struct { @@ -1181,6 +1186,9 @@ func validSessionDeletionPurgeObject(object SessionDeletionPurgeObject) bool { if object.Device == 0 || object.Inode == 0 || object.BirthtimeUnixNano <= 0 { return false } + if object.IdentityProof != sessionDeletionObjectProofWindows && (object.SecuritySHA256 != "" || object.Attributes != 0) { + return false + } switch object.IdentityProof { case sessionDeletionObjectProofDarwin: return object.GenerationSource == sessionDeletionGenerationSourceDarwin && @@ -1189,13 +1197,17 @@ func validSessionDeletionPurgeObject(object SessionDeletionPurgeObject) bool { return object.Generation != 0 && object.GenerationSource == sessionDeletionGenerationSourceLinux && object.BirthtimeSource == sessionDeletionBirthtimeSourceLinux + case sessionDeletionObjectProofWindows: + return object.Generation != 0 && object.Generation == object.Inode>>48 && + object.GenerationSource == sessionDeletionGenerationSourceWindows && + object.BirthtimeSource == sessionDeletionBirthtimeSourceWindows && validStateSHA256(object.SecuritySHA256) default: return false } } func deletionPurgeObjectHashFields(object SessionDeletionPurgeObject) string { - return object.IdentityProof + "\x00" + + fields := object.IdentityProof + "\x00" + object.GenerationSource + "\x00" + object.BirthtimeSource + "\x00" + strconv.FormatUint(object.Device, 10) + "\x00" + @@ -1204,6 +1216,10 @@ func deletionPurgeObjectHashFields(object SessionDeletionPurgeObject) string { strconv.FormatInt(object.BirthtimeUnixNano, 10) + "\x00" + strconv.FormatUint(uint64(object.UID), 10) + "\x00" + strconv.FormatUint(uint64(object.GID), 10) + if object.IdentityProof == sessionDeletionObjectProofWindows { + fields += "\x00" + object.SecuritySHA256 + "\x00" + strconv.FormatUint(uint64(object.Attributes), 10) + } + return fields } func validSessionDeletionPurgeXattrs(xattrs SessionDeletionPurgeXattrs) bool { diff --git a/internal/vfs/session_deletion_rename_other.go b/internal/vfs/session_deletion_rename_other.go index 082f89c..603f03e 100644 --- a/internal/vfs/session_deletion_rename_other.go +++ b/internal/vfs/session_deletion_rename_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux +//go:build !darwin && !linux && !windows package vfs diff --git a/internal/vfs/session_deletion_rename_windows.go b/internal/vfs/session_deletion_rename_windows.go new file mode 100644 index 0000000..93c944c --- /dev/null +++ b/internal/vfs/session_deletion_rename_windows.go @@ -0,0 +1,86 @@ +//go:build windows + +package vfs + +import ( + "fmt" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +type deletionRenameInformation struct { + ReplaceIfExists uint32 + RootDirectory windows.Handle + FileNameLength uint32 + FileName [1]uint16 +} + +const deletionFileRenameInformationEx = 65 + +func sessionDeletionRenameNoReplace(store *sessionDeletionStoreRoot, source, target string) error { + sourceRelative, err := store.relative(source) + if err != nil { + return fmt.Errorf("open deletion rename source path: %w", err) + } + targetRelative, err := store.relative(target) + if err != nil { + return err + } + sourceParent, err := store.root.Open(filepath.Dir(sourceRelative)) + if err != nil { + return err + } + defer sourceParent.Close() + targetParent, err := store.root.Open(filepath.Dir(targetRelative)) + if err != nil { + return err + } + defer targetParent.Close() + directoryName, err := windows.NewNTUnicodeString("") + if err != nil { + return err + } + parentAttributes := windows.OBJECT_ATTRIBUTES{RootDirectory: windows.Handle(targetParent.Fd()), ObjectName: directoryName} + parentAttributes.Length = uint32(unsafe.Sizeof(parentAttributes)) + var writableParent windows.Handle + var parentStatus windows.IO_STATUS_BLOCK + err = windows.NtCreateFile(&writableParent, windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.FILE_TRAVERSE, &parentAttributes, &parentStatus, nil, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, windows.FILE_OPEN, windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT, 0, 0) + if err != nil { + return fmt.Errorf("open writable quarantine parent: %w", err) + } + defer windows.CloseHandle(writableParent) + name, err := windows.NewNTUnicodeString(filepath.Base(sourceRelative)) + if err != nil { + return err + } + attributes := windows.OBJECT_ATTRIBUTES{RootDirectory: windows.Handle(sourceParent.Fd()), ObjectName: name} + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + err = windows.NtCreateFile(&handle, windows.DELETE|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, &attributes, &status, nil, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, windows.FILE_OPEN, windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT|windows.FILE_SYNCHRONOUS_IO_NONALERT, 0, 0) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + newName, err := windows.UTF16FromString(filepath.Base(targetRelative)) + if err != nil { + return err + } + var layout deletionRenameInformation + header := int(unsafe.Offsetof(layout.FileName)) + length := (len(newName) - 1) * 2 + buffer := make([]byte, header+length) + information := (*deletionRenameInformation)(unsafe.Pointer(&buffer[0])) + information.ReplaceIfExists = windows.FILE_RENAME_POSIX_SEMANTICS + information.RootDirectory = writableParent + information.FileNameLength = uint32(length) + copy(unsafe.Slice((*uint16)(unsafe.Pointer(&buffer[header])), len(newName)-1), newName[:len(newName)-1]) + // FILE_RENAME_REPLACE_IF_EXISTS stays clear. Source and destination parents are + // open handles anchored in os.Root, not freshly resolved absolute paths. + if err := windows.NtSetInformationFile(handle, &status, &buffer[0], uint32(len(buffer)), deletionFileRenameInformationEx); err != nil { + return fmt.Errorf("rename deletion quarantine: %w", err) + } + return nil +} diff --git a/internal/vfs/session_deletion_rename_windows_test.go b/internal/vfs/session_deletion_rename_windows_test.go new file mode 100644 index 0000000..4803ab6 --- /dev/null +++ b/internal/vfs/session_deletion_rename_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package vfs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWindowsDeletionRenameNoReplaceKeepsCollision(t *testing.T) { + root := t.TempDir() + store, err := openSessionDeletionStoreRoot(root) + if err != nil { + t.Fatal(err) + } + defer store.Close() + source := filepath.Join(root, "source") + target := filepath.Join(root, "quarantine", "target") + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Dir(target), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "data"), []byte("source"), 0600); err != nil { + t.Fatal(err) + } + if err := sessionDeletionRenameNoReplace(store, source, target); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "data"), []byte("new source"), 0600); err != nil { + t.Fatal(err) + } + if err := sessionDeletionRenameNoReplace(store, source, target); err == nil { + t.Fatal("existing quarantine replaced") + } + for path, expected := range map[string]string{filepath.Join(source, "data"): "new source", filepath.Join(target, "data"): "source"} { + data, err := os.ReadFile(path) + if err != nil || string(data) != expected { + t.Fatalf("collision changed %s: %q, %v", path, data, err) + } + } +} diff --git a/internal/vfs/session_deletion_safety.go b/internal/vfs/session_deletion_safety.go index 5480b18..c79535a 100644 --- a/internal/vfs/session_deletion_safety.go +++ b/internal/vfs/session_deletion_safety.go @@ -227,7 +227,7 @@ func (s *sessionDeletionStoreRoot) acquireWriterLease(path string) (*os.File, er if err := s.requireSameDevice(before, path); err != nil { return nil, err } - file, err := s.root.OpenFile(relative, os.O_RDWR, 0) + file, err := openDeletionWriterLease(s, relative, path) if err != nil { return nil, err } diff --git a/internal/vfs/writer_lease_guard.go b/internal/vfs/writer_lease_guard.go index 95312ec..e740958 100644 --- a/internal/vfs/writer_lease_guard.go +++ b/internal/vfs/writer_lease_guard.go @@ -22,7 +22,7 @@ func TryAcquireWriterLeaseGuardAtPath(leasePath string) (*WriterLeaseGuard, bool if !before.Mode().IsRegular() { return nil, false, errors.New("writer lease guard is not a regular file") } - file, err := os.OpenFile(leasePath, os.O_RDWR, 0) + file, err := openWriterLeaseFile(leasePath, false) if err != nil { return nil, false, fmt.Errorf("open writer lease guard: %w", err) } diff --git a/internal/vfs/writer_lock_unix.go b/internal/vfs/writer_lock_unix.go index a65c726..0fab72a 100644 --- a/internal/vfs/writer_lock_unix.go +++ b/internal/vfs/writer_lock_unix.go @@ -5,10 +5,23 @@ package vfs import ( "errors" "os" + "path/filepath" "golang.org/x/sys/unix" ) +func acquireInitialWriterLease(staging, _ string) (*os.File, error) { + return acquireWriterLease(filepath.Join(staging, "writer.lease")) +} + +func openWriterLeaseFile(path string, create bool) (*os.File, error) { + flags := os.O_RDWR + if create { + flags |= os.O_CREATE + } + return os.OpenFile(path, flags, 0o600) +} + func tryLockWriterFile(file *os.File) (bool, error) { err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) if errors.Is(err, unix.EWOULDBLOCK) { diff --git a/internal/vfs/writer_lock_windows.go b/internal/vfs/writer_lock_windows.go index ce721e7..fb28bfd 100644 --- a/internal/vfs/writer_lock_windows.go +++ b/internal/vfs/writer_lock_windows.go @@ -4,11 +4,110 @@ package vfs import ( "errors" + "fmt" "os" + "path/filepath" "golang.org/x/sys/windows" ) +// Sharing delete access lets initial publication unlink the temporary lease +// name while retaining its lock through the canonical hard link. +func openWriterLeaseFile(path string, create bool) (*os.File, error) { + if filepath.Base(path) != "writer.lease" { + return openSharedWriterLeaseFile(path, create) + } + // Published sessions can be moved into retired storage while their writer + // lease is held. Open through an external alias so that the lock does not + // pin the containing directory on Windows. + file, err := openSharedWriterLeaseFile(path, create) + if err != nil { + return nil, err + } + before, statErr := file.Stat() + closeErr := file.Close() + if err := errors.Join(statErr, closeErr); err != nil { + return nil, err + } + if !before.Mode().IsRegular() { + return nil, errors.New("writer lease is not a regular file") + } + aliasFile, err := os.CreateTemp(filepath.Dir(filepath.Dir(path)), ".codexfold-writer-*.lease") + if err != nil { + return nil, err + } + alias := aliasFile.Name() + if err := aliasFile.Close(); err != nil { + _ = os.Remove(alias) + return nil, err + } + if err := os.Remove(alias); err != nil { + return nil, err + } + if err := os.Link(path, alias); err != nil { + return nil, fmt.Errorf("link published writer lease: %w", err) + } + defer os.Remove(alias) + file, err = openSharedWriterLeaseFile(alias, false) + if err != nil { + return nil, err + } + opened, statErr := file.Stat() + after, pathErr := os.Lstat(path) + if statErr != nil || pathErr != nil || !os.SameFile(before, opened) || !os.SameFile(opened, after) { + _ = file.Close() + return nil, errors.Join(statErr, pathErr, errors.New("writer lease changed while opening its external alias")) + } + if err := os.Remove(alias); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func openSharedWriterLeaseFile(path string, create bool) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + disposition := uint32(windows.OPEN_EXISTING) + if create { + disposition = windows.OPEN_ALWAYS + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, disposition, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(handle), path), nil +} + +// Windows cannot rename a directory containing an open child handle, even +// with FILE_SHARE_DELETE. Open the lease through a name outside staging, then +// hard-link it into staging before publishing. Both names identify the same +// locked file, so no writer can race the directory publication. The caller +// holds the session initialization lease while using this temporary name. +func acquireInitialWriterLease(staging, sessionID string) (*os.File, error) { + external := filepath.Join(filepath.Dir(staging), initialSessionLockName(sessionID)+".writer") + lease, err := acquireWriterLease(external) + if err != nil { + return nil, err + } + if err := os.Link(external, filepath.Join(staging, "writer.lease")); err != nil { + _ = unlockWriterFile(lease) + _ = lease.Close() + _ = os.Remove(external) + return nil, fmt.Errorf("link initial writer lease (store requires hard-link support): %w", err) + } + if err := os.Remove(external); err != nil { + _ = unlockWriterFile(lease) + _ = lease.Close() + return nil, fmt.Errorf("unlink temporary writer lease: %w", err) + } + return lease, nil +} + func tryLockWriterFile(file *os.File) (bool, error) { overlapped := new(windows.Overlapped) err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped) @@ -23,7 +122,7 @@ func unlockWriterFile(file *os.File) error { } func cleanupStaleWriterLease(path string) error { - file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + file, err := openWriterLeaseFile(path, true) if err != nil { return err } diff --git a/scripts/enable-windows-enrollment-service.ps1 b/scripts/enable-windows-enrollment-service.ps1 new file mode 100644 index 0000000..4e189d8 --- /dev/null +++ b/scripts/enable-windows-enrollment-service.ps1 @@ -0,0 +1,33 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +$candidate = Join-Path $paths.Repo 'dist\codexfold-enroll.exe' +if (-not (Test-Path -LiteralPath $candidate)) { throw 'Build dist\codexfold-enroll.exe first.' } +$resultPath = Join-Path $paths.Repo ('.tmp\enrollment-install-' + [Guid]::NewGuid().ToString('N') + '.json') +New-Item -ItemType Directory -Path (Split-Path -Parent $resultPath) -Force | Out-Null +$arguments = 'fs enroll service install --codex-home "'+$paths.Home+'" --mount "'+$paths.Mount+'" --result "'+$resultPath+'"' +if ($Apply) { $arguments += ' --apply' } +$installerPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if ($Apply -and -not $installerPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $process = Start-Process -FilePath $candidate -ArgumentList $arguments -Verb RunAs -WindowStyle Hidden -PassThru +} else { + $process = Start-Process -FilePath $candidate -ArgumentList $arguments -WindowStyle Hidden -PassThru +} +if (-not $process.WaitForExit(120000)) { throw "Enrollment installation is still running; inspect $resultPath" } +$process.Refresh() +if (-not (Test-Path -LiteralPath $resultPath)) { throw 'Enrollment installer did not return a result.' } +$result = [IO.File]::ReadAllText($resultPath) | ConvertFrom-Json +if ($process.ExitCode -ne 0 -or $result.error) { throw "Enrollment installation failed ($($process.ExitCode)): $($result.error)" } +if ($Apply) { + $startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold Enrollment.lnk' + if (Test-Path -LiteralPath $startup) { + $link = (New-Object -ComObject WScript.Shell).CreateShortcut($startup) + $legacy = Join-Path $env:LOCALAPPDATA 'CodexFold\bin\codexfold-enroll.exe' + if ($link.TargetPath -eq $legacy -and $link.Arguments.Contains($paths.Store)) { Remove-Item -LiteralPath $startup } + } +} +$result | ConvertTo-Json -Depth 5 diff --git a/scripts/enable-windows-enrollment-worker.ps1 b/scripts/enable-windows-enrollment-worker.ps1 new file mode 100644 index 0000000..0c85916 --- /dev/null +++ b/scripts/enable-windows-enrollment-worker.ps1 @@ -0,0 +1,7 @@ +# Kept as the existing entry point; enrollment now uses a persistent SCM service. +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +& (Join-Path $PSScriptRoot 'enable-windows-enrollment-service.ps1') -CodexHome $CodexHome -MountDrive $MountDrive -Apply:$Apply diff --git a/scripts/enable-windows-local.ps1 b/scripts/enable-windows-local.ps1 new file mode 100644 index 0000000..8ab2774 --- /dev/null +++ b/scripts/enable-windows-local.ps1 @@ -0,0 +1,71 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +foreach ($path in @($paths.Candidate,$paths.TrayCandidate,$paths.BackupTool,(Join-Path $paths.Repo 'dist\codexfold-enroll.exe'))) { + if (-not (Test-Path -LiteralPath $path)) { throw "Build the Windows binaries first: $path" } +} +if (-not $Apply) { + [PSCustomObject]@{Apply=$false;Home=$paths.Home;Store=$paths.Store;Native=$paths.Native;Mount=$paths.Mount;Binary=$paths.Binary;Definition=$paths.Definition;BackupBase=$paths.BackupBase;Clients=@(Get-CimInstance Win32_Process -Filter "Name='codex.exe'").Count} | ConvertTo-Json + return +} +Assert-LocalFoldOffline +$namespace = Invoke-LocalFold $paths.Candidate @('fs','namespace','status','--codex-home',$paths.Home,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') +if ($namespace.active) { throw 'The home is already activated. Use the installed service start command or the restore script.' } +if (Test-Path -LiteralPath $paths.Mount) { throw 'The selected drive is already in use.' } +Stop-LocalFoldEnrollmentWorker $paths + +# Freeze one consistent recovery copy before changing the real namespace. +New-Item -ItemType Directory -Path $paths.BackupBase -Force | Out-Null +$backup = Join-Path $paths.BackupBase ((Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0,8)) +& $paths.BackupTool -backup-only -source-home $paths.Home -root $backup +if ($LASTEXITCODE -ne 0) { throw "Backup failed; the original namespace was not changed. Inspect $backup" } +$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +& icacls.exe $backup /inheritance:r /grant:r ('*'+$userSid+':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not protect the recovery copy.' } +Assert-LocalFoldOffline + +# This service label is shared with the owned preview; stop it before replacing +# its binary/configuration, preserving the preview data in its original folder. +$existing = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.fs'" +if ($existing) { + if (-not ($existing.PathName.Contains($paths.Repo + '\.tmp\windows-use\') -or $existing.PathName.Contains($paths.Binary))) { throw 'An unrelated service uses com.codexfold.fs; it was left unchanged.' } + Stop-Service -Name 'com.codexfold.fs' -ErrorAction SilentlyContinue + (Get-Service -Name 'com.codexfold.fs').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30)) +} +Protect-LocalFoldProgramDirectory (Split-Path -Parent $paths.Binary) +Protect-LocalFoldProgramDirectory (Split-Path -Parent $paths.Definition) +Copy-Item -LiteralPath $paths.Candidate -Destination $paths.Binary -Force +Copy-Item -LiteralPath $paths.TrayCandidate -Destination $paths.Tray -Force +Install-LocalFoldService $paths '0' +try { + Invoke-LocalFold $paths.Binary @('fs','namespace','activate','--apply','--codex-home',$paths.Home,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') | Out-Null + Invoke-LocalFold $paths.Binary @('fs','validate-native','--native-root',$paths.Native,'--json') | Out-Null + Assert-LocalFoldOffline + Install-LocalFoldService $paths '0' + $status = Invoke-LocalFold $paths.Binary @('fs','service','status','--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--json') + if (-not ($status.daemon_running -and $status.mount_healthy -and $status.build.healthy)) { throw 'The installed service did not become healthy.' } + & (Join-Path $PSScriptRoot 'enable-windows-enrollment-service.ps1') -CodexHome $paths.Home -MountDrive $MountDrive -Apply | Out-Null +} catch { + # Enrollment stays disabled until activation/preflight succeeds. Rollback + # can restore the original directories while there are no managed sessions. + $failure = $_ + Stop-LocalFoldEnrollmentWorker $paths + Stop-Service -Name 'com.codexfold.fs' -ErrorAction SilentlyContinue + (Get-Service -Name 'com.codexfold.fs').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30)) + & $paths.Binary fs namespace deactivate --apply --codex-home $paths.Home --store $paths.Store --mount $paths.Mount --native-root $paths.Native --json + throw $failure +} +$startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold.lnk' +$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($startup) +$shortcut.TargetPath = $paths.Tray +$shortcut.Arguments = '--store "' + $paths.Store + '"' +$shortcut.WorkingDirectory = Split-Path -Parent $paths.Tray +$shortcut.IconLocation = $paths.Tray + ',0' +$shortcut.Save() +Start-Process -FilePath $paths.Tray -ArgumentList ('--store "'+$paths.Store+'"') -WindowStyle Hidden | Out-Null +Write-Host 'CodexFold is enabled. You can reopen Codex now.' +Write-Host "Recovery copy: $backup" diff --git a/scripts/launch-windows-local.ps1 b/scripts/launch-windows-local.ps1 new file mode 100644 index 0000000..fb5ae56 --- /dev/null +++ b/scripts/launch-windows-local.ps1 @@ -0,0 +1,53 @@ +param( + [ValidateSet('Enable','Restore')][string]$Action = 'Enable' +) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +$result = 0 + +try { + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $hostPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $arguments = '-NoProfile -File "' + $PSCommandPath + '" -Action ' + $Action + # This is the user's interactive entry point. UAC and the elevated + # console remain visible so the user can review success or failure. + Start-Process -FilePath $hostPath -ArgumentList $arguments -WorkingDirectory $repoRoot -Verb RunAs | Out-Null + return + } + + $clients = @(Get-CimInstance Win32_Process -Filter "Name='codex.exe'") + if ($clients.Count -ne 0) { + Write-Host 'Close Codex Desktop and every Codex CLI before switching storage.' + Write-Host 'If the managed daemon remains after exiting, stop it in another terminal:' + $codexBinary = Join-Path $env:LOCALAPPDATA 'Programs\OpenAI\Codex\bin\codex.exe' + if (Test-Path -LiteralPath $codexBinary) { + Write-Host (' & "' + $codexBinary + '" app-server daemon stop') + } else { + Write-Host ' codex app-server daemon stop' + } + Write-Host 'Then run this launcher again. No session directory has been changed.' + throw "$($clients.Count) Codex processes are still running." + } + + $logDirectory = Join-Path $env:LOCALAPPDATA 'CodexFold\logs' + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + $logPath = Join-Path $logDirectory ($Action.ToLowerInvariant() + '-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') + Start-Transcript -LiteralPath $logPath | Out-Null + try { + $scriptName = if ($Action -eq 'Enable') { 'enable-windows-local.ps1' } else { 'restore-windows-local.ps1' } + & (Join-Path $PSScriptRoot $scriptName) -Apply + Write-Host "Operation completed. Log: $logPath" + } finally { + Stop-Transcript | Out-Null + } +} catch { + Write-Host ('Operation stopped: ' + $_.Exception.Message) -ForegroundColor Red + $result = 1 +} finally { + if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Read-Host 'Press Enter to close this window' | Out-Null + } +} +exit $result diff --git a/scripts/restore-windows-local.ps1 b/scripts/restore-windows-local.ps1 new file mode 100644 index 0000000..2e8617f --- /dev/null +++ b/scripts/restore-windows-local.ps1 @@ -0,0 +1,29 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +if (-not $Apply) { + [PSCustomObject]@{Apply=$false;Home=$paths.Home;Store=$paths.Store;Native=$paths.Native;Mount=$paths.Mount;Definition=$paths.Definition} | ConvertTo-Json + return +} +Assert-LocalFoldOffline +$definition = [IO.File]::ReadAllText($paths.Definition) | ConvertFrom-Json +if ($definition.binary_path -ne $paths.Binary -or -not ($definition.arguments -contains $paths.Home) -or -not ($definition.arguments -contains $paths.Store)) { throw 'The installed service belongs to another home.' } +Stop-LocalFoldEnrollmentWorker $paths +Install-LocalFoldService $paths '0' +$stateRoot = Join-Path $paths.Store 'fs\sessions' +if (Test-Path -LiteralPath $stateRoot) { + foreach ($session in @(Get-ChildItem -LiteralPath $stateRoot -Directory)) { + if (-not (Test-Path -LiteralPath (Join-Path $session.FullName 'state.json'))) { continue } + Invoke-LocalFold $paths.Binary @('fs','rollback',$session.Name,'--apply','--canonical-namespace','--codex-home',$paths.Home,'--store',$paths.Store,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') | Out-Null + } +} +Invoke-LocalFold $paths.Binary @('fs','service','stop','--apply','--definition',$paths.Definition,'--json') | Out-Null +Invoke-LocalFold $paths.Binary @('fs','namespace','deactivate','--apply','--codex-home',$paths.Home,'--store',$paths.Store,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') | Out-Null +Set-Service -Name 'com.codexfold.fs' -StartupType Disabled +$startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold.lnk' +if (Test-Path -LiteralPath $startup) { Remove-Item -LiteralPath $startup } +Write-Host 'Ordinary session directories are restored with the latest saved contents. Recovery copies and the fold store are retained.' diff --git a/scripts/stop-windows-test.ps1 b/scripts/stop-windows-test.ps1 new file mode 100644 index 0000000..dc8689f --- /dev/null +++ b/scripts/stop-windows-test.ps1 @@ -0,0 +1,34 @@ +param([string]$RunRoot) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$testBase = Join-Path $repoRoot '.tmp\windows-use' +$record = if ($RunRoot) { Join-Path $RunRoot 'running.json' } else { Join-Path $testBase 'current.json' } +$running = [IO.File]::ReadAllText($record) | ConvertFrom-Json +$resolvedRoot = [IO.Path]::GetFullPath($running.root) +if (-not $resolvedRoot.StartsWith($testBase + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'The process record is outside this workspace test directory.' +} +if ($running.mode -eq 'service') { + $definitionPath = [IO.Path]::GetFullPath($running.definition) + if (-not $definitionPath.StartsWith($resolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Service definition is outside this fixture.' } + $definition = [IO.File]::ReadAllText($definitionPath) | ConvertFrom-Json + $actualService = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.fs'" + if (-not $actualService -or -not $actualService.PathName.Contains($definitionPath) -or -not $definition.binary_path.StartsWith($resolvedRoot + '\bin\', [StringComparison]::OrdinalIgnoreCase)) { throw 'The service no longer belongs to this test; it was left unchanged.' } + $stopper = Start-Process -FilePath $definition.binary_path -ArgumentList @('fs','service','stop','--apply','--definition',$definitionPath,'--json') -Verb RunAs -WindowStyle Hidden -PassThru + $stopper.WaitForExit() + if ($stopper.ExitCode -ne 0) { throw 'The isolated service did not stop.' } + Write-Host "Stopped the isolated Windows service: $resolvedRoot" + return +} +$expectedExe = [IO.Path]::GetFullPath($running.executable) +$workspaceExe = [IO.Path]::GetFullPath((Join-Path $repoRoot 'dist\codexfold.exe')) +$runExe = [IO.Path]::GetFullPath((Join-Path $resolvedRoot 'bin\codexfold.exe')) +if ($expectedExe -ne $workspaceExe -and $expectedExe -ne $runExe) { throw 'Unexpected executable in the test record.' } +$process = Get-CimInstance Win32_Process -Filter "ProcessId=$($running.pid)" +if (-not $process) { Write-Host 'Test filesystem is already stopped.'; return } +if ($process.ExecutablePath -ne $expectedExe -or -not $process.CommandLine.Contains($resolvedRoot) -or -not $process.CommandLine.Contains('serve')) { + throw 'The recorded PID now belongs to another process; it was left running.' +} +Stop-Process -Id $process.ProcessId +Write-Host "Stopped the isolated filesystem: $resolvedRoot" +Write-Host 'Snapshots and logs are retained. The tray may be closed separately.' diff --git a/scripts/test-windows-use.ps1 b/scripts/test-windows-use.ps1 new file mode 100644 index 0000000..71961dc --- /dev/null +++ b/scripts/test-windows-use.ps1 @@ -0,0 +1,172 @@ +param( + [ValidateSet('Storage', 'Mount')][string]$Mode = 'Storage', + [switch]$KeepRunning, + [switch]$RealSessionCopies +) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$testBase = Join-Path $repoRoot '.tmp\windows-use' +$cli = Join-Path $repoRoot 'dist\codexfold.exe' +$tray = Join-Path $repoRoot 'dist\codexfold-tray.exe' + +function Invoke-CodexFold { + param([string[]]$CommandArguments) + $result = & $cli @CommandArguments + if ($LASTEXITCODE -ne 0) { throw "codexfold failed: $($CommandArguments -join ' ')" } + $logName = 'command-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.json' + $result | Set-Content -LiteralPath (Join-Path $runRoot $logName) -Encoding UTF8 + Write-Host ('OK: ' + ($CommandArguments[0..([Math]::Min(1, $CommandArguments.Length-1))] -join ' ')) +} +function Assert-Hash { + param([string]$Path, [string]$Expected) + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + if ($actual -ne $Expected) { throw "Byte verification failed: $Path" } +} +function Wait-Until { + param([scriptblock]$Condition, [string]$Description) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline) { + if (& $Condition) { return } + Start-Sleep -Milliseconds 200 + } + throw "Timed out: $Description" +} +function Start-IsolatedService { + param([string]$Suffix) + $stdoutLog = Join-Path $runRoot ("service$Suffix.stdout.log") + $stderrLog = Join-Path $runRoot ("service$Suffix.stderr.log") + $serveArgs = @('fs', 'serve', '--apply', '--foreground', '--codex-home', $fixture.home, '--store', $fixture.store, '--mount', $fixture.mount, '--operation-trace', (Join-Path $runRoot "operations$Suffix.log")) + $quotedServe = ($serveArgs | ForEach-Object { '"' + $_ + '"' }) -join ' ' + $process = Start-Process -FilePath $cli -ArgumentList $quotedServe -WindowStyle Hidden -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru + try { + Wait-Until { if ($process.HasExited) { throw "Filesystem exited. See $stderrLog" }; Test-Path -LiteralPath (Join-Path $fixture.mount '.codexfold-health') } 'WinFsp mount' + } catch { + if (-not $process.HasExited) { Stop-Process -Id $process.Id } + throw + } + return $process +} + +if ($env:OS -ne 'Windows_NT') { throw 'This script requires Windows.' } +if ($KeepRunning -and $Mode -ne 'Mount') { throw '-KeepRunning requires -Mode Mount.' } +if ($Mode -eq 'Mount') { + $winfsp = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\WOW6432Node\WinFsp' -ErrorAction SilentlyContinue + if (-not $winfsp) { $winfsp = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\WinFsp' -ErrorAction SilentlyContinue } + if (-not $winfsp) { throw 'WinFsp is not installed. Install the verified official prerequisite before mounting.' } +} +New-Item -ItemType Directory -Path $testBase -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $repoRoot 'dist') -Force | Out-Null +$runPrefix = if ($RealSessionCopies) { 'run-local-' } else { 'run-' } +$runName = $runPrefix + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$runRoot = [IO.Path]::GetFullPath((Join-Path $testBase $runName)) +if (-not $runRoot.StartsWith($testBase + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Test directory escaped its workspace.' } +$serviceProcess = $null +$passed = $false +Push-Location -LiteralPath $repoRoot +try { + if ($RealSessionCopies) { + $fixtureJSON = & go run ./scripts/windows-local-fixture.go --root $runRoot + } else { + $fixtureJSON = & go run ./scripts/windows-fixture.go $runRoot + } + if ($LASTEXITCODE -ne 0) { throw 'Isolated fixture setup failed.' } + $fixture = ($fixtureJSON -join "`n") | ConvertFrom-Json + # Each run has its own binary, so rebuilding never replaces a running daemon. + $binaryDirectory = Join-Path $runRoot 'bin' + New-Item -ItemType Directory -Path $binaryDirectory -Force | Out-Null + $cli = Join-Path $binaryDirectory 'codexfold.exe' + & go build -tags winfsp -o $cli ./cmd/codexfold + if ($LASTEXITCODE -ne 0) { throw 'Windows filesystem CLI build failed.' } + $common = @('--codex-home', $fixture.home, '--store', $fixture.store) + Write-Host "Isolated test: $runRoot" + if ($RealSessionCopies) { + Write-Host "Local inventory: $($fixture.inventory.present_files) files; $($fixture.inventory.source_bytes) bytes; $($fixture.inventory.stable_candidates) stable candidates." + } + foreach ($session in $fixture.sessions) { + Invoke-CodexFold (@('fold', $session.id, '--apply', '--json') + $common) + $restored = Join-Path $runRoot ($session.id + '.restored.jsonl') + Invoke-CodexFold (@('unfold', $session.id, '--to', $restored, '--json') + $common) + Assert-Hash $restored $session.sha256 + Assert-Hash $session.path $session.sha256 + if ($RealSessionCopies) { Assert-Hash $session.source_path $session.sha256 } + } + Invoke-CodexFold (@('pack', 'build', '--json') + $common) + Invoke-CodexFold (@('pack', 'doctor', '--json') + $common) + Invoke-CodexFold (@('doctor', '--json') + $common) + Write-Host 'PASS: fold, restore, packed storage and SHA-256 verification (TF-004, TF-012, TF-020).' + + if ($Mode -eq 'Mount') { + $serviceProcess = Start-IsolatedService '' + $mountedHashes = @{} + foreach ($session in $fixture.sessions) { + Invoke-CodexFold (@('fs', 'migrate', $session.id, '--apply', '--mount', $fixture.mount, '--cli', 'none', '--desktop-app', 'none', '--json') + $common) + $mounted = Join-Path $fixture.mount ($session.id + '.jsonl') + Assert-Hash $mounted $session.sha256 + $append = [Text.Encoding]::UTF8.GetBytes("{`"type`":`"event_msg`",`"payload`":{`"type`":`"agent_message`",`"message`":`"Windows mounted append test`"}}`n") + $stream = [IO.File]::Open($mounted, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::ReadWrite) + try { $stream.Write($append, 0, $append.Length); $stream.Flush($true) } finally { $stream.Dispose() } + $mountedBytes = [IO.File]::ReadAllBytes($mounted) + if ($mountedBytes.Length -ne ($session.bytes + $append.Length)) { throw 'Mounted append length differs.' } + $appendedHash = (Get-FileHash -LiteralPath $mounted -Algorithm SHA256).Hash + $expectedBytes = New-Object byte[] ($session.bytes + $append.Length) + [IO.File]::ReadAllBytes($session.path).CopyTo($expectedBytes, 0) + $append.CopyTo($expectedBytes, $session.bytes) + $hasher = [Security.Cryptography.SHA256]::Create() + try { $expectedHash = [BitConverter]::ToString($hasher.ComputeHash($expectedBytes)).Replace('-', '') } finally { $hasher.Dispose() } + if ($appendedHash -ne $expectedHash) { throw 'Mounted append changed existing bytes.' } + $mountedHashes[$session.id] = $appendedHash + Assert-Hash $session.path $session.sha256 + } + # Simulate termination only after Flush(true) has acknowledged each append. + Stop-Process -Id $serviceProcess.Id + $serviceProcess.WaitForExit(5000) | Out-Null + Wait-Until { -not (Test-Path -LiteralPath $fixture.mount) } 'mount removed after service termination' + $unexpectedWrite = Join-Path $fixture.mount 'unexpected-write.jsonl' + $writeRejected = $false + try { [IO.File]::WriteAllText($unexpectedWrite, '{}') } catch [IO.DirectoryNotFoundException] { $writeRejected = $true } + if (-not $writeRejected) { throw 'Unmounted path accepted an ordinary write.' } + $serviceProcess = Start-IsolatedService '-restart' + foreach ($session in $fixture.sessions) { + Assert-Hash (Join-Path $fixture.mount ($session.id + '.jsonl')) $mountedHashes[$session.id] + Assert-Hash $session.path $session.sha256 + } + Write-Host 'PASS: acknowledged appends survive service termination and remount; unmounted writes fail.' + # Roll back one session, preserving a second managed session for optional manual use. + $rollbackSession = $fixture.sessions[1] + $rollbackTarget = Join-Path $runRoot 'rollback.jsonl' + $beforeRollback = (Get-FileHash -LiteralPath (Join-Path $fixture.mount ($rollbackSession.id + '.jsonl')) -Algorithm SHA256).Hash + Invoke-CodexFold (@('fs', 'rollback', $rollbackSession.id, '--apply', '--mount', $fixture.mount, '--to', $rollbackTarget, '--json') + $common) + Assert-Hash $rollbackTarget $beforeRollback + Write-Host 'PASS: real WinFsp reads, flushed append, independent sources and byte-identical rollback.' + # Reconstruct this preview namespace after retirement before manual use. + # The complete live retirement/status handoff is a separate release gate. + Stop-Process -Id $serviceProcess.Id + $serviceProcess.WaitForExit(5000) | Out-Null + Wait-Until { -not (Test-Path -LiteralPath $fixture.mount) } 'mount removed after rollback' + $serviceProcess = Start-IsolatedService '-manual' + Assert-Hash (Join-Path $fixture.mount ($fixture.sessions[0].id + '.jsonl')) $mountedHashes[$fixture.sessions[0].id] + if (Test-Path -LiteralPath (Join-Path $fixture.mount ($rollbackSession.id + '.jsonl'))) { throw 'Rolled-back session remained mounted after restart.' } + if ($KeepRunning) { + if (Test-Path -LiteralPath $tray) { Start-Process -FilePath $tray -ArgumentList ('--store "' + $fixture.store + '"') -WindowStyle Hidden | Out-Null } + $running = [pscustomobject]@{root=$runRoot;store=$fixture.store;mount=$fixture.mount;pid=$serviceProcess.Id;executable=$cli} | ConvertTo-Json + $running | Set-Content -LiteralPath (Join-Path $runRoot 'running.json') -Encoding UTF8 + $running | Set-Content -LiteralPath (Join-Path $testBase 'current.json') -Encoding UTF8 + Write-Host "Ready for manual file tests: $($fixture.mount)" + $sourceDescription = if ($RealSessionCopies) { 'independent copies of stable local sessions' } else { 'synthetic sessions' } + Write-Host "Filesystem PID: $($serviceProcess.Id). This run uses $sourceDescription." + } + } + if ($RealSessionCopies) { + foreach ($session in $fixture.sessions) { Assert-Hash $session.source_path $session.sha256 } + Write-Host 'PASS: both original local session hashes are unchanged.' + } + $passed = $true +} finally { + if ($serviceProcess -and (-not $KeepRunning -or -not $passed) -and -not $serviceProcess.HasExited) { + # Only this run's child process is stopped. All snapshots and logs are retained. + Stop-Process -Id $serviceProcess.Id + $serviceProcess.WaitForExit(5000) | Out-Null + } + Pop-Location +} +Write-Host "Test artifacts retained: $runRoot" diff --git a/scripts/upgrade-windows-core.ps1 b/scripts/upgrade-windows-core.ps1 new file mode 100644 index 0000000..47b9feb --- /dev/null +++ b/scripts/upgrade-windows-core.ps1 @@ -0,0 +1,63 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +foreach ($path in @($paths.Candidate,$paths.TrayCandidate)) { + if (-not (Test-Path -LiteralPath $path)) { throw "Build the Windows binaries first: $path" } +} +$definition = [IO.File]::ReadAllText($paths.Definition) | ConvertFrom-Json +if ($definition.binary_path -ne $paths.Binary -or $definition.service_name -ne 'com.codexfold.fs') { throw 'The filesystem definition belongs to another installation.' } +foreach ($expected in @(@('--codex-home',$paths.Home),@('--store',$paths.Store),@('--mount',$paths.Mount),@('--native-root',$paths.Native))) { + $configuredValue = $null + for ($index=0; $index -lt $definition.arguments.Count; $index++) { + $argument = $definition.arguments[$index] + if ($argument -eq $expected[0] -and $index+1 -lt $definition.arguments.Count) { $configuredValue = $definition.arguments[$index+1] } + if ($argument.StartsWith($expected[0]+'=')) { $configuredValue = $argument.Substring($expected[0].Length+1) } + } + if (-not $configuredValue -or $configuredValue.Replace('/','\').TrimEnd('\') -ne $expected[1].Replace('/','\').TrimEnd('\')) { throw ('The filesystem binding does not match '+$expected[0]) } +} +$currentService = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.fs'" +if (-not $currentService -or -not $currentService.PathName.Contains($paths.Definition) -or -not $currentService.PathName.Contains($paths.Binary)) { throw 'The filesystem service belongs to another installation.' } +$preview = Invoke-LocalFold $paths.Candidate @('fs','service','update-binary',$paths.Candidate,'--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--json') +if (-not $Apply) { + [PSCustomObject]@{Apply=$false;Home=$paths.Home;Mount=$paths.Mount;Candidate=$paths.Candidate;Target=$paths.Binary;RequiresClientsClosed=$true;Update=$preview} | ConvertTo-Json -Depth 5 + return +} +Assert-LocalFoldOffline +# Stop only the owned worker, leaving the user's persisted policy untouched. +# This lets an interrupted retirement finish through the existing recovery +# proofs during the subsequent engine startup. +$worker = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.enroll'" +$workerWasRunning = $false +try { +if ($worker) { + $workerDefinition = Join-Path $env:ProgramData 'CodexFold\Enrollment\service.json' + $binding = [IO.File]::ReadAllText($workerDefinition) | ConvertFrom-Json + if ($binding.codex_home -ne $paths.Home -or $binding.store -ne $paths.Store -or -not $worker.PathName.Contains($workerDefinition)) { throw 'The enrollment service belongs to another installation.' } + $workerWasRunning = $worker.State -eq 'Running' + if ($worker.State -ne 'Stopped') { + Stop-Service -Name 'com.codexfold.enroll' + (Get-Service -Name 'com.codexfold.enroll').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(40)) + } +} +if (-not $worker -and (Test-Path -LiteralPath (Join-Path $paths.Store 'enrollment\worker-policy.json'))) { + $rawPolicy = [IO.File]::ReadAllText((Join-Path $paths.Store 'enrollment\worker-policy.json')) | ConvertFrom-Json + if ($rawPolicy.enabled) { throw 'Pause the non-service folding worker before this offline upgrade.' } +} + Assert-LocalFoldOffline + $updated = Invoke-LocalFold $paths.Candidate @('fs','service','update-binary',$paths.Candidate,'--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--apply','--json') + $status = Invoke-LocalFold $paths.Candidate @('fs','service','status','--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--json') + if (-not ($status.daemon_running -and $status.mount_healthy -and $status.build.healthy)) { throw 'The upgraded filesystem did not pass its health check.' } + & $paths.TrayCandidate --quit --store $paths.Store + Copy-Item -LiteralPath $paths.TrayCandidate -Destination $paths.Tray -Force + $localTray = Join-Path $env:LOCALAPPDATA 'CodexFold\bin\codexfold-tray.exe' + if (Test-Path -LiteralPath $localTray) { Copy-Item -LiteralPath $paths.TrayCandidate -Destination $localTray -Force } + Start-Process -FilePath $paths.Tray -ArgumentList ('--background --store "'+$paths.Store+'"') -WindowStyle Hidden | Out-Null + $updated | ConvertTo-Json -Depth 5 +} finally { + if ($workerWasRunning) { Start-Service -Name 'com.codexfold.enroll' } +} +Write-Host 'The resident Windows mount is ready. You can reopen Codex.' diff --git a/scripts/windows-client-smoke.cjs b/scripts/windows-client-smoke.cjs new file mode 100644 index 0000000..ecf3bd1 --- /dev/null +++ b/scripts/windows-client-smoke.cjs @@ -0,0 +1,124 @@ +// Unmodified local Codex app-server against an explicitly isolated Windows fixture. +// This stage sends no model requests and never copies login credentials. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const readline = require('node:readline'); +const {spawn} = require('node:child_process'); + +async function main() { + assert.equal(process.platform, 'win32'); + const executable = path.resolve(process.argv[2] || ''); + const root = path.resolve(process.argv[3] || ''); + const testBase = path.resolve(__dirname, '..', '.tmp', 'windows-use') + path.sep; + assert.ok(root.startsWith(testBase), 'Only workspace-owned isolated fixtures are accepted'); + let full = true; + try { await fs.access(path.join(root, 'full-fixture.json')); } catch { full = false; } + const fixture = JSON.parse(await fs.readFile(path.join(root, full ? 'full-fixture.json' : 'fixture.json'), 'utf8')); + const deletionCanary = process.argv[4] === 'delete-fork'; + const forkFixture = deletionCanary && JSON.parse(await fs.readFile(path.join(root, 'client-fork.json'), 'utf8')); + const session = deletionCanary ? {id:forkFixture.id,rollout_path:forkFixture.path} : full ? fixture.session : fixture.sessions[process.argv[4] === 'native' ? 1 : 0]; + const managed = process.argv[4] === 'managed' || deletionCanary; + let mounted = full ? session.rollout_path : path.join(fixture.mount, session.id + '.jsonl'); + // Let the real client create its own complete schema, rather than using the + // small engine-only fixture database. No production database is copied. + const clientHome = full ? fixture.home : path.join(root, 'client-home'); + assert.ok(path.resolve(clientHome).toLowerCase().startsWith(root.toLowerCase() + path.sep), 'The test home must remain inside its owned fixture'); + if (full) assert.ok(path.resolve(mounted).toLowerCase().startsWith(path.resolve(clientHome).toLowerCase() + path.sep), 'The selected rollout must belong to the copied home'); + if (managed) { + assert.ok(full, 'Managed client validation requires a complete fixture'); + const native = path.join(root, 'native', path.relative(clientHome, mounted)); + await assert.rejects(fs.stat(native), error => error.code === 'ENOENT', 'The managed rollout must be served from compressed storage'); + } + await fs.mkdir(clientHome, {recursive:true}); + if (!full && process.argv[4] === 'native') { + const date = path.basename(session.source_path).match(/^rollout-(\d{4})-(\d{2})-(\d{2})T/); + assert.ok(date, 'The native baseline must preserve the original rollout filename'); + const directory = path.join(clientHome, 'sessions', date[1], date[2], date[3]); + await fs.mkdir(directory, {recursive:true}); + mounted = path.join(directory, path.basename(session.source_path)); + await fs.copyFile(session.path, mounted); + } + const child = spawn(executable, ['app-server', '--stdio'], { + env: {...process.env, CODEX_HOME: clientHome}, cwd: root, + windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + }); + const pending = new Map(); + let serial = 0, stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + const lines = readline.createInterface({input: child.stdout}); + lines.on('line', line => { + let message; + try { message = JSON.parse(line); } catch { return; } + if (message.id !== undefined && message.method) { + child.stdin.write(JSON.stringify({id:message.id, error:{code:-32601,message:'No test approval or external action handler'}}) + '\n'); + return; + } + const request = pending.get(message.id); + if (!request) return; + clearTimeout(request.timer); pending.delete(message.id); + if (message.error) { + fs.writeFile(path.join(root, 'client-error.json'), JSON.stringify(message.error, null, 2)).catch(() => {}); + request.reject(new Error(request.method + ' failed with code ' + message.error.code)); + } else request.resolve(message.result); + }); + child.on('exit', () => { + for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('Owned test app-server exited')); } + pending.clear(); + }); + const call = (method, params) => new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timed out')); }, 30000); + pending.set(id, {method, resolve, reject, timer}); + child.stdin.write(JSON.stringify({id, method, params}) + '\n'); + }); + try { + await call('initialize', {clientInfo:{name:'codexfold_windows_smoke',version:'1.0.0'},capabilities:{experimentalApi:true}}); + child.stdin.write(JSON.stringify({method:'initialized',params:{}}) + '\n'); + const resumed = await call('thread/resume', { + threadId:session.id, path:mounted, cwd:root, + modelProvider:'openai', approvalPolicy:'never', sandbox:'read-only', + }); + assert.equal(resumed.thread.id, session.id); + const label = process.argv[4] === 'native' ? 'native' : managed ? 'managed' : 'mounted'; + console.log('PASS: local Codex resumes the copied ' + label + ' thread'); + const read = await call('thread/read', {threadId:session.id, includeTurns:true}); + assert.equal(read.thread.id, session.id); + console.log('PASS: local Codex reads copied ' + label + ' history; turns=' + read.thread.turns.length); + if (deletionCanary) { + await call('thread/delete', {threadId:session.id}); + await fs.writeFile(path.join(root, 'client-deletion-result.json'), JSON.stringify({id:session.id,deleted:true}, null, 2)); + console.log('PASS: local Codex deletes the compressed test fork'); + return; + } + if (full && process.argv[4] !== 'native') { + const fork = await call('thread/fork', {threadId:session.id,cwd:root,modelProvider:'openai',approvalPolicy:'never',sandbox:'read-only'}); + assert.notEqual(fork.thread.id, session.id); + console.log('PASS: local Codex forks the mounted thread'); + await call('thread/archive', {threadId:fork.thread.id}); + await call('thread/unarchive', {threadId:fork.thread.id}); + console.log('PASS: local Codex archives and unarchives its new fork'); + if (process.argv[4] === 'keep-fork') { + await fs.writeFile(path.join(root, 'client-fork.json'), JSON.stringify({id:fork.thread.id,path:fork.thread.path}, null, 2)); + } else { + await call('thread/delete', {threadId:fork.thread.id}); + console.log('PASS: local Codex deletes its new native fork'); + } + if (managed) { + await call('thread/archive', {threadId:session.id}); + await call('thread/unarchive', {threadId:session.id}); + console.log('PASS: local Codex archives and unarchives the compressed managed thread'); + } + } + } finally { + await fs.writeFile(path.join(root, 'client.stderr.log'), stderr); + child.stdin.end(); + await new Promise(resolve => { + if (child.exitCode !== null) return resolve(); + const timer = setTimeout(() => { child.kill(); resolve(); }, 5000); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + }); + lines.close(); + } +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/scripts/windows-fixture.go b/scripts/windows-fixture.go new file mode 100644 index 0000000..828d24e --- /dev/null +++ b/scripts/windows-fixture.go @@ -0,0 +1,98 @@ +//go:build ignore + +// Creates synthetic sessions for scripts/test-windows-use.ps1. Never reads the real Codex home. +package main + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +func main() { + if err := create(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func create() error { + if len(os.Args) != 2 { + return fmt.Errorf("usage: go run scripts/windows-fixture.go ") + } + root, err := filepath.Abs(os.Args[1]) + if err != nil { + return err + } + // Refuse every existing directory: a fixture can never overwrite user data. + if err := os.Mkdir(root, 0700); err != nil { + return err + } + home := filepath.Join(root, "home") + if err := os.MkdirAll(filepath.Join(home, "sessions"), 0700); err != nil { + return err + } + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec(`create table threads (id text primary key, title text, cwd text, rollout_path text, model_provider text, model text, updated_at integer, archived integer, git_branch text)`) + if err != nil { + return err + } + type session struct { + ID string `json:"id"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Bytes int `json:"bytes"` + } + var sessions []session + for i, id := range []string{"a1111111-1111-4111-8111-111111111111", "b2222222-2222-4222-8222-222222222222"} { + var content bytes.Buffer + encoder := json.NewEncoder(&content) + if err := encoder.Encode(map[string]any{"timestamp": "2026-10-01T00:00:00Z", "type": "session_meta", "payload": map[string]any{"id": id, "cwd": root, "originator": "codexfold-windows-test", "cli_version": "fixture"}}); err != nil { + return err + } + for record := 0; record < 192; record++ { + payload := map[string]any{"type": "agent_message", "message": strings.Repeat("CodexFold Windows byte-preserving test. 中文会话内容。\n", 180)} + if err := encoder.Encode(map[string]any{"timestamp": "2026-10-01T00:00:01Z", "type": "event_msg", "payload": payload}); err != nil { + return err + } + } + if err := encoder.Encode(map[string]any{"type": "event_msg", "payload": map[string]any{"type": "agent_message", "message": fmt.Sprintf("Independent session %d", i)}}); err != nil { + return err + } + rollout := filepath.Join(home, "sessions", "rollout-2026-10-01T00-00-00-"+id+".jsonl") + if err := os.WriteFile(rollout, content.Bytes(), 0600); err != nil { + return err + } + digest := sha256.Sum256(content.Bytes()) + if _, err := db.Exec(`insert into threads values (?, ?, ?, ?, 'fixture', 'fixture', ?, 0, '')`, id, "Windows isolated test", root, rollout, time.Now().Add(-2*time.Hour).Unix()); err != nil { + return err + } + sessions = append(sessions, session{id, rollout, hex.EncodeToString(digest[:]), content.Len()}) + } + if err := db.Close(); err != nil { + return err + } + metadata := map[string]any{"root": root, "home": home, "store": filepath.Join(root, "store"), "mount": filepath.Join(root, "mount"), "sessions": sessions} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "fixture.json"), encoded, 0600); err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} diff --git a/scripts/windows-full-fixture.go b/scripts/windows-full-fixture.go new file mode 100644 index 0000000..6d15064 --- /dev/null +++ b/scripts/windows-full-fixture.go @@ -0,0 +1,278 @@ +//go:build ignore + +// A byte-preserving full session-tree and SQLite snapshot for real-client tests. +// Opens the production database read-only; does not copy credentials or config. +package main + +import ( + "bufio" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/samekind/codexfold/internal/codex" +) + +type snapshotFile struct { + Source string `json:"source"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + rootArg := flag.String("root", "", "New workspace-owned fixture directory") + homeArg := flag.String("source-home", "", "Codex home to open read-only") + backupOnly := flag.Bool("backup-only", false, "Preserve original SQLite routes for an offline recovery backup") + flag.Parse() + home, err := codex.ResolveHome(*homeArg) + if err != nil { + return err + } + home, err = filepath.Abs(home) + if err != nil { + return err + } + root, err := filepath.Abs(*rootArg) + if err != nil || *rootArg == "" { + return errors.New("a new absolute fixture root is required") + } + relative, err := filepath.Rel(home, root) + if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) { + return errors.New("fixture must be outside the production Codex home") + } + if err := os.Mkdir(root, 0700); err != nil { + return err + } + clone := filepath.Join(root, "home") + if err := os.Mkdir(clone, 0700); err != nil { + return err + } + var files []snapshotFile + var skipped int + for _, namespace := range []string{"sessions", "archived_sessions"} { + sourceBase := filepath.Join(home, namespace) + if *backupOnly { + info, err := os.Lstat(sourceBase) + if err != nil || !info.IsDir() || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return fmt.Errorf("backup requires ordinary session directories: %s", sourceBase) + } + } + if err := os.MkdirAll(filepath.Join(clone, namespace), 0700); err != nil { + return err + } + err := filepath.WalkDir(sourceBase, func(source string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + skipped++ + return nil + } + rel, err := filepath.Rel(home, source) + if err != nil { + return err + } + target := filepath.Join(clone, rel) + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + digest, err := copySnapshot(source, target, info) + if err != nil { + skipped++ + _ = os.Remove(target) // exact file created by this copy only + return nil + } + files = append(files, snapshotFile{source, target, info.Size(), digest}) + return nil + }) + if err != nil { + return err + } + } + if *backupOnly && skipped != 0 { + return fmt.Errorf("backup is incomplete: %d changed or non-regular files", skipped) + } + // VACUUM INTO makes one consistent snapshot, including committed WAL data, + // while the source connection remains mode=ro. + sourceDB, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(home, "state_5.sqlite"))+"?mode=ro") + if err != nil { + return err + } + _, err = sourceDB.Exec(`vacuum into ?`, filepath.Join(clone, "state_5.sqlite")) + closeErr := sourceDB.Close() + if err := errors.Join(err, closeErr); err != nil { + return err + } + if *backupOnly { + var bytes int64 + for _, file := range files { + bytes += file.Bytes + } + metadata := map[string]any{"root": root, "home": clone, "source_home": home, "files": files, "copied_bytes": bytes, "copied_files": len(files), "database_routes_preserved": true} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "backup.json"), encoded, 0600); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"root": root, "copied_files": len(files), "copied_bytes": bytes, "database_routes_preserved": true}) + } + db, err := sql.Open("sqlite", filepath.Join(clone, "state_5.sqlite")) + if err != nil { + return err + } + defer db.Close() + rows, err := db.Query(`select id, rollout_path from threads`) + if err != nil { + return err + } + type route struct{ id, path string } + var routes []route + for rows.Next() { + var r route + if err := rows.Scan(&r.id, &r.path); err != nil { + rows.Close() + return err + } + routes = append(routes, r) + } + if err := errors.Join(rows.Err(), rows.Close()); err != nil { + return err + } + for _, r := range routes { + rel, err := filepath.Rel(home, r.path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + // Never let a test client route writes to an external original file. + if _, err := db.Exec(`delete from threads where id=?`, r.id); err != nil { + return err + } + continue + } + if _, err := db.Exec(`update threads set rollout_path=? where id=?`, filepath.Join(clone, rel), r.id); err != nil { + return err + } + } + if err := db.Close(); err != nil { + return err + } + sessions, err := codex.LoadSessions(clone) + if err != nil { + return err + } + var candidates []codex.Session + for _, s := range sessions { + info, err := os.Stat(s.RolloutPath) + if err != nil || info.Size() == 0 || s.UpdatedAt > time.Now().Add(-time.Hour).Unix() { + continue + } + file, err := os.Open(s.RolloutPath) + if err != nil { + continue + } + first, readErr := bufio.NewReader(file).ReadBytes('\n') + file.Close() + var header struct { + Type string `json:"type"` + Payload struct { + ID string `json:"id"` + Source json.RawMessage `json:"source"` + } `json:"payload"` + } + if readErr != nil || json.Unmarshal(first, &header) != nil || header.Type != "session_meta" || header.Payload.ID != s.ID { + continue + } + var source string + if json.Unmarshal(header.Payload.Source, &source) != nil { + continue + } // multi-agent child histories need their parent + candidates = append(candidates, s) + } + if len(candidates) == 0 { + return errors.New("no stable main thread is available for isolated real-client testing") + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].UpdatedAt > candidates[j].UpdatedAt }) + var bytes int64 + for _, f := range files { + bytes += f.Bytes + } + metadata := map[string]any{"root": root, "home": clone, "store": filepath.Join(root, "store"), "native_root": filepath.Join(root, "native"), "mount": "T:\\", "files": files, "source_home": home, "copied_bytes": bytes, "copied_files": len(files), "skipped_files": skipped, "session": candidates[0], "source": "full-local-session-copy"} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "full-fixture.json"), encoded, 0600); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"root": root, "copied_files": len(files), "copied_bytes": bytes, "skipped_files": skipped, "threads": len(sessions), "main_thread": candidates[0].ID}) +} + +func copySnapshot(sourcePath, target string, expected os.FileInfo) (string, error) { + source, err := os.Open(sourcePath) + if err != nil { + return "", err + } + defer source.Close() + before, err := source.Stat() + if err != nil { + return "", err + } + if !os.SameFile(before, expected) || before.Size() != expected.Size() || !before.ModTime().Equal(expected.ModTime()) { + return "", errors.New("source changed") + } + destination, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return "", err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(destination, hash), source) + err = errors.Join(copyErr, destination.Sync(), destination.Close()) + if err != nil { + return "", err + } + after, err := source.Stat() + if err != nil { + return "", err + } + if n != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) { + return "", errors.New("source changed") + } + digest := hex.EncodeToString(hash.Sum(nil)) + if _, err := source.Seek(0, io.SeekStart); err != nil { + return "", err + } + hash.Reset() + if _, err := io.Copy(hash, source); err != nil { + return "", err + } + if hex.EncodeToString(hash.Sum(nil)) != digest { + return "", errors.New("source digest changed") + } + return digest, os.Chtimes(target, before.ModTime(), before.ModTime()) +} diff --git a/scripts/windows-local-common.ps1 b/scripts/windows-local-common.ps1 new file mode 100644 index 0000000..b629913 --- /dev/null +++ b/scripts/windows-local-common.ps1 @@ -0,0 +1,82 @@ +$ErrorActionPreference = 'Stop' + +function Get-LocalFoldPaths { + param([string]$CodexHome, [string]$MountDrive) + $repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + if (-not $CodexHome) { + $CodexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.codex' } + } + $foldHome = [IO.Path]::GetFullPath($CodexHome) + if (-not (Test-Path -LiteralPath (Join-Path $foldHome 'state_5.sqlite'))) { throw 'Codex state_5.sqlite was not found.' } + [PSCustomObject]@{ + Home = $foldHome + Store = Join-Path $foldHome 'fold-store' + Native = Join-Path $foldHome 'fold-native' + Mount = $MountDrive.ToUpperInvariant() + ':/' + Binary = Join-Path $env:ProgramFiles 'CodexFold\codexfold.exe' + Tray = Join-Path $env:ProgramFiles 'CodexFold\codexfold-tray.exe' + Definition = Join-Path $env:ProgramData 'CodexFold\service.json' + Logs = Join-Path $env:ProgramData 'CodexFold\logs' + Candidate = Join-Path $repoRoot 'dist\codexfold.exe' + TrayCandidate = Join-Path $repoRoot 'dist\codexfold-tray.exe' + BackupTool = Join-Path $repoRoot 'dist\codexfold-home-backup.exe' + BackupBase = Join-Path $env:LOCALAPPDATA 'CodexFold\backups' + Repo = $repoRoot + } +} + +function Assert-LocalFoldOffline { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this script in an administrator PowerShell window.' } + $clients = @(Get-CimInstance Win32_Process -Filter "Name='codex.exe'") + if ($clients.Count -ne 0) { throw "Close all Codex Desktop and CLI clients first ($($clients.Count) processes remain). This script does not close clients." } +} + +function Invoke-LocalFold { + param([string]$Binary, [string[]]$CommandArguments) + $output = & $Binary @CommandArguments + if ($LASTEXITCODE -ne 0) { throw "CodexFold failed: $($CommandArguments[0..([Math]::Min(2, $CommandArguments.Length-1))] -join ' ')" } + if ($output) { ($output -join "`n") | ConvertFrom-Json } +} + +function Install-LocalFoldService { + param($Paths, [string]$Interval) + Invoke-LocalFold $Paths.Binary @('fs','service','install','--apply','--binary',$Paths.Binary,'--codex-home',$Paths.Home,'--store',$Paths.Store,'--mount',$Paths.Mount,'--canonical-namespace','--native-root',$Paths.Native,'--definition',$Paths.Definition,'--log-dir',$Paths.Logs,'--enrollment-interval',$Interval,'--enrollment-stable-for','1h','--enrollment-batch-size','1','--json') | Out-Null +} + +function Protect-LocalFoldProgramDirectory { + param([string]$Path) + New-Item -ItemType Directory -Path $Path -Force | Out-Null + & icacls.exe $Path /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not protect the installed program directory.' } +} + +function Stop-LocalFoldEnrollmentWorker { + param($Paths) + $enrollmentService = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.enroll'" + if ($enrollmentService) { + $enrollmentDefinition = Join-Path $env:ProgramData 'CodexFold\Enrollment\service.json' + $binding = [IO.File]::ReadAllText($enrollmentDefinition) | ConvertFrom-Json + if ($binding.codex_home -ne $Paths.Home -or $binding.store -ne $Paths.Store -or -not $enrollmentService.PathName.Contains($enrollmentDefinition) -or -not $enrollmentService.PathName.Contains($binding.binary_path)) { throw 'The enrollment service belongs to another installation.' } + if ($enrollmentService.State -ne 'Stopped') { + Stop-Service -Name 'com.codexfold.enroll' + (Get-Service -Name 'com.codexfold.enroll').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(40)) + } + Set-Service -Name 'com.codexfold.enroll' -StartupType Disabled + } + $policy = Join-Path $Paths.Store 'enrollment\worker-policy.json' + if (-not (Test-Path -LiteralPath $policy)) { return } + $workerBinary = Join-Path $env:LOCALAPPDATA 'CodexFold\bin\codexfold-enroll.exe' + if (-not $enrollmentService) { + if (-not (Test-Path -LiteralPath $workerBinary)) { throw 'The separate enrollment worker binary is missing; stop it before switching storage.' } + $arguments = 'fs enroll stop --apply --store "'+$Paths.Store+'" --codex-home "'+$Paths.Home+'"' + $stopRequest = Start-Process -FilePath $workerBinary -ArgumentList $arguments -WindowStyle Hidden -PassThru + if (-not $stopRequest.WaitForExit(40000) -or $stopRequest.ExitCode -ne 0) { throw 'The separate enrollment worker did not finish stopping.' } + } + $startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold Enrollment.lnk' + if (Test-Path -LiteralPath $startup) { + $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($startup) + if ($shortcut.TargetPath -eq $workerBinary -and $shortcut.Arguments.Contains($Paths.Store)) { Remove-Item -LiteralPath $startup } + } +} diff --git a/scripts/windows-local-fixture.go b/scripts/windows-local-fixture.go new file mode 100644 index 0000000..074b8ab --- /dev/null +++ b/scripts/windows-local-fixture.go @@ -0,0 +1,204 @@ +//go:build ignore + +// Copies stable local sessions into an independent test home. Source access is +// read-only; no credentials, configuration, or live SQLite database are copied. +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/samekind/codexfold/internal/codex" +) + +type localCandidate struct { + session codex.Session + info os.FileInfo +} +type copiedSession struct { + ID string `json:"id"` + Path string `json:"path"` + SourcePath string `json:"source_path"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + sourceHome := flag.String("source-home", "", "Local Codex home, opened read-only") + rootArg := flag.String("root", "", "New test directory; omitted reports only inventory") + flag.Parse() + realHome, err := codex.ResolveHome(*sourceHome) + if err != nil { + return err + } + realHome, err = filepath.Abs(realHome) + if err != nil { + return err + } + sessions, err := codex.LoadSessions(realHome) + if err != nil { + return err + } + var candidates []localCandidate + var present, missing, excluded int + var totalBytes int64 + cutoff := time.Now().Add(-time.Hour) + for _, s := range sessions { + relative, err := filepath.Rel(realHome, s.RolloutPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + excluded++ + continue + } + info, err := os.Lstat(s.RolloutPath) + if os.IsNotExist(err) { + missing++ + continue + } + if err != nil || !info.Mode().IsRegular() { + excluded++ + continue + } + present++ + totalBytes += info.Size() + if info.Size() > 0 && info.Size() <= 64<<20 && info.ModTime().Before(cutoff) && s.UpdatedAt < cutoff.Unix() && filepath.Base(s.ID) == s.ID && !strings.ContainsAny(s.ID, "/\\\x00") { + candidates = append(candidates, localCandidate{s, info}) + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].info.Size() > candidates[j].info.Size() }) + inventory := map[string]any{"source_home": realHome, "database_sessions": len(sessions), "present_files": present, "missing_files": missing, "excluded_files": excluded, "source_bytes": totalBytes, "stable_candidates": len(candidates)} + if *rootArg == "" { + return printJSON(inventory) + } + if len(candidates) < 2 { + return errors.New("at least two stable local sessions are required") + } + root, err := filepath.Abs(*rootArg) + if err != nil { + return err + } + if root == realHome || strings.HasPrefix(strings.ToLower(root), strings.ToLower(realHome)+string(filepath.Separator)) { + return errors.New("test directory must be outside the real Codex home") + } + if err := os.Mkdir(root, 0o700); err != nil { + return err + } + home := filepath.Join(root, "home") + if err := os.MkdirAll(filepath.Join(home, "sessions"), 0o700); err != nil { + return err + } + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + return err + } + defer db.Close() + if _, err = db.Exec(`create table threads (id text primary key, title text, cwd text, rollout_path text, model_provider text, model text, updated_at integer, archived integer, git_branch text)`); err != nil { + return err + } + var copies []copiedSession + for _, candidate := range candidates { + if len(copies) == 2 { + break + } + s := candidate.session + target := filepath.Join(home, "sessions", "rollout-local-"+s.ID+".jsonl") + digest, err := copyStable(candidate, target) + if err != nil { + fmt.Fprintln(os.Stderr, "Skipped changed/unavailable source:", s.ID, err) + continue + } + if _, err = db.Exec(`insert into threads values (?, ?, ?, ?, ?, ?, ?, 0, '')`, s.ID, "Local session copy", root, target, s.ModelProvider, s.Model, s.UpdatedAt); err != nil { + return err + } + copies = append(copies, copiedSession{s.ID, target, s.RolloutPath, digest, candidate.info.Size()}) + } + if len(copies) != 2 { + return errors.New("could not capture two stable local sessions") + } + if err := db.Close(); err != nil { + return err + } + metadata := map[string]any{"root": root, "home": home, "store": filepath.Join(root, "store"), "mount": filepath.Join(root, "mount"), "sessions": copies, "inventory": inventory, "source": "local-session-copies"} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "fixture.json"), encoded, 0o600); err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} + +func copyStable(candidate localCandidate, target string) (string, error) { + source, err := os.Open(candidate.session.RolloutPath) + if err != nil { + return "", err + } + defer source.Close() + before, err := source.Stat() + if err != nil { + return "", err + } + if !os.SameFile(candidate.info, before) || before.Size() != candidate.info.Size() || !before.ModTime().Equal(candidate.info.ModTime()) { + return "", errors.New("source changed before copy") + } + destination, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return "", err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(destination, hash), source) + syncErr := destination.Sync() + closeErr := destination.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return "", err + } + after, err := source.Stat() + if err != nil { + return "", err + } + if n != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) { + return "", errors.New("source changed during copy") + } + if _, err := source.Seek(0, io.SeekStart); err != nil { + return "", err + } + verification := sha256.New() + if _, err := io.Copy(verification, source); err != nil { + return "", err + } + digest := hex.EncodeToString(hash.Sum(nil)) + if digest != hex.EncodeToString(verification.Sum(nil)) { + return "", errors.New("source hash changed during copy") + } + if err := os.Chtimes(target, before.ModTime(), before.ModTime()); err != nil { + return "", err + } + return digest, nil +} + +func printJSON(value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err == nil { + fmt.Println(string(encoded)) + } + return err +} diff --git a/scripts/windows-model-smoke.cjs b/scripts/windows-model-smoke.cjs new file mode 100644 index 0000000..de65ff2 --- /dev/null +++ b/scripts/windows-model-smoke.cjs @@ -0,0 +1,149 @@ +// A real, short model conversation in the owned Windows fixture. Authentication +// is supplied over stdin in memory; production history and credentials stay put. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const readline = require('node:readline'); +const {spawn} = require('node:child_process'); +const {randomBytes} = require('node:crypto'); + +async function main() { + assert.equal(process.platform, 'win32'); + const executable = path.resolve(process.argv[2] || ''); + const root = path.resolve(process.argv[3] || ''); + const mode = process.argv[4] || 'read'; + assert.ok(['start', 'resume', 'recovered', 'live-recovery', 'read'].includes(mode)); + assert.ok(root.startsWith(path.resolve(__dirname, '..', '.tmp', 'windows-use') + path.sep)); + const fixture = JSON.parse(await fs.readFile(path.join(root, 'full-fixture.json'), 'utf8')); + const home = path.resolve(fixture.home); + assert.ok(home.startsWith(root + path.sep)); + const recordPath = path.join(root, 'model-canary.json'); + const cwd = path.join(root, 'model-work'); + await fs.mkdir(cwd, {recursive:true}); + await assert.rejects(fs.stat(path.join(home, 'auth.json')), e => e.code === 'ENOENT'); + let record = mode === 'start' ? {code:'CF_' + randomBytes(10).toString('hex'), phases:[]} : JSON.parse(await fs.readFile(recordPath, 'utf8')); + const auth = mode === 'read' ? null : JSON.parse(await fs.readFile(path.join(process.env.USERPROFILE, '.codex', 'auth.json'), 'utf8')); + if (auth) assert.ok(auth.tokens?.access_token && auth.tokens?.account_id, 'Existing ChatGPT login is required'); + const child = spawn(executable, ['app-server', '--stdio'], { + cwd, env:{...process.env, CODEX_HOME:home}, windowsHide:true, stdio:['pipe','pipe','pipe'], + }); + const pending = new Map(), completed = new Map(), waiters = new Map(); + let serial = 0, tools = 0, stderr = ''; + const send = message => child.stdin.write(JSON.stringify(message) + '\n'); + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + const lines = readline.createInterface({input:child.stdout}); + lines.on('line', line => { + let m; try { m = JSON.parse(line); } catch { return; } + if (m.id !== undefined && m.method) { + send({id:m.id,error:{code:-32601,message:'Test client declines external actions and token refresh'}}); + return; + } + if (m.method === 'item/started' && !['userMessage','agentMessage','reasoning'].includes(m.params?.item?.type)) tools++; + if (m.method === 'turn/completed') { + const key = m.params.threadId; + completed.set(key, m.params.turn); + const waiter = waiters.get(key); + if (waiter) { clearTimeout(waiter.timer); waiters.delete(key); waiter.resolve(m.params.turn); } + } + const request = pending.get(m.id); + if (!request) return; + clearTimeout(request.timer); pending.delete(m.id); + if (m.error) request.reject(new Error(request.method + ': ' + String(m.error.message))); + else request.resolve(m.result); + }); + child.on('exit', () => { + for (const request of [...pending.values(), ...waiters.values()]) { clearTimeout(request.timer); request.reject(new Error('Owned model test client exited')); } + pending.clear(); waiters.clear(); + }); + const call = (method, params) => new Promise((resolve,reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timed out')); }, 60000); + pending.set(id,{method,resolve,reject,timer}); send({id,method,params}); + }); + const waitTurn = threadId => new Promise((resolve,reject) => { + if (completed.has(threadId)) return resolve(completed.get(threadId)); + const timer = setTimeout(() => { waiters.delete(threadId); reject(new Error('Real model turn timed out')); },180000); + waiters.set(threadId,{resolve,reject,timer}); + }); + const options = {cwd,model:'gpt-6.1-sol',modelProvider:'openai',approvalPolicy:'never',sandbox:'read-only', + baseInstructions:'You are a storage compatibility test. Answer only the requested short text. Do not use any tools, access files, or delegate.', + config:{model_reasoning_effort:'medium'},environments:[]}; + try { + await call('initialize',{clientInfo:{name:'codexfold_windows_model_canary',version:'1.0.0'},capabilities:{experimentalApi:true}}); + send({method:'initialized',params:{}}); + if (auth) { + await call('account/login/start',{type:'chatgptAuthTokens',accessToken:auth.tokens.access_token,chatgptAccountId:auth.tokens.account_id}); + console.log('PASS: isolated test client authenticated in memory'); + } + let thread; + if (mode === 'start') thread = (await call('thread/start',options)).thread; + else { + assert.ok(record.id && record.path); + const canaryPath = path.resolve(String(record.path).replace(/^\\\\\?\\/,'')); + assert.ok(canaryPath.toLowerCase().startsWith(home.toLowerCase() + path.sep), 'Resume only the isolated canary'); + record.path = canaryPath; + if (mode !== 'read') await assert.rejects(fs.stat(path.join(root,'native',path.relative(home,record.path))), e => e.code === 'ENOENT'); + thread = (await call('thread/resume',{...options,threadId:record.id,path:record.path})).thread; + } + if (mode === 'live-recovery') { + const trigger = path.join(root,'model-recovery-trigger.json'); + await fs.rm(trigger,{force:true}); + await fs.writeFile(path.join(root,'model-recovery-ready.json'),JSON.stringify({threadId:thread.id,pid:child.pid})); + console.log('READY: owned model test client remains open during filesystem recovery'); + const deadline = Date.now() + 180000; + while (true) { + try { await fs.access(trigger); break; } catch (error) { if (error.code !== 'ENOENT') throw error; } + assert.ok(Date.now() < deadline,'Filesystem recovery signal timed out'); + await new Promise(resolve => setTimeout(resolve,250)); + } + } + if (mode !== 'read') { + const prompt = mode === 'start' ? 'Remember the code ' + record.code + '. Reply with exactly SAVED.' : + mode === 'resume' ? 'What code did I ask you to remember? Reply only with that code.' : + 'Reply with the code I asked you to remember, followed by a space and RECOVERED.'; + completed.delete(thread.id); + const waiting = waitTurn(thread.id); + // Attach a handler immediately, including when turn/start itself fails. + waiting.catch(() => {}); + const started = await call('turn/start',{threadId:thread.id,input:[{type:'text',text:prompt}],effort:'medium',environments:[]}); + console.log('Started real model turn: ' + mode); + const turn = await waiting; + assert.equal(turn.id,started.turn.id); + assert.equal(turn.status,'completed', 'The real model turn must finish successfully'); + const text = turn.items.filter(item => item.type === 'agentMessage').map(item => item.text).join('\n').trim(); + assert.equal(text, mode === 'start' ? 'SAVED' : mode === 'resume' ? record.code : record.code + ' RECOVERED'); + assert.equal(tools,0,'The model must not invoke tools'); + record.phases.push({mode,turnId:turn.id,completed:true}); + console.log('PASS: real model replied correctly without tool actions (' + mode + ')'); + } + const read = (await call('thread/read',{threadId:thread.id,includeTurns:true})).thread; + record.id = read.id; record.path = read.path; + // Paths from the app-server may be verbatim drive paths. Validate their + // resolved namespace before persisting the record or reading any bytes. + const localPath = path.resolve(String(record.path).replace(/^\\\\\?\\/,'')); + assert.ok(localPath.toLowerCase().startsWith(home.toLowerCase() + path.sep), 'The canary must remain in the isolated home'); + record.path = localPath; + const messages = read.turns.flatMap(turn => turn.items).filter(item => item.type === 'agentMessage').map(item => item.text.trim()); + for (const phase of record.phases) { + const expected = phase.mode === 'start' ? 'SAVED' : phase.mode === 'resume' ? record.code : record.code + ' RECOVERED'; + assert.ok(messages.includes(expected),'Previously completed replies must survive reopening'); + } + record.visibleTurns = read.turns.length; + await fs.writeFile(recordPath,JSON.stringify(record,null,2)); + console.log('PASS: persisted history contains every completed reply; turns=' + read.turns.length); + } finally { + child.stdin.end(); + await new Promise(resolve => { + if (child.exitCode !== null) return resolve(); + const timer = setTimeout(() => { child.kill(); resolve(); },5000); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + }); + lines.close(); + for (const request of [...pending.values(), ...waiters.values()]) clearTimeout(request.timer); + // Keep diagnostic text private and remove token values before writing it. + if (auth) for (const token of Object.values(auth.tokens)) if (typeof token === 'string' && token) stderr = stderr.split(token).join('[REDACTED]'); + await fs.writeFile(path.join(root,'model-client.stderr.log'),stderr); + await assert.rejects(fs.stat(path.join(home,'auth.json')), e => e.code === 'ENOENT','External-token authentication must not create a credential file'); + } +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git "a/\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" "b/\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" new file mode 100644 index 0000000..69c0c46 --- /dev/null +++ "b/\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" @@ -0,0 +1,4 @@ +@echo off +setlocal +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "%~dp0scripts\launch-windows-local.ps1" -Action Enable +exit /b %errorlevel% diff --git "a/\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" "b/\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" new file mode 100644 index 0000000..232e5da --- /dev/null +++ "b/\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" @@ -0,0 +1,4 @@ +@echo off +setlocal +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "%~dp0scripts\launch-windows-local.ps1" -Action Restore +exit /b %errorlevel%
未连接
正在读取后台状态…
暂无持续故障记录
诊断包含运行状态、设置和汇总记录,不含会话内容。