From 5749a3bf8d25a482a167511800c95de81f0801dc Mon Sep 17 00:00:00 2001 From: luohui1 <3053763193@qq.com> Date: Sat, 3 Oct 2026 02:53:06 -0700 Subject: [PATCH 1/2] feat: add Windows WinFsp services and native tray preview --- .github/workflows/ci.yml | 11 +- .gitignore | 1 + README.md | 8 +- cmd/codexfold-tray/assets/app.manifest | 17 + .../assets/codexfold-attention.ico | Bin 0 -> 14651 bytes cmd/codexfold-tray/assets/codexfold.ico | Bin 0 -> 10260 bytes cmd/codexfold-tray/assets/generate.go | 100 +++ cmd/codexfold-tray/main_other.go | 7 + cmd/codexfold-tray/main_windows.go | 61 ++ cmd/codexfold-tray/resources_windows_test.go | 32 + cmd/codexfold-tray/rsrc_windows_amd64.syso | Bin 0 -> 27332 bytes cmd/codexfold-tray/rsrc_windows_arm64.syso | Bin 0 -> 27332 bytes docs/windows-preview.md | 275 ++++++++ go.mod | 5 +- go.sum | 8 + internal/cli/fs.go | 67 +- internal/cli/fs_enroll.go | 90 ++- internal/cli/fs_enroll_alias.go | 52 ++ internal/cli/fs_enroll_alias_windows_test.go | 66 ++ internal/cli/fs_enroll_child_other.go | 7 + internal/cli/fs_enroll_child_windows.go | 13 + internal/cli/fs_enroll_heartbeat_test.go | 124 ++++ internal/cli/fs_enroll_install_windows.go | 394 +++++++++++ internal/cli/fs_enroll_run.go | 157 +++++ internal/cli/fs_enroll_run_test.go | 329 +++++++++ internal/cli/fs_enroll_service_other.go | 7 + internal/cli/fs_enroll_service_windows.go | 164 +++++ .../cli/fs_enroll_service_windows_test.go | 121 ++++ internal/cli/fs_enroll_writer.go | 8 + internal/cli/fs_enroll_writer_windows.go | 34 +- internal/cli/fs_enroll_writer_windows_test.go | 138 ++++ internal/cli/fs_enrollment_reclaim_test.go | 5 +- internal/cli/fs_managed_registry.go | 3 +- internal/cli/fs_managed_reload_wake_test.go | 32 + internal/cli/fs_namespace.go | 2 +- internal/cli/fs_namespace_activation_test.go | 33 +- internal/cli/fs_namespace_readiness.go | 12 +- internal/cli/fs_service.go | 23 +- internal/cli/fs_service_fskit_other.go | 2 +- internal/cli/fs_service_live_update_other.go | 7 + .../cli/fs_service_live_update_windows.go | 100 +++ internal/cli/fs_service_runtime_windows.go | 103 ++- internal/cli/fs_status_other.go | 13 + internal/cli/fs_status_windows.go | 59 ++ internal/cli/fs_test.go | 9 +- internal/cli/fs_windows_core.go | 125 ++++ internal/cli/fs_windows_core_other.go | 20 + internal/cli/fs_windows_core_test.go | 47 ++ internal/dirsync/sync_test.go | 22 + internal/dirsync/sync_unix.go | 8 + internal/dirsync/sync_windows.go | 25 + internal/enroll/control.go | 58 +- internal/enroll/control_read_other.go | 9 + internal/enroll/control_read_windows.go | 44 ++ internal/enroll/control_windows_test.go | 33 + internal/fskitstatus/status.go | 4 +- internal/mountfs/drive_mount_other.go | 7 + internal/mountfs/drive_mount_windows.go | 68 ++ internal/mountfs/drive_mount_windows_test.go | 68 ++ internal/mountfs/filesystem.go | 4 +- internal/mountfs/fuse_errno_unix.go | 7 + internal/mountfs/fuse_errno_windows.go | 59 ++ internal/mountfs/fuse_errno_windows_test.go | 31 + internal/mountfs/host.go | 72 +- internal/mountfs/host_cgofuse.go | 210 ++++-- internal/mountfs/host_platform_windows.go | 8 +- internal/mountfs/host_safety_test.go | 10 + internal/mountfs/host_safety_windows_test.go | 45 ++ internal/mountfs/native_append.go | 4 +- internal/mountfs/native_open_unix.go | 9 + internal/mountfs/native_open_windows.go | 37 + internal/mountfs/windows_core_backend.go | 105 +++ internal/mountfs/windows_core_host.go | 439 ++++++++++++ internal/mountfs/windows_core_rpc.go | 203 ++++++ internal/mountfs/windows_core_test.go | 146 ++++ internal/mountfs/windows_volume_prefix.go | 19 + internal/pack/accounting.go | 88 +++ internal/pack/accounting_test.go | 31 + internal/pack/build.go | 3 +- internal/pack/recovery.go | 7 +- internal/service/binary_update.go | 4 +- internal/service/build_status.go | 16 + internal/service/mount_probe_windows.go | 61 +- internal/service/mount_probe_windows_test.go | 40 ++ internal/service/service_test.go | 7 +- internal/service/windows.go | 51 +- internal/service/windows_core_image.go | 67 ++ internal/service/windows_core_image_test.go | 98 +++ internal/service/windows_core_offline.go | 76 +++ internal/service/windows_enrollment.go | 59 ++ internal/service/windows_enrollment_test.go | 55 ++ internal/sessionns/activation.go | 10 +- internal/sessionns/link_unix.go | 8 + internal/sessionns/link_windows.go | 67 ++ internal/sessionns/routing_guard.go | 60 +- .../sessionns/routing_guard_windows_test.go | 55 ++ internal/storage/gc.go | 4 +- internal/tray/accounting.go | 96 +++ internal/tray/accounting_test.go | 70 ++ internal/tray/dashboard.html | 580 ++++++++++++++++ internal/tray/diagnostics.go | 42 ++ internal/tray/diagnostics_windows.go | 128 ++++ internal/tray/files_other.go | 8 + internal/tray/files_windows.go | 34 + internal/tray/history.go | 272 ++++++++ internal/tray/history_test.go | 108 +++ internal/tray/logs.go | 42 ++ internal/tray/policy.go | 172 +++++ internal/tray/policy_test.go | 104 +++ internal/tray/status.go | 316 +++++++++ internal/tray/status_test.go | 99 +++ internal/tray/testdata/windows-smoke.cjs | 231 +++++++ internal/tray/window_windows.go | 642 ++++++++++++++++++ internal/tray/window_windows_test.go | 31 + internal/vfs/initial_publish.go | 2 +- internal/vfs/initial_publish_windows_test.go | 99 +++ internal/vfs/native_retirement.go | 4 +- internal/vfs/session.go | 4 +- internal/vfs/session_deletion_lease_other.go | 9 + .../vfs/session_deletion_lease_windows.go | 11 + internal/vfs/session_deletion_object_other.go | 2 +- .../vfs/session_deletion_object_windows.go | 111 +++ .../session_deletion_object_windows_test.go | 82 +++ internal/vfs/session_deletion_purge.go | 30 +- internal/vfs/session_deletion_rename_other.go | 2 +- .../vfs/session_deletion_rename_windows.go | 86 +++ .../session_deletion_rename_windows_test.go | 47 ++ internal/vfs/session_deletion_safety.go | 2 +- internal/vfs/writer_lease_guard.go | 2 +- internal/vfs/writer_lock_unix.go | 13 + internal/vfs/writer_lock_windows.go | 101 ++- scripts/enable-windows-enrollment-service.ps1 | 33 + scripts/enable-windows-enrollment-worker.ps1 | 7 + scripts/enable-windows-local.ps1 | 71 ++ scripts/launch-windows-local.ps1 | 53 ++ scripts/restore-windows-local.ps1 | 29 + scripts/stop-windows-test.ps1 | 34 + scripts/test-windows-use.ps1 | 172 +++++ scripts/upgrade-windows-core.ps1 | 63 ++ scripts/windows-client-smoke.cjs | 124 ++++ scripts/windows-fixture.go | 98 +++ scripts/windows-full-fixture.go | 278 ++++++++ scripts/windows-local-common.ps1 | 82 +++ scripts/windows-local-fixture.go | 204 ++++++ scripts/windows-model-smoke.cjs | 149 ++++ ...3\345\274\217\344\275\277\347\224\250.cmd" | 4 + ...6\351\200\232\345\255\230\345\202\250.cmd" | 4 + 147 files changed, 10307 insertions(+), 192 deletions(-) create mode 100644 cmd/codexfold-tray/assets/app.manifest create mode 100644 cmd/codexfold-tray/assets/codexfold-attention.ico create mode 100644 cmd/codexfold-tray/assets/codexfold.ico create mode 100644 cmd/codexfold-tray/assets/generate.go create mode 100644 cmd/codexfold-tray/main_other.go create mode 100644 cmd/codexfold-tray/main_windows.go create mode 100644 cmd/codexfold-tray/resources_windows_test.go create mode 100644 cmd/codexfold-tray/rsrc_windows_amd64.syso create mode 100644 cmd/codexfold-tray/rsrc_windows_arm64.syso create mode 100644 docs/windows-preview.md create mode 100644 internal/cli/fs_enroll_alias.go create mode 100644 internal/cli/fs_enroll_alias_windows_test.go create mode 100644 internal/cli/fs_enroll_child_other.go create mode 100644 internal/cli/fs_enroll_child_windows.go create mode 100644 internal/cli/fs_enroll_heartbeat_test.go create mode 100644 internal/cli/fs_enroll_install_windows.go create mode 100644 internal/cli/fs_enroll_run.go create mode 100644 internal/cli/fs_enroll_run_test.go create mode 100644 internal/cli/fs_enroll_service_other.go create mode 100644 internal/cli/fs_enroll_service_windows.go create mode 100644 internal/cli/fs_enroll_service_windows_test.go create mode 100644 internal/cli/fs_enroll_writer_windows_test.go create mode 100644 internal/cli/fs_managed_reload_wake_test.go create mode 100644 internal/cli/fs_service_live_update_other.go create mode 100644 internal/cli/fs_service_live_update_windows.go create mode 100644 internal/cli/fs_status_other.go create mode 100644 internal/cli/fs_status_windows.go create mode 100644 internal/cli/fs_windows_core.go create mode 100644 internal/cli/fs_windows_core_other.go create mode 100644 internal/cli/fs_windows_core_test.go create mode 100644 internal/dirsync/sync_test.go create mode 100644 internal/dirsync/sync_unix.go create mode 100644 internal/dirsync/sync_windows.go create mode 100644 internal/enroll/control_read_other.go create mode 100644 internal/enroll/control_read_windows.go create mode 100644 internal/enroll/control_windows_test.go create mode 100644 internal/mountfs/drive_mount_other.go create mode 100644 internal/mountfs/drive_mount_windows.go create mode 100644 internal/mountfs/drive_mount_windows_test.go create mode 100644 internal/mountfs/fuse_errno_unix.go create mode 100644 internal/mountfs/fuse_errno_windows.go create mode 100644 internal/mountfs/fuse_errno_windows_test.go create mode 100644 internal/mountfs/host_safety_windows_test.go create mode 100644 internal/mountfs/native_open_unix.go create mode 100644 internal/mountfs/native_open_windows.go create mode 100644 internal/mountfs/windows_core_backend.go create mode 100644 internal/mountfs/windows_core_host.go create mode 100644 internal/mountfs/windows_core_rpc.go create mode 100644 internal/mountfs/windows_core_test.go create mode 100644 internal/mountfs/windows_volume_prefix.go create mode 100644 internal/pack/accounting.go create mode 100644 internal/pack/accounting_test.go create mode 100644 internal/service/mount_probe_windows_test.go create mode 100644 internal/service/windows_core_image.go create mode 100644 internal/service/windows_core_image_test.go create mode 100644 internal/service/windows_core_offline.go create mode 100644 internal/service/windows_enrollment.go create mode 100644 internal/service/windows_enrollment_test.go create mode 100644 internal/sessionns/link_unix.go create mode 100644 internal/sessionns/link_windows.go create mode 100644 internal/sessionns/routing_guard_windows_test.go create mode 100644 internal/tray/accounting.go create mode 100644 internal/tray/accounting_test.go create mode 100644 internal/tray/dashboard.html create mode 100644 internal/tray/diagnostics.go create mode 100644 internal/tray/diagnostics_windows.go create mode 100644 internal/tray/files_other.go create mode 100644 internal/tray/files_windows.go create mode 100644 internal/tray/history.go create mode 100644 internal/tray/history_test.go create mode 100644 internal/tray/logs.go create mode 100644 internal/tray/policy.go create mode 100644 internal/tray/policy_test.go create mode 100644 internal/tray/status.go create mode 100644 internal/tray/status_test.go create mode 100644 internal/tray/testdata/windows-smoke.cjs create mode 100644 internal/tray/window_windows.go create mode 100644 internal/tray/window_windows_test.go create mode 100644 internal/vfs/initial_publish_windows_test.go create mode 100644 internal/vfs/session_deletion_lease_other.go create mode 100644 internal/vfs/session_deletion_lease_windows.go create mode 100644 internal/vfs/session_deletion_object_windows.go create mode 100644 internal/vfs/session_deletion_object_windows_test.go create mode 100644 internal/vfs/session_deletion_rename_windows.go create mode 100644 internal/vfs/session_deletion_rename_windows_test.go create mode 100644 scripts/enable-windows-enrollment-service.ps1 create mode 100644 scripts/enable-windows-enrollment-worker.ps1 create mode 100644 scripts/enable-windows-local.ps1 create mode 100644 scripts/launch-windows-local.ps1 create mode 100644 scripts/restore-windows-local.ps1 create mode 100644 scripts/stop-windows-test.ps1 create mode 100644 scripts/test-windows-use.ps1 create mode 100644 scripts/upgrade-windows-core.ps1 create mode 100644 scripts/windows-client-smoke.cjs create mode 100644 scripts/windows-fixture.go create mode 100644 scripts/windows-full-fixture.go create mode 100644 scripts/windows-local-common.ps1 create mode 100644 scripts/windows-local-fixture.go create mode 100644 scripts/windows-model-smoke.cjs create mode 100644 "\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" create mode 100644 "\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfae646..d5970d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: args: check windows-compile: - name: windows-compile + name: windows-preview-checks runs-on: windows-latest steps: - uses: actions/checkout@v7.0.1 @@ -80,7 +80,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 -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 0000000000000000000000000000000000000000..a27be91bdc7fcf589870b161011e1bcc9b95a901 GIT binary patch literal 14651 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 m1~_@PP?E!C{= 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 0000000000000000000000000000000000000000..c2ec89f32edfc907d8f165e9d4437298a08c9239 GIT binary patch literal 27332 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 literal 0 HcmV?d00001 diff --git a/cmd/codexfold-tray/rsrc_windows_arm64.syso b/cmd/codexfold-tray/rsrc_windows_arm64.syso new file mode 100644 index 0000000000000000000000000000000000000000..09272d4ca61ac931d3e9343744b6681e913f1a97 GIT binary patch literal 27332 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/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

未连接

+
+
+ + + +
+
+
+
+
+
+
压缩减少
—
+
压缩比例
—
+
会话数据
—
+
磁盘占用
—
+
+
+

自动折叠

未连接
+

正在读取后台状态…

+
+ + + + +
+

+ +
+
待回收占用—
+
当前净节省—
+
+
+
+
+

空间变化

+
+ + + + +
+
+
原始大小当前占用
+
+ + + + + +
暂无记录
+
+
24 小时前现在
+

+

+
+
+
+

读写速度

+
读取 —写入 —
+
+
+ + + + +
暂无记录
+
+
10 分钟前现在
+
+

故障记录

暂无持续故障记录

+
+

运行状态

托管会话:—
+
+
文件服务未连接
+
会话管理未连接
+
+
技术详情
+
+
+
+ +

诊断

+

+

+  

诊断包含运行状态、设置和汇总记录,不含会话内容。

+
+
+ + + 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% From 0361d19dbcd2ae61adcce81044b92d2ff3b1e79f Mon Sep 17 00:00:00 2001 From: luohui1 <3053763193@qq.com> Date: Sat, 3 Oct 2026 02:58:03 -0700 Subject: [PATCH 2/2] ci: use native Go Windows loader and check deletion adapters --- .github/workflows/ci.yml | 5 ++++- docs/windows-preview.md | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5970d1..fd343b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: 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 @@ -81,7 +84,7 @@ jobs: - 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 -run 'TestMonitor|TestPolicy|TestDashboard|TestHistory|TestIncidents|TestUnreadable|TestPeriodicEnrollment|TestEnrollmentWorker|TestPersistentEnrollment|TestWindows|TestFuse|TestPublishedLogicalBytes' -count=1 -timeout=120s + 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 diff --git a/docs/windows-preview.md b/docs/windows-preview.md index a9eb909..266ea07 100644 --- a/docs/windows-preview.md +++ b/docs/windows-preview.md @@ -5,11 +5,16 @@ 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: