@nowcrew/daemon 0.6.97 → 0.6.98-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ import { posix, win32 } from "node:path";
3
3
  import { buildServiceSpec, serviceStatus, windowsCommandArg, } from "./computer-service.js";
4
4
  import { daemonGlobalInstallation } from "./daemon-installation.js";
5
5
  import { inspectLegacyServiceInstallationDetails } from "./legacy-service-installation.js";
6
+ import { powershellCommandArgs } from "./powershell-command.js";
7
+ import { windowsTaskPrincipalFields, windowsTaskPrincipalMatches, windowsTaskPrincipalScript, } from "./windows-task-principal.js";
6
8
  function encoded(value) {
7
9
  return Buffer.from(value, "utf16le").toString("base64");
8
10
  }
@@ -142,12 +144,11 @@ async function discoverWindows(input, identity) {
142
144
  "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));",
143
145
  "$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
144
146
  "if($tasks.Count -ne 1){[pscustomobject]@{count=$tasks.Count}|ConvertTo-Json -Compress;exit 0};",
145
- "$task=$tasks[0];$actions=@($task.Actions);$me=[Security.Principal.WindowsIdentity]::GetCurrent().Name;",
146
- "[pscustomobject]@{count=1;state=[int]$task.State;actionCount=$actions.Count;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};principalMatches=([String]::Equals($task.Principal.UserId,$me,[StringComparison]::OrdinalIgnoreCase)-and [int]$task.Principal.LogonType -eq 3 -and [int]$task.Principal.RunLevel -eq 0)}|ConvertTo-Json -Compress;",
147
+ "$task=$tasks[0];$actions=@($task.Actions);",
148
+ windowsTaskPrincipalScript,
149
+ `[pscustomobject]@{count=1;state=[int]$task.State;actionCount=$actions.Count;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};${windowsTaskPrincipalFields}}|ConvertTo-Json -Compress;`,
147
150
  ].join("");
148
- const result = await input.runner("powershell.exe", [
149
- "-NoProfile", "-NonInteractive", "-Command", script, encoded(identity.id),
150
- ]);
151
+ const result = await input.runner("powershell.exe", powershellCommandArgs(script, [encoded(identity.id)]));
151
152
  if (result.exitCode !== 0) {
152
153
  throw new Error(`Unable to inspect legacy scheduled task: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
153
154
  }
@@ -165,7 +166,7 @@ async function discoverWindows(input, identity) {
165
166
  : null;
166
167
  if (definition.count !== 1
167
168
  || definition.actionCount !== 1
168
- || definition.principalMatches !== true
169
+ || !windowsTaskPrincipalMatches(definition)
169
170
  || !win32.isAbsolute(definition.execute ?? "")
170
171
  || match === null
171
172
  || match[2] !== input.desired.profile
@@ -6,7 +6,9 @@ import { promisify } from "node:util";
6
6
  import { dirname, resolve } from "node:path";
7
7
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
8
8
  import { daemonRegistryCandidates } from "./daemon-registry.js";
9
+ import { windowsTaskPrincipalFields, windowsTaskPrincipalMatches, windowsTaskPrincipalScript, } from "./windows-task-principal.js";
9
10
  import { npmInvocation } from "./npm-command.js";
11
+ import { powershellCommandArgs } from "./powershell-command.js";
10
12
  const execFileAsync = promisify(execFile);
11
13
  const WINDOWS_STOP_TIMEOUT_MS = 5_000;
12
14
  const WINDOWS_STOP_POLL_MS = 100;
@@ -195,12 +197,11 @@ async function windowsTaskDefinitionMatches(spec, runner) {
195
197
  "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));",
196
198
  "$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
197
199
  "if($tasks.Count -ne 1){[pscustomobject]@{count=$tasks.Count}|ConvertTo-Json -Compress;exit 0};",
198
- "$task=$tasks[0];$actions=@($task.Actions);$me=[Security.Principal.WindowsIdentity]::GetCurrent().Name;",
199
- "[pscustomobject]@{count=1;actionCount=$actions.Count;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};principalMatches=([String]::Equals($task.Principal.UserId,$me,[StringComparison]::OrdinalIgnoreCase)-and [int]$task.Principal.LogonType -eq 3 -and [int]$task.Principal.RunLevel -eq 0)}|ConvertTo-Json -Compress;",
200
+ "$task=$tasks[0];$actions=@($task.Actions);",
201
+ windowsTaskPrincipalScript,
202
+ `[pscustomobject]@{count=1;actionCount=$actions.Count;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};${windowsTaskPrincipalFields}}|ConvertTo-Json -Compress;`,
200
203
  ].join("");
201
- const result = await runner("powershell.exe", [
202
- "-NoProfile", "-NonInteractive", "-Command", script, encodedName,
203
- ]);
204
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [encodedName]));
204
205
  if (result.exitCode !== 0) {
205
206
  throw new Error(`Unable to inspect scheduled task definition: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
206
207
  }
@@ -214,7 +215,7 @@ async function windowsTaskDefinitionMatches(spec, runner) {
214
215
  const expectedArguments = spec.daemonCommand.slice(1).map(windowsCommandArg).join(" ");
215
216
  return definition.count === 1
216
217
  && definition.actionCount === 1
217
- && definition.principalMatches === true
218
+ && windowsTaskPrincipalMatches(definition)
218
219
  && sameWindowsPath(definition.execute, spec.daemonCommand[0])
219
220
  && definition.arguments === expectedArguments;
220
221
  }
@@ -226,9 +227,7 @@ async function windowsTaskState(spec, runner) {
226
227
  "$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
227
228
  "if($tasks.Count -eq 0){'missing'}elseif($tasks.Count -eq 1){'state:'+([int]$tasks[0].State)}else{throw 'duplicate scheduled tasks'}",
228
229
  ].join("");
229
- const result = await runner("powershell.exe", [
230
- "-NoProfile", "-NonInteractive", "-Command", script, encodedName,
231
- ]);
230
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [encodedName]));
232
231
  if (result.exitCode !== 0) {
233
232
  throw new Error(`Unable to query scheduled task: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
234
233
  }
@@ -279,10 +278,9 @@ export async function installService(spec, runner = systemCommandRunner) {
279
278
  "$principal=New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited;",
280
279
  "Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -ErrorAction Stop|Out-Null;",
281
280
  ].join("");
282
- await checked(runner, "powershell.exe", [
283
- "-NoProfile", "-NonInteractive", "-Command", script,
281
+ await checked(runner, "powershell.exe", powershellCommandArgs(script, [
284
282
  encodedName, encodedExecutable, encodedArguments,
285
- ], "Scheduled task installation");
283
+ ]), "Scheduled task installation");
286
284
  return;
287
285
  }
288
286
  if (spec.descriptorPath === null || spec.descriptor === null)
@@ -418,6 +416,33 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
418
416
  }
419
417
  }
420
418
  const systemDetachedSpawn = (command, args, options) => spawn(command, [...args], options);
419
+ function windowsRestartCommandArgs(spec) {
420
+ if (!spec.runtimeProcessIdentity)
421
+ throw new Error("Windows daemon process identity is required for restart");
422
+ const encodedName = Buffer.from(spec.id, "utf16le").toString("base64");
423
+ const encodedExecutable = Buffer.from(spec.daemonCommand[0], "utf16le").toString("base64");
424
+ const encodedArguments = Buffer.from(spec.daemonCommand.slice(1).map(windowsCommandArg).join(" "), "utf16le").toString("base64");
425
+ const script = [
426
+ "$ErrorActionPreference='Stop';",
427
+ "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[1]));",
428
+ "$exe=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[2]));",
429
+ "$argv=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[3]));",
430
+ "$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};",
431
+ "$actions=@($task[0].Actions);if($actions.Count -ne 1 -or -not [String]::Equals($actions[0].Execute,$exe,[StringComparison]::OrdinalIgnoreCase) -or $actions[0].Arguments -cne $argv){throw 'scheduled task action changed'};",
432
+ "$p=Get-Process -Id ([int]$args[0]) -ErrorAction Stop;",
433
+ "if($p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() -cne $args[4]){throw 'daemon process identity changed'};",
434
+ "$p.Kill();",
435
+ "if(-not $p.WaitForExit(10000)){throw 'daemon exit timeout'};",
436
+ "$deadline=[DateTime]::UtcNow.AddSeconds(10);",
437
+ "do{$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};if([int]$task[0].State -ne 4){break};Start-Sleep -Milliseconds 100}while([DateTime]::UtcNow -lt $deadline);",
438
+ "if([int]$task[0].State -eq 4){throw 'scheduled task stop timeout'};",
439
+ "$actions=@($task[0].Actions);if($actions.Count -ne 1 -or -not [String]::Equals($actions[0].Execute,$exe,[StringComparison]::OrdinalIgnoreCase) -or $actions[0].Arguments -cne $argv){throw 'scheduled task action changed'};",
440
+ "& schtasks.exe /Run /TN $n | Out-Null;if($LASTEXITCODE -ne 0){throw 'scheduled task start failed'};",
441
+ ].join("");
442
+ return powershellCommandArgs(script, [
443
+ String(process.pid), encodedName, encodedExecutable, encodedArguments, spec.runtimeProcessIdentity,
444
+ ]);
445
+ }
421
446
  const RESTART_HELPER_SOURCE = String.raw `
422
447
  const { spawnSync } = require("node:child_process");
423
448
  const spec = JSON.parse(process.argv[1]);
@@ -428,27 +453,7 @@ setTimeout(() => {
428
453
  } else if (spec.platform === "linux") {
429
454
  restarted = spawnSync("systemctl", ["--user", "--no-block", "restart", spec.id], { stdio: "ignore", shell: false });
430
455
  } else {
431
- const encodedName = Buffer.from(spec.id, "utf16le").toString("base64");
432
- const encodedExecutable = Buffer.from(spec.executable, "utf16le").toString("base64");
433
- const encodedArguments = Buffer.from(spec.arguments, "utf16le").toString("base64");
434
- const script = [
435
- "$ErrorActionPreference='Stop';",
436
- "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[1]));",
437
- "$exe=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[2]));",
438
- "$argv=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[3]));",
439
- "$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};",
440
- "$actions=@($task[0].Actions);if($actions.Count -ne 1 -or -not [String]::Equals($actions[0].Execute,$exe,[StringComparison]::OrdinalIgnoreCase) -or $actions[0].Arguments -cne $argv){throw 'scheduled task action changed'};",
441
- "$p=Get-Process -Id ([int]$args[0]) -ErrorAction Stop;",
442
- "if($p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() -cne $args[4]){throw 'daemon process identity changed'};",
443
- "$p.Kill();",
444
- "if(-not $p.WaitForExit(10000)){throw 'daemon exit timeout'};",
445
- "$deadline=[DateTime]::UtcNow.AddSeconds(10);",
446
- "do{$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};if([int]$task[0].State -ne 4){break};Start-Sleep -Milliseconds 100}while([DateTime]::UtcNow -lt $deadline);",
447
- "if([int]$task[0].State -eq 4){throw 'scheduled task stop timeout'};",
448
- "$actions=@($task[0].Actions);if($actions.Count -ne 1 -or -not [String]::Equals($actions[0].Execute,$exe,[StringComparison]::OrdinalIgnoreCase) -or $actions[0].Arguments -cne $argv){throw 'scheduled task action changed'};",
449
- "& schtasks.exe /Run /TN $n | Out-Null;if($LASTEXITCODE -ne 0){throw 'scheduled task start failed'};",
450
- ].join("");
451
- restarted = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, String(spec.daemonPid), encodedName, encodedExecutable, encodedArguments, spec.processIdentity], { stdio: "ignore", shell: false });
456
+ restarted = spawnSync("powershell.exe", spec.powershellArgs, { stdio: "ignore", shell: false });
452
457
  }
453
458
  if (restarted.status !== 0) process.send?.({ type: "restart-failed", status: restarted.status });
454
459
  setTimeout(() => {
@@ -470,15 +475,8 @@ export function scheduleServiceRestart(spec, spawnDetached = systemDetachedSpawn
470
475
  ? { platform: spec.platform, id: spec.id }
471
476
  : {
472
477
  platform: spec.platform,
473
- id: spec.id,
474
- daemonPid: process.pid,
475
- executable: spec.daemonCommand[0],
476
- arguments: spec.daemonCommand.slice(1).map(windowsCommandArg).join(" "),
477
- processIdentity: spec.runtimeProcessIdentity,
478
+ powershellArgs: windowsRestartCommandArgs(spec),
478
479
  };
479
- if (spec.platform === "win32" && !payload.processIdentity) {
480
- throw new Error("Windows daemon process identity is required for restart");
481
- }
482
480
  const child = spawnDetached(process.execPath, ["-e", RESTART_HELPER_SOURCE, JSON.stringify(payload)], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], shell: false });
483
481
  child.unref();
484
482
  let settleFailure;
@@ -627,7 +625,7 @@ export async function windowsProfileIsPrivate(path, runner = systemCommandRunner
627
625
  "$unsafe=$rules|Where-Object { $_.AccessControlType -eq 'Allow' -and $safe -notcontains $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value };",
628
626
  "if($unsafe){'false'}else{'true'}",
629
627
  ].join("");
630
- const result = await runner("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, encodedPath]);
628
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [encodedPath]));
631
629
  if (result.exitCode !== 0)
632
630
  throw new Error(`Profile ACL inspection failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
633
631
  if (result.stdout.trim() === "true")
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { hostname } from "node:os";
3
3
  import { MachineTokenSchema } from "./computer-profile.js";
4
+ import { powershellCommandArgs } from "./powershell-command.js";
4
5
  import { systemCommandRunner, } from "./computer-service.js";
5
6
  const HttpUrlSchema = z.string().url().refine((value) => {
6
7
  const url = new URL(value);
@@ -309,8 +310,10 @@ export function createComputerSetupClient(options) {
309
310
  };
310
311
  }
311
312
  export async function openSetupBrowser(url, platform = process.platform, runner = systemCommandRunner) {
312
- const command = platform === "win32" ? "explorer.exe" : platform === "darwin" ? "open" : "xdg-open";
313
- const args = [url];
313
+ const command = platform === "win32" ? "powershell.exe" : platform === "darwin" ? "open" : "xdg-open";
314
+ const args = platform === "win32"
315
+ ? powershellCommandArgs("Start-Process -FilePath $args[0] -ErrorAction Stop | Out-Null", [url])
316
+ : [url];
314
317
  const result = await runner(command, args);
315
318
  if (result.exitCode !== 0) {
316
319
  throw new Error(`Unable to open the computer setup browser: ${redactSetupSecrets(result.stderr.trim() || `exit ${result.exitCode}`)}`);
@@ -33,6 +33,7 @@ export const SELF_TRAINING_EXECUTION_CAPABILITY = "self_training_execution_v1";
33
33
  export const EXECUTION_LARGE_ATTACHMENTS_CAPABILITY = "execution_attachments_300m_v1";
34
34
  export const PROJECT_SKILL_PROJECTION_CAPABILITY = "project_skill_projection_v2";
35
35
  export const MACHINE_ENVIRONMENT_CAPABILITY = "machine_environment_v1";
36
+ export const MACHINE_HELLO_ACK_CAPABILITY = "machine_hello_ack_v1";
36
37
  export const NATIVE_PROJECT_WORKSPACE_READINESS = Object.freeze({
37
38
  resolverReady: true,
38
39
  nativeRuntimeAdaptersReady: true,
@@ -62,6 +63,7 @@ export const DAEMON_CAPABILITIES = Object.freeze([
62
63
  "agent_ability_runtime_assets_v1",
63
64
  "agent_environments_v1",
64
65
  MACHINE_ENVIRONMENT_CAPABILITY,
66
+ MACHINE_HELLO_ACK_CAPABILITY,
65
67
  ]);
66
68
  /** 能力只按平台过滤,进程存活期间静态(CLAUDE.md 红线)。Windows 在 junction/copy 真机 smoke 通过前不宣告 Project Skills。 */
67
69
  export const daemonCapabilities = (runtimePlatform = process.platform, readiness = NATIVE_PROJECT_WORKSPACE_READINESS) => {
@@ -1,9 +1,9 @@
1
1
  import { posix, win32 } from "node:path";
2
- /** Execute POSIX npm through its owning Node binary so service PATH is irrelevant. */
2
+ /** Execute npm through its owning Node binary so PATH and cmd.exe quoting are irrelevant. */
3
3
  export function npmInvocation(platform, nodePath = process.execPath) {
4
4
  const paths = platform === "win32" ? win32 : posix;
5
- const npmPath = paths.join(paths.dirname(nodePath), platform === "win32" ? "npm.cmd" : "npm");
6
- return platform === "win32"
7
- ? { command: npmPath, argsPrefix: [] }
8
- : { command: nodePath, argsPrefix: [npmPath] };
5
+ const npmPath = platform === "win32"
6
+ ? paths.join(paths.dirname(nodePath), "node_modules", "npm", "bin", "npm-cli.js")
7
+ : paths.join(paths.dirname(nodePath), "npm");
8
+ return { command: nodePath, argsPrefix: [npmPath] };
9
9
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * powershell.exe -Command joins trailing argv into command text, rather than
3
+ * binding it to $args. Invoke a scriptblock with literal arguments inside one
4
+ * UTF-16LE encoded command. Encoding is transport, not encryption.
5
+ */
6
+ export function powershellCommandArgs(script, args = []) {
7
+ // PowerShell also treats typographic single quotes as string delimiters.
8
+ const literals = args.map((arg) => `'${arg.replace(/['\u2018-\u201b]/gu, (quote) => quote + quote)}'`).join(" ");
9
+ const command = "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);\n"
10
+ + `& {\n${script}\n} ${literals}`;
11
+ return ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(command, "utf16le").toString("base64")];
12
+ }
@@ -1,20 +1,109 @@
1
1
  import { WebSocket } from "ws";
2
+ const HELLO_RETRY_DELAYS_MS = [1_000, 2_000, 4_000];
2
3
  /** 记住最近一次 hello 与 socket,便于存储身份变化后重发;Project Skills 状态恒为 ready。 */
3
4
  export function createMachineHelloPublisher(input) {
4
5
  let latest = null;
5
6
  let latestSocket = null;
6
- const republish = () => {
7
- if (latest === null || latestSocket?.readyState !== WebSocket.OPEN)
7
+ let acknowledged = false;
8
+ let attempts = 0;
9
+ // 发送代次:每开一轮新发送(attach/publish/republish)自增,随帧发出并要求 ack 回显。
10
+ let generation = 0;
11
+ let retryTimer;
12
+ const clearRetry = () => {
13
+ if (retryTimer !== undefined) {
14
+ clearTimeout(retryTimer);
15
+ retryTimer = undefined;
16
+ }
17
+ };
18
+ const scheduleRetry = () => {
19
+ clearRetry();
20
+ if (acknowledged || latest === null || latestSocket?.readyState !== WebSocket.OPEN)
21
+ return;
22
+ // 当前 server 不支持 ack → 不排重试(否则永远等不到确认,白跑满退避)。
23
+ if (input.ackSupported && !input.ackSupported())
24
+ return;
25
+ const delay = HELLO_RETRY_DELAYS_MS[attempts - 1];
26
+ if (delay === undefined) {
27
+ // 退避跑满仍未 ack:期待 ack(ackSupported 已声明支持)才升级为断连重连,避免旧行为下反复重连。
28
+ if (input.ackSupported !== undefined && latestSocket !== null)
29
+ input.onExhausted?.(latestSocket);
8
30
  return;
9
- const hello = input.effective({ ...latest, projectSkillsStatus: "ready" });
31
+ }
32
+ retryTimer = setTimeout(() => {
33
+ retryTimer = undefined;
34
+ sendCurrent();
35
+ }, delay);
36
+ };
37
+ function sendCurrent() {
38
+ if (latest === null || latestSocket?.readyState !== WebSocket.OPEN || acknowledged)
39
+ return;
40
+ // helloSeq 随帧发出;同一代次的多次重试复用同一 seq,ack 回显该 seq 即视为确认。
41
+ const hello = input.effective({ ...latest, projectSkillsStatus: "ready", helloSeq: generation });
42
+ const attempt = attempts + 1;
43
+ attempts = attempt;
10
44
  try {
11
45
  latestSocket.send(JSON.stringify(hello));
12
- input.onSent(hello);
46
+ input.onSent(hello, attempt);
47
+ }
48
+ catch (error) {
49
+ input.onSendFailed?.(hello, attempt, error);
13
50
  }
14
- catch { /* 非 OPEN,忽略 */ }
51
+ scheduleRetry();
52
+ }
53
+ const attach = (socket) => {
54
+ clearRetry();
55
+ latestSocket = socket;
56
+ acknowledged = false;
57
+ attempts = 0;
58
+ generation += 1;
59
+ sendCurrent();
60
+ };
61
+ const detach = (socket) => {
62
+ if (latestSocket !== socket)
63
+ return;
64
+ clearRetry();
65
+ latestSocket = null;
66
+ acknowledged = false;
67
+ attempts = 0;
68
+ };
69
+ const republish = () => {
70
+ if (latest === null || latestSocket === null)
71
+ return;
72
+ clearRetry();
73
+ acknowledged = false;
74
+ attempts = 0;
75
+ generation += 1;
76
+ sendCurrent();
77
+ };
78
+ const acknowledge = (socket, seq) => {
79
+ // 非当前 socket / 已确认 / 该 socket 已在关闭(close 后迟到的 ack)一律忽略:
80
+ // 后者会错误触发 onAck→清零强制重连计数,削弱重连上限。
81
+ if (latestSocket !== socket || latest === null || acknowledged || socket.readyState !== WebSocket.OPEN)
82
+ return;
83
+ // 代次校验:只接受回显当前代次的 ack,忽略上一代次迟到的 ack(否则会误停新代次的重试)。
84
+ // seq 缺省(老 server 不回显)时退回仅按 socket 匹配,保持兼容。
85
+ if (seq !== undefined && seq !== generation)
86
+ return;
87
+ acknowledged = true;
88
+ clearRetry();
89
+ input.onAck?.(input.effective({ ...latest, projectSkillsStatus: "ready" }));
15
90
  };
16
91
  return Object.freeze({
17
- publish: (hello, socket) => { latest = hello; latestSocket = socket; republish(); },
92
+ publish: (hello, socket) => {
93
+ latest = hello;
94
+ if (latestSocket !== socket) {
95
+ attach(socket);
96
+ return;
97
+ }
98
+ acknowledged = false;
99
+ attempts = 0;
100
+ generation += 1;
101
+ clearRetry();
102
+ sendCurrent();
103
+ },
104
+ attach,
105
+ detach,
18
106
  republish,
107
+ acknowledge,
19
108
  });
20
109
  }
package/dist/serve.js CHANGED
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import { dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
10
10
  import { mergeRunAgentResults, reportScheduledStartFailure, runAgent } from "./runner.js";
11
11
  import { buildOriginDecisionRetryPrompt, buildScheduledPrompt } from "./prompt.js";
12
- import { daemonCapabilities, daemonCapabilityBindings, } from "./machine-info.js";
12
+ import { daemonCapabilities, daemonCapabilityBindings, MACHINE_HELLO_ACK_CAPABILITY, } from "./machine-info.js";
13
13
  import { reconcileExecutionJournal } from "./execution-recovery.js";
14
14
  import { initReleaseSlog, reportDaemonConnectionClose, reportReleaseError } from "./release-channel-guard.js";
15
15
  import { handleWorkspaceControlRequest } from "./workspace-control-handler.js";
@@ -65,6 +65,10 @@ export function serve(config, opts = {}) {
65
65
  let serverCapabilities = new Set();
66
66
  let backoff = 1000;
67
67
  const maxBackoff = opts.maxBackoffMs ?? 30_000;
68
+ // hello 重试跑满仍未 ack 时主动断连重连(破 server 侧连接归属/fence 死锁)。收到 ack 清零;
69
+ // 连续多次仍拿不到 ack 则停手,退回 connected-offline,避免事故期把控制面打成重连风暴。
70
+ const maxUnackedHelloReconnects = 3;
71
+ let unackedHelloReconnects = 0;
68
72
  const testShutdown = readTestShutdownConfiguration(process.env);
69
73
  const shutdownTimeoutMs = opts.shutdownTimeoutMs ?? testShutdown.timeoutMs ?? 30_000;
70
74
  const createWebSocket = opts.createWebSocket ?? ((url) => new WebSocket(url));
@@ -160,7 +164,41 @@ export function serve(config, opts = {}) {
160
164
  : configuredIdentity;
161
165
  return currentIdentity === undefined ? hello : { ...hello, ...currentIdentity };
162
166
  },
163
- onSent: (hello) => log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · installed=[${hello.runtimes.join(",")}] · executable=[${hello.executionRuntimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`),
167
+ onSent: (hello, attempt) => dslog("daemon.machine_hello_send_queued", "机器 hello 已进入发送队列", {
168
+ attempt, hostname: hello.hostname, daemon_version: hello.daemonVersion,
169
+ }),
170
+ onSendFailed: (hello, attempt, error) => dslog("daemon.machine_hello_send_failed", "机器 hello 发送失败", {
171
+ level: "WARN", attempt, hostname: hello.hostname, daemon_version: hello.daemonVersion,
172
+ error_message: error instanceof Error ? error.message : String(error),
173
+ }),
174
+ onAck: (hello) => {
175
+ unackedHelloReconnects = 0; // 拿到确认 = 本连接登记成功,清零强制重连计数。
176
+ dslog("daemon.machine_hello_ack_received", "机器 hello 已收到控制面确认", {
177
+ hostname: hello.hostname, daemon_version: hello.daemonVersion,
178
+ });
179
+ },
180
+ // 仅当当前 server 声明支持 ack 才重试;老 server 收不到 ack,重试无意义(退回单次上报)。
181
+ ackSupported: () => serverCapabilities.has(MACHINE_HELLO_ACK_CAPABILITY),
182
+ // 重试跑满仍未 ack → 断开当前连接,靠重连换新连接(新 epoch/token)破 server 侧 fence 死锁。
183
+ onExhausted: (socket) => {
184
+ if (stopped || ws !== socket || socket.readyState !== WebSocket.OPEN)
185
+ return;
186
+ if (unackedHelloReconnects >= maxUnackedHelloReconnects) {
187
+ dslog("daemon.machine_hello_unacked_giveup", "机器 hello 连续多次重连仍未确认,停止强制重连(保持连接,等待下次事件或人工介入)", {
188
+ level: "ERROR", reconnects: unackedHelloReconnects,
189
+ });
190
+ return;
191
+ }
192
+ unackedHelloReconnects += 1;
193
+ // 抖动重连间隔:控制面回滚时多台 daemon 会同时耗尽重试,固定间隔会同步重连造成羊群。
194
+ // close handler 用当前 backoff 排重连,这里注入 1~4s 随机值把各机错峰(open 会再把它复位)。
195
+ const jitteredBackoff = 1_000 + Math.floor(Math.random() * 3_000);
196
+ backoff = Math.min(jitteredBackoff, maxBackoff);
197
+ dslog("daemon.machine_hello_unacked_reconnect", "机器 hello 重试跑满仍未确认,断开重连换新连接", {
198
+ level: "WARN", reconnects: unackedHelloReconnects, backoff_ms: backoff,
199
+ });
200
+ socket.close(1011, "machine hello unacknowledged");
201
+ },
164
202
  });
165
203
  const machineRuntime = createMachineRuntimeEnvironment({
166
204
  config,
@@ -310,6 +348,7 @@ export function serve(config, opts = {}) {
310
348
  // 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
311
349
  void drainSpool();
312
350
  // 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
351
+ machineHelloPublisher.attach(openedSocket);
313
352
  machineRuntime.open(openedSocket, { capabilities: capabilities.machineHello, beforePublish: Promise.resolve(), connectionId });
314
353
  opts.onOpen?.(ws);
315
354
  void sendSnapshot(`reconnect-${randomUUID()}`);
@@ -352,6 +391,11 @@ export function serve(config, opts = {}) {
352
391
  if (typeof decoded !== "object" || decoded === null)
353
392
  return;
354
393
  const rawType = "type" in decoded && typeof decoded.type === "string" ? decoded.type : "";
394
+ if (rawType === "machine:hello_ack") {
395
+ const ackSeq = "helloSeq" in decoded && typeof decoded.helloSeq === "number" ? decoded.helloSeq : undefined;
396
+ machineHelloPublisher.acknowledge(connectionSocket, ackSeq);
397
+ return;
398
+ }
355
399
  if (rawType === "machine:environment:get" || rawType === "machine:environment:refresh") {
356
400
  await handleMachineEnvironmentFrame(decoded, machineRuntime.provider, sendToConnection);
357
401
  return;
@@ -735,6 +779,11 @@ export function serve(config, opts = {}) {
735
779
  // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
736
780
  const r = msg;
737
781
  setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
782
+ // 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。ackSupported() 依赖它判断是否重试,
783
+ // 必须在任何 republish() 之前就位;否则本轮 republish 看到空集合→不排重试→整套 ack/重连机制哑火。
784
+ serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
785
+ ? r.serverCapabilities.filter((c) => typeof c === "string")
786
+ : []);
738
787
  if (opts.storageRuntime !== undefined && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
739
788
  const bindingSocket = ws;
740
789
  bindStorageServerIdentity(opts.storageRuntime, { workspaceId: r.workspaceId, machineId: r.machineId }, {
@@ -751,10 +800,7 @@ export function serve(config, opts = {}) {
751
800
  },
752
801
  });
753
802
  }
754
- // 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
755
- serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
756
- ? r.serverCapabilities.filter((c) => typeof c === "string")
757
- : []);
803
+ machineHelloPublisher.republish();
758
804
  if (serverCapabilities.has(PROJECT_SKILLS_CAPABILITY)) {
759
805
  void projectSkillsController.initialize()
760
806
  .then(() => projectSkillsController.publishCurrent())
@@ -1203,6 +1249,7 @@ export function serve(config, opts = {}) {
1203
1249
  });
1204
1250
  connectionSocket.on("close", (code) => {
1205
1251
  machineRuntime.close(connectionSocket);
1252
+ machineHelloPublisher.detach(connectionSocket);
1206
1253
  if (stopped || ws !== connectionSocket)
1207
1254
  return;
1208
1255
  // server 能力随连接失效;下个连接的 ready 帧重新声明(防降级重连后沿用过期能力)。
@@ -1,5 +1,6 @@
1
1
  import { systemCommandRunner, windowsCommandArg, } from "./computer-service.js";
2
2
  import { win32 } from "node:path";
3
+ import { powershellCommandArgs } from "./powershell-command.js";
3
4
  function encoded(value) {
4
5
  return Buffer.from(value, "utf16le").toString("base64");
5
6
  }
@@ -33,10 +34,9 @@ export async function inspectWindowsServiceUpdateScope(spec, npmPrefix, daemonPi
33
34
  "$enginePid=if($instances.Count -eq 1){[int64]$instances[0].EnginePID}else{0};",
34
35
  "[pscustomobject]@{count=1;state=[int]$task.State;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};instanceCount=$instances.Count;enginePid=$enginePid;engineOwned=($enginePid -eq $pidValue -or $ancestors -contains $enginePid);processIdentity=$processIdentity;conflictingPrefixes=$conflicts.Count}|ConvertTo-Json -Compress;",
35
36
  ].join("");
36
- const result = await runner("powershell.exe", [
37
- "-NoProfile", "-NonInteractive", "-Command", script,
37
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [
38
38
  encoded(spec.id), encoded(entryMarker), String(daemonPid),
39
- ]);
39
+ ]));
40
40
  if (result.exitCode !== 0)
41
41
  return { valid: false };
42
42
  let inspection;
@@ -0,0 +1,26 @@
1
+ /** Read only: resolve the task's account with Windows, without guessing or stripping its domain. */
2
+ export const windowsTaskPrincipalScript = [
3
+ "$currentUserSid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;",
4
+ "$principalSid=$null;",
5
+ "try{",
6
+ "$account=[string]$task.Principal.UserId;",
7
+ "if($account -match '^S-\\d-'){",
8
+ "$principalSid=([Security.Principal.SecurityIdentifier]::new($account)).Value;",
9
+ "}else{",
10
+ "$principalSid=([Security.Principal.NTAccount]::new($account)).Translate([Security.Principal.SecurityIdentifier]).Value;",
11
+ "}",
12
+ "}catch{$principalSid=$null};",
13
+ ].join("");
14
+ export const windowsTaskPrincipalFields = [
15
+ "currentUserSid=$currentUserSid;",
16
+ "principalSid=$principalSid;",
17
+ "logonType=[int]$task.Principal.LogonType;",
18
+ "runLevel=[int]$task.Principal.RunLevel;",
19
+ ].join("");
20
+ export function windowsTaskPrincipalMatches(principal) {
21
+ return typeof principal.currentUserSid === "string"
22
+ && /^S-1-(?:\d+-)*\d+$/.test(principal.currentUserSid)
23
+ && principal.principalSid === principal.currentUserSid
24
+ && principal.logonType === 3 // Interactive
25
+ && principal.runLevel === 0; // Limited
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.97",
3
+ "version": "0.6.98-beta.1",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",