diff --git a/.github/workflows/wefto-ci.yml b/.github/workflows/wefto-ci.yml index 435b416933..c34d417fec 100644 --- a/.github/workflows/wefto-ci.yml +++ b/.github/workflows/wefto-ci.yml @@ -56,6 +56,12 @@ jobs: node-version-file: magpie/.node-version package-manager-cache: false + - name: Resolve the macOS temporary directory + if: runner.os == 'macOS' + run: | + canonical_temp="$(cd "${TMPDIR:-/tmp}" && pwd -P)" + printf 'TMPDIR=%s\nTMP=%s\nTEMP=%s\n' "$canonical_temp" "$canonical_temp" "$canonical_temp" >> "$GITHUB_ENV" + - name: Configure the Windows race compiler if: runner.os == 'Windows' shell: pwsh diff --git a/magpie/docs/product-regression.md b/magpie/docs/product-regression.md index b09095f778..8a2b0afa38 100644 --- a/magpie/docs/product-regression.md +++ b/magpie/docs/product-regression.md @@ -95,6 +95,8 @@ Windows 在 PowerShell 中可用 `npm.cmd`,不要求改变脚本执行策略 三个独立原生 job 分别使用 Ubuntu 24.04 x64、Windows 2025 x64、macOS 15 arm64。每个 job 从 `go.mod`、`.node-version`、`package-lock.json` 准备工具链、Go modules 和 Playwright 浏览器;Windows 显式检查 MinGW-w64 gcc,Linux 在准备阶段安装浏览器系统库。当前不持久化依赖缓存,避免混用不同平台或镜像版本的缓存。 +macOS 在运行入口检查和产品回归前,将系统临时目录解析为真实路径并固定 `TMPDIR`、`TMP`、`TEMP`。系统常见的 `/var` → `/private/var` 别名因此不会混入测试布局;产品原有的符号链接/重解析点路径保护仍生效。 + 准备完成后运行 `npm run test:entry`,再调用同一条完整入口 `npm test -- --output=dist/regression/ci`;构建、27 包 race、27 包 vet、Chromium 和 WebKit 任一步失败都会阻断该 job。最终还检查 summary 的 `passed`、`complete: true`、干净 tracked 工作树和本次 GitHub 候选 SHA;不使用诊断模式或允许失败的矩阵项。 每个平台始终尝试上传 `magpie/dist/regression/` 为 `wefto-regression-<平台>-<运行次数>` artifact,保留 14 天,包含本次二进制、summary、日志和截图。若在依赖准备阶段即失败,还没有回归目录,错误可从 Actions 步骤日志查看。工作流只获得仓库内容读取权限,不发布 Release,不需要模型、供应商或宿主凭证。 diff --git a/magpie/docs/subsystems/wefto-service.md b/magpie/docs/subsystems/wefto-service.md index 308ba4183a..2efe04ca2c 100644 --- a/magpie/docs/subsystems/wefto-service.md +++ b/magpie/docs/subsystems/wefto-service.md @@ -8,6 +8,10 @@ [`routercontrol`](../../internal/routercontrol/control.go) implements background start, status, stop, restart and explicit Windows per-user autostart. `state/router.lock/instance.json` records exact executable, PID, process stamp, instance identity and address. Control validates those against the authenticated instance; another executable owning the layout is not adopted. Only recognized exited/reused-process locks may be archived. +Executable comparisons resolve both existing absolute paths before comparing them. This recognizes Windows 8.3 aliases and macOS symlinked temporary roots returned by OS process queries. A missing or unreadable path does not establish identity; a distinct hard-link path remains another installation. PID creation stamps and authenticated instance checks still apply. + +Native Windows supports external process identity and per-user autostart. Linux provides external PID identity for regression runners. Other platforms currently identify only the current foreground process: an external process remains unknown, and its lock must not be archived or its process controlled. The cleanup regression exercises that refusal on those platforms rather than spawning a child that cannot be identified. + The ordinary Windows development entry is [`scripts/start-project.ps1`](../../../scripts/start-project.ps1), also called by `start.cmd`. It uses `magpie/dist/wefto.exe` and ordinary Windows roots, and reports the actual management URL. `-CheckOnly -NoBrowser` is read-only. Its check accepts a whole fresh or legacy layout, never a mixture. Upgrades stop the original executable before switching builds; see the [upgrade guide](../wefto-upgrade.md). ## Management and model requests diff --git a/magpie/internal/routercontrol/cleanup_test.go b/magpie/internal/routercontrol/cleanup_test.go index e8755f42ca..d369c0049e 100644 --- a/magpie/internal/routercontrol/cleanup_test.go +++ b/magpie/internal/routercontrol/cleanup_test.go @@ -1,16 +1,47 @@ package routercontrol import ( + "bytes" "encoding/json" "github.com/yetone/magpie/internal/dirs" "github.com/yetone/magpie/internal/proc" "os" "path/filepath" + "runtime" "testing" "time" ) func TestStartupFailureCleanupOwnsExactChild(t *testing.T) { + if runtime.GOOS != "windows" && runtime.GOOS != "linux" { + t.Run("unsupported-process-identity-retains-unknown-lock", func(t *testing.T) { + stamp, executable, err := processIdentity(os.Getppid()) + if err == nil || stamp != "" || executable != "" { + t.Fatal("unsupported external process identity did not remain unknown", err) + } + layout := startupRecordFixture(t, true) + record, err := ReadRecord(layout) + if err != nil { + t.Fatal(err) + } + record.PID = os.Getppid() + before, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(RecordFile(layout), before, 0o600); err != nil { + t.Fatal(err) + } + if err := archiveExitedLock(layout); err == nil { + t.Fatal("unsupported external process control archived an unknown lock") + } + after, err := os.ReadFile(RecordFile(layout)) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("unknown process record was changed", err) + } + }) + return + } if os.Args[len(os.Args)-1] == "router-control-cleanup-child" { for { time.Sleep(time.Minute) diff --git a/magpie/internal/routercontrol/control.go b/magpie/internal/routercontrol/control.go index 4383f20a32..d2012cd3a0 100644 --- a/magpie/internal/routercontrol/control.go +++ b/magpie/internal/routercontrol/control.go @@ -147,6 +147,22 @@ func Verify(r Record) error { return nil } func samePath(a, b string) bool { + if !filepath.IsAbs(a) || !filepath.IsAbs(b) { + return false + } + // OS process queries can retain Windows 8.3 names or a symlinked macOS + // temporary root, while LaunchRecord stores the resolved executable path. + // Compare resolved paths, not file IDs: another hard-linked installation + // is still another executable path. Unavailable paths remain unknown. + var err error + a, err = filepath.EvalSymlinks(a) + if err != nil { + return false + } + b, err = filepath.EvalSymlinks(b) + if err != nil { + return false + } if runtime.GOOS == "windows" { return strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) } diff --git a/magpie/internal/routercontrol/executable_test.go b/magpie/internal/routercontrol/executable_test.go new file mode 100644 index 0000000000..1e50d6c3ed --- /dev/null +++ b/magpie/internal/routercontrol/executable_test.go @@ -0,0 +1,47 @@ +package routercontrol + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExecutableIdentityResolvesAliasesAndRejectsOtherPaths(t *testing.T) { + stamp, executable, err := processIdentity(os.Getpid()) + if err != nil { + t.Fatal(err) + } + record := Record{PID: os.Getpid(), Executable: executable, ProcessStamp: stamp} + t.Run("symlink", func(t *testing.T) { + alias := filepath.Join(t.TempDir(), "service-alias") + if err := os.Symlink(executable, alias); err != nil { + t.Skip("symbolic links unavailable: ", err) + } + record.Executable = alias + if err := Verify(record); err != nil { + t.Fatalf("the same executable's resolved alias was rejected: %v", err) + } + }) + t.Run("hard-link-is-another-path", func(t *testing.T) { + // A running Windows image cannot be unlinked until the process exits. + // Use closed fixture files to exercise the same path-ownership rule. + root := t.TempDir() + owned := filepath.Join(root, "owned.exe") + if err := os.WriteFile(owned, []byte("executable identity fixture"), 0o600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "another-installation.exe") + if err := os.Link(owned, alias); err != nil { + t.Skip("hard links unavailable: ", err) + } + if samePath(owned, alias) { + t.Fatal("another executable path was adopted merely because it shares a file ID") + } + }) + t.Run("missing-path-is-unknown", func(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing-executable") + if samePath(missing, missing) { + t.Fatal("unavailable executable paths were treated as the same identity") + } + }) +} diff --git a/magpie/internal/routercontrol/executable_windows_test.go b/magpie/internal/routercontrol/executable_windows_test.go new file mode 100644 index 0000000000..95eb90f9f4 --- /dev/null +++ b/magpie/internal/routercontrol/executable_windows_test.go @@ -0,0 +1,58 @@ +package routercontrol + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/dirs" + "github.com/yetone/magpie/internal/proc" + "golang.org/x/sys/windows" +) + +func TestLaunchRecordFromWindowsShortExecutablePath(t *testing.T) { + const child = "routercontrol-short-executable-child" + if os.Args[len(os.Args)-1] == child { + layout := dirs.Layout{State: t.TempDir()} + if err := os.Mkdir(layout.ServiceLock(), 0o700); err != nil { + t.Fatal(err) + } + record, err := LaunchRecord(layout, "test", "short-path-instance", "127.0.0.1:13145", false) + if err != nil { + t.Fatalf("launching the same executable through its Windows short path: %v", err) + } + if err := Verify(record); err != nil { + t.Fatal(err) + } + return + } + + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + long, err := filepath.EvalSymlinks(executable) + if err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(long) + if err != nil { + t.Fatal(err) + } + buffer := make([]uint16, 32768) + n, err := windows.GetShortPathName(pointer, &buffer[0], uint32(len(buffer))) + if err != nil || n == 0 || n >= uint32(len(buffer)) { + t.Fatalf("getting the executable's Windows short path: %d, %v", n, err) + } + short := windows.UTF16ToString(buffer[:n]) + if strings.EqualFold(long, short) { + t.Skip("the executable's volume does not provide a distinct 8.3 alias") + } + t.Logf("launching %s through %s", long, short) + command := proc.Command(short, "-test.run=^TestLaunchRecordFromWindowsShortExecutablePath$", "--", child) + hide(command) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("short-path service identity failed: %v\n%s", err, output) + } +}