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