From 580e517d03f2cf076d61faeb68b8652f5b099fb1 Mon Sep 17 00:00:00 2001 From: Patodo Date: Thu, 17 Sep 2026 19:21:32 +0800 Subject: [PATCH] fix(windows): run the daemon with the user's own privileges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows user task registered with an S4U logon type. For an administrator account S4U issues an unfiltered (elevated) token, so the daemon — and the Server it spawns — ran elevated, and the control pipe it created carried a high mandatory label. Windows then lets a non-elevated client read that pipe but never write to it, so every command that talks to the daemon failed: $ localapp server status {"error":{"code":"ipc_transport_failed","message":"..."}} Register the task with InteractiveToken instead, which reuses the token from the owner's own logon session and runs the daemon at that user's integrity level. RunLevel LeastPrivilege stays on the principal: measured on Windows 11, this definition with HighestAvailable yields a High token, and S4U yields High whether or not RunLevel is present. Verified on Windows 11 26200 with an administrator account: after the task re-registered and started the daemon, the control pipe accepts read+write from a non-elevated client, and `localapp server status`, `server logs` and the Server /health endpoint all succeed from a normal terminal. Trade-off: the task can no longer start the daemon without an interactive logon session, which is what S4U was introduced for. Session-less Windows machines should use the foreground `localapp server run` instead, which docs/local-runtime.md now states. --- docs/local-runtime.md | 2 ++ .../localapp/src/service/windows-user-task.ts | 16 ++++++++++------ packages/localapp/tests/service-manager.test.ts | 8 ++++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/local-runtime.md b/docs/local-runtime.md index 28de9c6..65365eb 100644 --- a/docs/local-runtime.md +++ b/docs/local-runtime.md @@ -4,6 +4,8 @@ LocalApp 只有一个 Server 实现。它可以作为开发机上的本地 Serve 用户只安装一个 `@patodo/localapp` npm 包,安装后的可执行命令仍为 `localapp`。个人电脑上运行的是当前操作系统用户的常驻 daemon;容器、NAS、局域网主机或公开服务器使用同一包的前台 Server 模式。项目不再提供 Tauri、托盘、Desktop 窗口或单独的 Rust CLI。 +Windows 上的 daemon 是当前用户的计划任务:登录时启动,并以该用户自身的普通权限运行,不继承提权令牌。因此它需要有交互登录会话——只通过 SSH 使用、没有桌面会话的机器请改用 `localapp server run` 前台模式。首次注册计划任务可能需要管理员终端,之后 `status`、`stop`、`dev` 等命令在普通终端即可操作 daemon。 + ```bash npm install --global @patodo/localapp localapp server # 等同于 localapp server start,注册并启动用户 daemon diff --git a/packages/localapp/src/service/windows-user-task.ts b/packages/localapp/src/service/windows-user-task.ts index f501e89..b1169e7 100644 --- a/packages/localapp/src/service/windows-user-task.ts +++ b/packages/localapp/src/service/windows-user-task.ts @@ -71,11 +71,15 @@ function isNotRunningTask(stderr: string): boolean { } /** - * The daemon must also run while the owning user has no interactive logon - * session (SSH-only or headless Windows), so the task registers with an S4U - * logon type instead of the schtasks /TR default, which is interactive-only - * and silently refuses to start. ExecutionTimeLimit PT0S removes the default - * 72-hour kill. + * InteractiveToken reuses the token from the owning user's own logon session, + * so the daemon runs at the user's integrity level. S4U would also allow a + * start with no interactive logon session, but it grants an administrator + * account an unfiltered (elevated) token, and the control pipe created by an + * elevated daemon carries a high mandatory label: Windows then lets a + * non-elevated client read the pipe but never write to it, so every CLI command + * that talks to the daemon fails with an access-denied transport error. + * LeastPrivilege is what keeps the reused token un-elevated and must stay with + * the principal. ExecutionTimeLimit PT0S removes the default 72-hour kill. */ function taskDefinition(nodePath: string, launcherPath: string): Buffer { // Omitting UserId registers the principal as the creating user, so the @@ -92,7 +96,7 @@ function taskDefinition(nodePath: string, launcherPath: string): Buffer { - S4U + InteractiveToken LeastPrivilege diff --git a/packages/localapp/tests/service-manager.test.ts b/packages/localapp/tests/service-manager.test.ts index d165bd7..ce3396d 100644 --- a/packages/localapp/tests/service-manager.test.ts +++ b/packages/localapp/tests/service-manager.test.ts @@ -99,7 +99,7 @@ describe("per-user service manager", () => { await expect(fs.stat(manager.registrationPath)).resolves.toMatchObject({ isFile: expect.any(Function) }); }); - it("creates an S4U current-user Windows task that survives a logged-off console", async () => { + it("creates a least-privilege interactive current-user Windows task", async () => { const fixture = await serviceFixture("windows & task (one)!"); const commands: ServiceCommandInvocation[] = []; const manager = createServiceManager({ @@ -117,7 +117,11 @@ describe("per-user service manager", () => { expect(create?.args).toContain("/XML"); expect(create?.args).not.toContain("SYSTEM"); const definition = await fs.readFile(create?.args[create.args.indexOf("/XML") + 1] ?? "", "utf16le"); - expect(definition).toContain("S4U"); + // Break caught: S4U hands an administrator account an elevated token, whose + // control pipe a non-elevated client can read but never write, so every + // daemon command failed with an access-denied transport error. + expect(definition).toContain("InteractiveToken"); + expect(definition).not.toContain("S4U"); expect(definition).toContain("LeastPrivilege"); expect(definition).toContain("PT0S"); expect(definition).toContain("C:\\Program Files\\Node & Runtime\\node.exe");