@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 +43 -1
- package/dist/automatic-daemon-installer.js +41 -10
- package/dist/automatic-service-migration.js +7 -6
- package/dist/computer-service.js +70 -47
- package/dist/computer-setup-client.js +43 -9
- package/dist/computer-setup-existing.js +154 -0
- package/dist/computer-setup-orchestration.js +32 -6
- package/dist/computer-setup.js +37 -6
- package/dist/computer-token-setup.js +46 -0
- package/dist/i18n.js +16 -0
- package/dist/machine-info.js +3 -3
- package/dist/marketplace-controller.js +8 -21
- package/dist/marketplace-serve.js +2 -4
- package/dist/npm-command.js +5 -5
- package/dist/powershell-command.js +12 -0
- package/dist/project-skills/machine-hello-publisher.js +95 -6
- package/dist/serve.js +54 -7
- package/dist/windows-scheduled-task.js +3 -3
- package/dist/windows-task-principal.js +26 -0
- package/package.json +1 -1
- package/dist/marketplace-maintenance-bridge.js +0 -79
- package/dist/marketplace-maintenance-protocol.js +0 -40
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
|
-
##
|
|
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
|
|
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)
|
|
146
|
-
|
|
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
|
|
169
|
+
|| !windowsTaskPrincipalMatches(definition)
|
|
169
170
|
|| !win32.isAbsolute(definition.execute ?? "")
|
|
170
171
|
|| match === null
|
|
171
172
|
|| match[2] !== input.desired.profile
|
package/dist/computer-service.js
CHANGED
|
@@ -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)
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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" ? "
|
|
313
|
-
const args =
|
|
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}`)}`);
|