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");