@nowcrew/daemon 0.6.98 → 0.6.99-beta.2

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.
package/README.md CHANGED
@@ -40,7 +40,40 @@ This compatibility form stays resident in the current terminal and reconnects wi
40
40
  `pnpm --filter @nowcrew/daemon daemon`. `crew-daemon run --agent <handle> --channel <id>` is the legacy
41
41
  manual one-shot entry.
42
42
 
43
- ## Browser-Approved Computer Setup
43
+ ## Single-Command Computer Setup
44
+
45
+ After the deployment enables Token setup, an owner/admin opens **Add Computer**, copies the complete
46
+ command, and pastes it into the target computer's terminal. The same steps apply on macOS, Linux/SSH,
47
+ and Windows. Node.js 22+ and npm must already be installed. The command launches the installer with npx;
48
+ no separate global installation, browser confirmation, or verification code is needed during execution.
49
+
50
+ The generated command pins an exact published installer version and includes a `--setup-token`.
51
+ This is an already-approved one-time connection request, valid for ten minutes. The program exchanges
52
+ it for the machine credential only when executed, then reuses native service installation, new-hello
53
+ verification, credential finalization and rollback. Merely generating or copying a command does not
54
+ rotate the existing credential. An expired or consumed command must be regenerated in the Web UI.
55
+
56
+ For a machine credential you already hold, the same setup entry accepts `--api-key` or
57
+ `CREW_MACHINE_TOKEN` without browser pairing:
58
+
59
+ ```bash
60
+ nowwork-computer setup --server-url https://nowwork.nowcoder.com /nowcoder-app \
61
+ --machine <machine-uuid> --api-key <existing-machine-token>
62
+ ```
63
+
64
+ The machine Token must match the specified Workspace and Computer. This path never rotates the
65
+ credential or creates an approval request; it confirms a new control-plane connection with the expected
66
+ profile and paths before reporting success. Token sources cannot be combined. JSON output puts progress
67
+ on stderr and the final result on stdout. On Windows shells that block npm.ps1/npx.ps1, use npx.cmd as
68
+ the package entry; the setup arguments and workflow are unchanged.
69
+
70
+ Deployment requires `CREW_PUBLIC_URL` (or `CREW_SERVER_URL`) and
71
+ `CREW_COMPUTER_TOKEN_SETUP_VERSION` set to an exact, published version known to support the Token flags.
72
+ The server checks its published release catalog and channel before issuing a Token command. Leave the
73
+ version unset until that installer has been released; it returns `computer_token_setup_unavailable`
74
+ instead of generating a command for an unsupported package. Existing clients retain browser setup.
75
+
76
+ ## Browser-Approved Computer Setup (Compatibility)
44
77
 
45
78
  The Web UI generates a token-free command for a specific Workspace and Machine:
46
79
 
@@ -77,6 +110,15 @@ to confirm Workspace, Machine, environment, profile, and storage identity. It do
77
110
  existing `.crew` data. Existing `crew-daemon install --server-url ... --api-key ...` and profile lifecycle
78
111
  commands remain available as compatibility and recovery entry points.
79
112
 
113
+ Profiles created by older installers may omit `workspaceId` and `machineId`. During setup, the installer
114
+ uses the newly authorized machine credential to call `POST /daemon/computer-setup/legacy-profile` with
115
+ the SHA-256 of the previous profile token. The server checks historical ownership within that exact
116
+ Workspace and Machine, including revoked credentials, without reactivating them. After the server,
117
+ profile name, Agent root and any existing identity fields match, normal installation persists both UUIDs.
118
+ A mismatch or failed verification stops before changing the local profile or service; do not delete a
119
+ profile or fill in UUIDs by guessing. Deploy the server endpoint before publishing the compatible daemon;
120
+ an older server cannot verify legacy profiles, and already published daemon packages remain unchanged.
121
+
80
122
  ## Machine Environment
81
123
 
82
124
  The daemon loads exported variables from its OS account’s bash/zsh configuration on macOS/Linux,
@@ -1,7 +1,8 @@
1
1
  import { homedir } from "node:os";
2
+ import { createHash } from "node:crypto";
2
3
  import { posix, win32 } from "node:path";
3
4
  import { z } from "zod";
4
- import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenSchema, removeProfile, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
5
+ import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenSchema, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
5
6
  import { buildServiceSpec, captureExactService, hardenWindowsProfile, replaceExactService, restoreExactService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, } from "./computer-service.js";
6
7
  import { daemonGlobalInstallation } from "./daemon-installation.js";
7
8
  import { buildManagedServiceRecord, installManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
@@ -15,8 +16,14 @@ import { daemonRegistryCandidates } from "./daemon-registry.js";
15
16
  import { npmInvocation } from "./npm-command.js";
16
17
  import { discoverExactLegacyService, } from "./automatic-service-migration.js";
17
18
  import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
18
- import { serverHostIdentity } from "./server-url.js";
19
- import { computerSetupRecoveryGuidance } from "./computer-setup-client.js";
19
+ import { serverHostIdentity, serverUrlsMatch } from "./server-url.js";
20
+ import { computerSetupRecoveryGuidance, createComputerSetupClient, } from "./computer-setup-client.js";
21
+ export const automaticDaemonInstallStageMessage = {
22
+ downloading: "Downloading the Daemon package...",
23
+ configuring: "Configuring the Daemon service...",
24
+ starting: "Starting the Daemon service...",
25
+ confirming: "Confirming the computer connection...",
26
+ };
20
27
  function profileStorage(platform, userHome, runner) {
21
28
  return {
22
29
  platform,
@@ -57,6 +64,7 @@ function defaultDependencies() {
57
64
  version: daemonVersion(),
58
65
  ...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
59
66
  runner,
67
+ verifyLegacyProfileIdentity: (serverUrl, input) => createComputerSetupClient({ serverUrl }).verifyLegacyProfileIdentity(input),
60
68
  captureProfile: async (target) => (await listProfiles(target.daemonHome)).includes(target.profile)
61
69
  ? loadProfile(target.profile, target.daemonHome, storage)
62
70
  : null,
@@ -260,18 +268,37 @@ function setupIdentity(input) {
260
268
  machineId: z.string().uuid().parse(input.machineId),
261
269
  };
262
270
  }
263
- function assertExistingProfileIdentity(previous, identity) {
271
+ async function assertExistingProfileIdentity(previous, identity, target, machineToken, dependencies) {
264
272
  if (previous === null || identity === null)
265
273
  return;
266
- if (previous.workspaceId === undefined || previous.machineId === undefined) {
267
- throw new Error(`Profile '${previous.name}' does not record its Workspace and Machine identity`);
268
- }
269
- if (previous.workspaceId !== identity.workspaceId) {
274
+ if (previous.workspaceId !== undefined && previous.workspaceId !== identity.workspaceId) {
270
275
  throw new Error(`Profile '${previous.name}' belongs to a different Workspace identity`);
271
276
  }
272
- if (previous.machineId !== identity.machineId) {
277
+ if (previous.machineId !== undefined && previous.machineId !== identity.machineId) {
273
278
  throw new Error(`Profile '${previous.name}' belongs to a different Machine identity`);
274
279
  }
280
+ if (previous.workspaceId !== undefined && previous.machineId !== undefined)
281
+ return;
282
+ const rootIdentity = (root) => {
283
+ const resolved = resolveAgentsRoot(root, dependencies.userHome, dependencies.platform);
284
+ return dependencies.platform === "win32" ? resolved.toLowerCase() : resolved;
285
+ };
286
+ if (previous.name !== target.profile || !serverUrlsMatch(previous.serverUrl, target.serverUrl)
287
+ || rootIdentity(previous.agentsRoot) !== rootIdentity(target.agentsRoot)) {
288
+ throw new Error(`Legacy profile '${previous.name}' does not match the requested server, profile, or Agent root`);
289
+ }
290
+ if (dependencies.verifyLegacyProfileIdentity === undefined) {
291
+ throw new Error(`Cannot verify legacy profile '${previous.name}' Workspace and Machine identity`);
292
+ }
293
+ // The old credential may already have been revoked by setup. Only the current credential
294
+ // authenticates this read-only request; the old hash proves historical ownership.
295
+ const verified = await dependencies.verifyLegacyProfileIdentity(target.serverUrl, {
296
+ machineToken,
297
+ previousTokenHash: createHash("sha256").update(previous.machineToken).digest("hex"),
298
+ });
299
+ if (verified.workspaceId !== identity.workspaceId || verified.machineId !== identity.machineId) {
300
+ throw new Error(`Legacy profile '${previous.name}' belongs to a different Workspace or Machine identity`);
301
+ }
275
302
  }
276
303
  export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
277
304
  const machineToken = MachineTokenSchema.parse(input.machineToken);
@@ -308,7 +335,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
308
335
  });
309
336
  const previousProfile = await dependencies.captureProfile(target);
310
337
  await dependencies.assertProfileTarget(target, previousProfile);
311
- assertExistingProfileIdentity(previousProfile, identity);
338
+ await assertExistingProfileIdentity(previousProfile, identity, target, machineToken, dependencies);
312
339
  const previousService = await dependencies.captureService(spec, target);
313
340
  if (previousService.installed && !previousService.exact) {
314
341
  throw new Error(`Installed service '${spec.id}' does not match the requested identity`);
@@ -331,6 +358,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
331
358
  });
332
359
  let installFailure;
333
360
  try {
361
+ input.onProgress?.("downloading");
334
362
  await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner, target.registryUrl);
335
363
  }
336
364
  catch (error) {
@@ -353,6 +381,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
353
381
  legacyRemovalStarted = true;
354
382
  await dependencies.removeLegacyService(legacyService);
355
383
  }
384
+ input.onProgress?.("configuring");
356
385
  profileSaved = true;
357
386
  await dependencies.saveTargetProfile({
358
387
  version: 1,
@@ -367,6 +396,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
367
396
  }, target.daemonHome);
368
397
  serviceReplacementStarted = true;
369
398
  await dependencies.replaceService(spec, target);
399
+ input.onProgress?.("starting");
370
400
  await dependencies.startService(spec);
371
401
  const status = await dependencies.verifyService(spec);
372
402
  if (!status.running)
@@ -380,6 +410,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
380
410
  daemonHome: target.daemonHome,
381
411
  agentsRoot: target.agentsRoot,
382
412
  };
413
+ input.onProgress?.("confirming");
383
414
  await input.verifyAfterStart?.(result);
384
415
  return result;
385
416
  }
@@ -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,10 +6,14 @@ 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;
15
+ const DARWIN_STOP_TIMEOUT_MS = 30_000;
16
+ const DARWIN_STOP_POLL_MS = 100;
13
17
  /** Windows cannot spawn .cmd/.bat shims directly with shell:false; route them through cmd.exe explicitly. */
14
18
  export function systemCommandInvocation(command, args, platform = process.platform, comSpec = process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe") {
15
19
  if (platform === "win32" && /\.(?:cmd|bat)$/iu.test(command)) {
@@ -184,6 +188,32 @@ async function checked(runner, command, args, action) {
184
188
  }
185
189
  return result;
186
190
  }
191
+ async function stopLaunchdService(spec, initial, runner) {
192
+ const assertQueryable = (result) => {
193
+ // launchctl uses 113 for an absent service. Other failures cannot prove it stopped.
194
+ if (result.exitCode !== 0 && result.exitCode !== 113) {
195
+ throw new Error(`launchd status failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
196
+ }
197
+ };
198
+ assertQueryable(initial);
199
+ if (initial.exitCode === 113)
200
+ return;
201
+ const target = `${spec.managerDomain}/${spec.id}`;
202
+ await checked(runner, "launchctl", ["bootout", target], "launchd stop");
203
+ const deadline = Date.now() + DARWIN_STOP_TIMEOUT_MS;
204
+ while (true) {
205
+ const current = await runner("launchctl", ["print", target]);
206
+ assertQueryable(current);
207
+ if (current.exitCode === 113)
208
+ return;
209
+ // bootout returns while the job is SIGTERMed and still owns its installation lease.
210
+ // Wait for removal, not merely a non-running state, before installing or bootstrapping.
211
+ if (Date.now() >= deadline) {
212
+ throw new Error(`launchd service '${spec.id}' did not unload within ${DARWIN_STOP_TIMEOUT_MS}ms`);
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, DARWIN_STOP_POLL_MS));
215
+ }
216
+ }
187
217
  function sameWindowsPath(left, right) {
188
218
  return left !== undefined
189
219
  && left.replaceAll("/", "\\").toLowerCase() === right.replaceAll("/", "\\").toLowerCase();
@@ -195,12 +225,11 @@ async function windowsTaskDefinitionMatches(spec, runner) {
195
225
  "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));",
196
226
  "$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
197
227
  "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;",
228
+ "$task=$tasks[0];$actions=@($task.Actions);",
229
+ windowsTaskPrincipalScript,
230
+ `[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
231
  ].join("");
201
- const result = await runner("powershell.exe", [
202
- "-NoProfile", "-NonInteractive", "-Command", script, encodedName,
203
- ]);
232
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [encodedName]));
204
233
  if (result.exitCode !== 0) {
205
234
  throw new Error(`Unable to inspect scheduled task definition: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
206
235
  }
@@ -214,7 +243,7 @@ async function windowsTaskDefinitionMatches(spec, runner) {
214
243
  const expectedArguments = spec.daemonCommand.slice(1).map(windowsCommandArg).join(" ");
215
244
  return definition.count === 1
216
245
  && definition.actionCount === 1
217
- && definition.principalMatches === true
246
+ && windowsTaskPrincipalMatches(definition)
218
247
  && sameWindowsPath(definition.execute, spec.daemonCommand[0])
219
248
  && definition.arguments === expectedArguments;
220
249
  }
@@ -226,9 +255,7 @@ async function windowsTaskState(spec, runner) {
226
255
  "$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
227
256
  "if($tasks.Count -eq 0){'missing'}elseif($tasks.Count -eq 1){'state:'+([int]$tasks[0].State)}else{throw 'duplicate scheduled tasks'}",
228
257
  ].join("");
229
- const result = await runner("powershell.exe", [
230
- "-NoProfile", "-NonInteractive", "-Command", script, encodedName,
231
- ]);
258
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [encodedName]));
232
259
  if (result.exitCode !== 0) {
233
260
  throw new Error(`Unable to query scheduled task: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
234
261
  }
@@ -279,10 +306,9 @@ export async function installService(spec, runner = systemCommandRunner) {
279
306
  "$principal=New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited;",
280
307
  "Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -ErrorAction Stop|Out-Null;",
281
308
  ].join("");
282
- await checked(runner, "powershell.exe", [
283
- "-NoProfile", "-NonInteractive", "-Command", script,
309
+ await checked(runner, "powershell.exe", powershellCommandArgs(script, [
284
310
  encodedName, encodedExecutable, encodedArguments,
285
- ], "Scheduled task installation");
311
+ ]), "Scheduled task installation");
286
312
  return;
287
313
  }
288
314
  if (spec.descriptorPath === null || spec.descriptor === null)
@@ -358,9 +384,7 @@ export async function uninstallService(spec, runner = systemCommandRunner) {
358
384
  }
359
385
  if (spec.platform === "darwin") {
360
386
  const status = await serviceStatus(spec, runner);
361
- if (status.loaded) {
362
- await checked(runner, "launchctl", ["bootout", `${spec.managerDomain}/${spec.id}`], "launchd bootout");
363
- }
387
+ await stopLaunchdService(spec, status, runner);
364
388
  }
365
389
  else {
366
390
  const disable = await runner("systemctl", ["--user", "disable", "--now", spec.id]);
@@ -387,8 +411,7 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
387
411
  await checked(runner, "launchctl", ["bootstrap", spec.managerDomain, spec.descriptorPath], "launchd start");
388
412
  }
389
413
  else if (action === "stop") {
390
- if (status.loaded)
391
- await checked(runner, "launchctl", ["bootout", target], "launchd stop");
414
+ await stopLaunchdService(spec, status, runner);
392
415
  }
393
416
  else {
394
417
  if (status.loaded)
@@ -418,6 +441,33 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
418
441
  }
419
442
  }
420
443
  const systemDetachedSpawn = (command, args, options) => spawn(command, [...args], options);
444
+ function windowsRestartCommandArgs(spec) {
445
+ if (!spec.runtimeProcessIdentity)
446
+ throw new Error("Windows daemon process identity is required for restart");
447
+ const encodedName = Buffer.from(spec.id, "utf16le").toString("base64");
448
+ const encodedExecutable = Buffer.from(spec.daemonCommand[0], "utf16le").toString("base64");
449
+ const encodedArguments = Buffer.from(spec.daemonCommand.slice(1).map(windowsCommandArg).join(" "), "utf16le").toString("base64");
450
+ const script = [
451
+ "$ErrorActionPreference='Stop';",
452
+ "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[1]));",
453
+ "$exe=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[2]));",
454
+ "$argv=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[3]));",
455
+ "$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};",
456
+ "$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'};",
457
+ "$p=Get-Process -Id ([int]$args[0]) -ErrorAction Stop;",
458
+ "if($p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() -cne $args[4]){throw 'daemon process identity changed'};",
459
+ "$p.Kill();",
460
+ "if(-not $p.WaitForExit(10000)){throw 'daemon exit timeout'};",
461
+ "$deadline=[DateTime]::UtcNow.AddSeconds(10);",
462
+ "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);",
463
+ "if([int]$task[0].State -eq 4){throw 'scheduled task stop timeout'};",
464
+ "$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'};",
465
+ "& schtasks.exe /Run /TN $n | Out-Null;if($LASTEXITCODE -ne 0){throw 'scheduled task start failed'};",
466
+ ].join("");
467
+ return powershellCommandArgs(script, [
468
+ String(process.pid), encodedName, encodedExecutable, encodedArguments, spec.runtimeProcessIdentity,
469
+ ]);
470
+ }
421
471
  const RESTART_HELPER_SOURCE = String.raw `
422
472
  const { spawnSync } = require("node:child_process");
423
473
  const spec = JSON.parse(process.argv[1]);
@@ -428,27 +478,7 @@ setTimeout(() => {
428
478
  } else if (spec.platform === "linux") {
429
479
  restarted = spawnSync("systemctl", ["--user", "--no-block", "restart", spec.id], { stdio: "ignore", shell: false });
430
480
  } 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 });
481
+ restarted = spawnSync("powershell.exe", spec.powershellArgs, { stdio: "ignore", shell: false });
452
482
  }
453
483
  if (restarted.status !== 0) process.send?.({ type: "restart-failed", status: restarted.status });
454
484
  setTimeout(() => {
@@ -470,15 +500,8 @@ export function scheduleServiceRestart(spec, spawnDetached = systemDetachedSpawn
470
500
  ? { platform: spec.platform, id: spec.id }
471
501
  : {
472
502
  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,
503
+ powershellArgs: windowsRestartCommandArgs(spec),
478
504
  };
479
- if (spec.platform === "win32" && !payload.processIdentity) {
480
- throw new Error("Windows daemon process identity is required for restart");
481
- }
482
505
  const child = spawnDetached(process.execPath, ["-e", RESTART_HELPER_SOURCE, JSON.stringify(payload)], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], shell: false });
483
506
  child.unref();
484
507
  let settleFailure;
@@ -627,7 +650,7 @@ export async function windowsProfileIsPrivate(path, runner = systemCommandRunner
627
650
  "$unsafe=$rules|Where-Object { $_.AccessControlType -eq 'Allow' -and $safe -notcontains $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value };",
628
651
  "if($unsafe){'false'}else{'true'}",
629
652
  ].join("");
630
- const result = await runner("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, encodedPath]);
653
+ const result = await runner("powershell.exe", powershellCommandArgs(script, [encodedPath]));
631
654
  if (result.exitCode !== 0)
632
655
  throw new Error(`Profile ACL inspection failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
633
656
  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);
@@ -38,6 +39,26 @@ const SetupBootstrapSchema = z.object({
38
39
  expiresAt: z.string().datetime({ offset: true }).optional(),
39
40
  machineToken: MachineTokenSchema,
40
41
  }).strict();
42
+ const SetupTokenTargetSchema = SetupBootstrapSchema.omit({ setupId: true, machineToken: true }).extend({
43
+ workspaceSlug: z.string().min(1).max(80),
44
+ releaseTag: z.enum(["latest", "beta"]),
45
+ connection: z.object({
46
+ status: z.enum(["online", "offline"]),
47
+ epoch: z.number().int().nonnegative(),
48
+ profile: z.string().nullable(),
49
+ daemonHome: z.string().nullable(),
50
+ agentsRoot: z.string().nullable(),
51
+ storageId: z.string().optional(),
52
+ credentialMatchesConnection: z.boolean(),
53
+ }).strict(),
54
+ }).strict();
55
+ const SetupReceiptSchema = z.object({
56
+ setupId: z.string().uuid(),
57
+ credentialState: z.enum(["pending", "finalized"]),
58
+ target: SetupTokenTargetSchema.omit({ connection: true }),
59
+ connection: SetupTokenTargetSchema.shape.connection,
60
+ connectionConfirmed: z.boolean(),
61
+ }).strict();
41
62
  const SetupPendingSchema = z.object({
42
63
  status: z.enum(["authorization_pending", "pending"]),
43
64
  intervalSeconds: z.number().int().positive().max(300).optional(),
@@ -75,7 +96,8 @@ export class ComputerSetupHttpError extends Error {
75
96
  }
76
97
  }
77
98
  export function redactSetupSecrets(value, secrets = []) {
78
- let redacted = value.replace(/\bsk_machine_[A-Za-z0-9_-]+\b/gu, "<redacted>");
99
+ let redacted = value.replace(/\bsk_machine_[A-Za-z0-9_-]+\b/gu, "<redacted>")
100
+ .replace(/(--setup-token(?:=|\s+))[A-Za-z0-9_-]+/gu, "$1<redacted>");
79
101
  for (const secret of secrets) {
80
102
  if (secret.length > 0)
81
103
  redacted = redacted.replaceAll(secret, "<redacted>");
@@ -236,8 +258,9 @@ export function createComputerSetupClient(options) {
236
258
  setTimeout(resolve, milliseconds);
237
259
  }));
238
260
  const now = options.now ?? Date.now;
239
- let activeMachineId;
240
261
  const request = async (method, path, body, bearer) => {
262
+ const secretInputs = [bearer ?? "", body?.deviceCode ?? "",
263
+ body?.setupToken ?? ""];
241
264
  const controller = new AbortController();
242
265
  const timeoutId = setTimeout(() => controller.abort(), requestTimeoutMs);
243
266
  try {
@@ -252,7 +275,7 @@ export function createComputerSetupClient(options) {
252
275
  });
253
276
  const payload = await response.json();
254
277
  if (!response.ok || payload?.success === false) {
255
- const message = redactSetupSecrets(payload?.error?.message ?? `computer setup request failed (HTTP ${response.status})`);
278
+ const message = redactSetupSecrets(payload?.error?.message ?? `computer setup request failed (HTTP ${response.status})`, secretInputs);
256
279
  throw new ComputerSetupHttpError(message, response.status, payload?.error?.code ?? `HTTP_${response.status}`);
257
280
  }
258
281
  return (payload?.success === true ? payload.data : payload);
@@ -262,15 +285,26 @@ export function createComputerSetupClient(options) {
262
285
  throw new Error("computer setup request timed out");
263
286
  if (error instanceof ComputerSetupHttpError)
264
287
  throw error;
265
- throw new Error(redactSetupSecrets(error instanceof Error ? error.message : String(error)));
288
+ throw new Error(redactSetupSecrets(error instanceof Error ? error.message : String(error), secretInputs));
266
289
  }
267
290
  finally {
268
291
  clearTimeout(timeoutId);
269
292
  }
270
293
  };
271
294
  return {
295
+ receipt: async ({ setupToken, machineToken }) => {
296
+ const receipt = SetupReceiptSchema.safeParse(await request("POST", "/daemon/computer-setup/receipt", { setupToken }, machineToken));
297
+ if (!receipt.success)
298
+ throw new Error("Invalid computer setup receipt response");
299
+ return receipt.data;
300
+ },
301
+ tokenTarget: async (machineToken) => SetupTokenTargetSchema.parse(await request("GET", "/daemon/computer-setup/target", undefined, machineToken)),
302
+ verifyLegacyProfileIdentity: async (input) => z.object({
303
+ workspaceId: z.string().uuid(), machineId: z.string().uuid(),
304
+ }).strict().parse(await request("POST", "/daemon/computer-setup/legacy-profile", {
305
+ previousTokenHash: input.previousTokenHash,
306
+ }, input.machineToken)),
272
307
  start: async (input) => {
273
- activeMachineId = input.machineId;
274
308
  return parseStart(await request("POST", "/api/auth/computer-setup/start", {
275
309
  ...(input.workspace.kind === "id"
276
310
  ? { workspaceId: input.workspace.value }
@@ -281,8 +315,6 @@ export function createComputerSetupClient(options) {
281
315
  }));
282
316
  },
283
317
  poll: async (deviceCode) => {
284
- if (activeMachineId === undefined)
285
- throw new Error("computer setup polling has no active machine");
286
318
  return parsePoll(await request("POST", "/api/auth/computer-setup/poll", { deviceCode }));
287
319
  },
288
320
  waitForHello: async (input) => {
@@ -309,8 +341,10 @@ export function createComputerSetupClient(options) {
309
341
  };
310
342
  }
311
343
  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];
344
+ const command = platform === "win32" ? "powershell.exe" : platform === "darwin" ? "open" : "xdg-open";
345
+ const args = platform === "win32"
346
+ ? powershellCommandArgs("Start-Process -FilePath $args[0] -ErrorAction Stop | Out-Null", [url])
347
+ : [url];
314
348
  const result = await runner(command, args);
315
349
  if (result.exitCode !== 0) {
316
350
  throw new Error(`Unable to open the computer setup browser: ${redactSetupSecrets(result.stderr.trim() || `exit ${result.exitCode}`)}`);