@nowcrew/daemon 0.6.74 → 0.6.75

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
@@ -11,6 +11,13 @@ For a temporary foreground connection, run:
11
11
  npx @nowcrew/daemon@latest --server-url https://nowwork.example --api-key sk_machine_...
12
12
  ```
13
13
 
14
+ The package has two release channels. The `in` environment consumes stable `x.y.z` releases through
15
+ the `latest` tag; the `dev` environment consumes `x.y.z-beta.n` prereleases through the `beta` tag.
16
+ The parser also accepts the historical `x.y.z-beta-n` spelling and normalizes it for ordering; new
17
+ published versions should use the dotted SemVer form.
18
+ Never use `latest` for a dev installation or `beta` for an in installation. See the
19
+ [Daemon Release Runbook](../docs/releases/daemon-release-runbook.md) for release and rollback procedures.
20
+
14
21
  This compatibility form stays resident in the current terminal and reconnects with backoff. New
15
22
  **Add Computer** commands use the managed automatic installation described below. For local development, run
16
23
  `pnpm --filter @nowcrew/daemon daemon`. `crew-daemon run --agent <handle> --channel <id>` is the legacy
@@ -22,7 +29,7 @@ Persist a named connection without putting its machine token in shell history or
22
29
 
23
30
  ```bash
24
31
  printf '%s' "$CREW_MACHINE_TOKEN" | crew-daemon profile save work \
25
- --server-url https://nowwork.example --token-stdin
32
+ --server-url https://nowwork.example --release-tag latest --token-stdin
26
33
  crew-daemon profile list
27
34
  crew-daemon profile show work # reports configured=true; never prints the token
28
35
  crew-daemon serve --profile work
@@ -36,8 +43,9 @@ The connection command generated by **Add Computer** installs the package into a
36
43
  saves the private profile, installs the native user service, starts it, and verifies that it is running:
37
44
 
38
45
  ```bash
39
- npx -y --registry https://registry.npmjs.org @nowcrew/daemon@latest install \
40
- --server-url https://nowwork-dev.nowcoder.com --api-key sk_machine_...
46
+ npx -y --registry https://registry.npmjs.org @nowcrew/daemon@beta install \
47
+ --server-url https://nowwork-dev.nowcoder.com --api-key sk_machine_... \
48
+ --release-tag beta --registry https://registry.npmjs.org
41
49
  ```
42
50
 
43
51
  The installer selects the fixed `in` layout for `nowwork-in.nowcoder.com` and the fixed `dev` layout for
@@ -45,8 +53,8 @@ The installer selects the fixed `in` layout for `nowwork-in.nowcoder.com` and th
45
53
  An unknown hostname is rejected before any write and reports how to retry with an explicit custom profile:
46
54
 
47
55
  ```bash
48
- npx -y --registry https://registry.npmjs.org @nowcrew/daemon@latest install \
49
- --server-url https://lab.example.com --api-key sk_machine_... --profile lab
56
+ npx -y --registry https://registry.npmjs.org @nowcrew/daemon@beta install \
57
+ --server-url https://lab.example.com --api-key sk_machine_... --profile lab --release-tag beta
50
58
  ```
51
59
 
52
60
  Custom profiles use `~/.crew/daemon-<profile>`, `~/.crew/agents-<profile>`, and
@@ -95,13 +103,16 @@ Upgrade the global daemon package, optionally restarting one installed profile a
95
103
  ```bash
96
104
  crew-daemon upgrade
97
105
  crew-daemon upgrade --profile work
106
+ crew-daemon upgrade --profile work --release-tag beta
98
107
  ```
99
108
 
100
109
  With `--profile`, upgrade validates isolation, stops the native service, acquires the installation lease,
101
110
  replaces only the entrypoint-derived npm prefix, releases the lease, and starts the service. If package
102
111
  replacement fails, it still attempts to start the previous installation. Without `--profile`, upgrade
103
- refuses to replace a prefix currently leased by a running daemon; use the profile form for a managed
104
- service.
112
+ refuses to replace a prefix currently leased by a running daemon; it also permits only the currently
113
+ installed daemon's release channel. Cross-channel upgrades (for example installing `beta` from a stable
114
+ global package) require `--profile`, so the target server environment and profile isolation are verified.
115
+ Use the profile form for any managed service.
105
116
 
106
117
  ## Managed Self-Update
107
118
 
@@ -171,7 +182,7 @@ daemon home also contributes a stable hash to the native service ID, so equal pr
171
182
 
172
183
  Before replacing files, the daemon refuses new work, requires zero local active or queued work, and
173
184
  non-blockingly acquires every host execution slot. It then installs only the server-selected exact
174
- `x.y.z` version with npm using the prefix derived from the running global entrypoint, verifies the installed
185
+ exact `x.y.z` or `x.y.z-beta.n` version with npm using the prefix derived from the running global entrypoint, verifies the installed
175
186
  package version, and schedules the existing native service restart from a detached helper. macOS uses
176
187
  `launchctl kickstart -k` to avoid the `bootout`/`bootstrap` race; Linux uses `systemctl --user --no-block
177
188
  restart`. The helper reports manager failure over IPC. A failed handoff releases both execution barriers and
@@ -195,11 +206,11 @@ Failures are recoverable and keep a bounded code in the machine record:
195
206
  | `update_in_progress` | wait for the active request to reach a terminal state |
196
207
  | `update_timeout` | inspect `crew-daemon status --profile work`, restart if needed, then retry |
197
208
 
198
- Remote downgrade is intentionally unsupported. To roll back, install a known exact release and restart
199
- the service manually:
209
+ The Web UI supports owner/admin rollback to an exact release in the configured channel. When the managed
210
+ control plane is unavailable, install a known exact release and restart the service manually:
200
211
 
201
212
  ```bash
202
- npm install --global --prefix <environment-prefix> --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-x.y.z>
213
+ npm install --global --prefix <environment-prefix> --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-release>
203
214
  crew-daemon restart --profile work
204
215
  crew-daemon status --profile work
205
216
  ```
@@ -8,6 +8,9 @@ import { ManagedServiceConflictError, managedServiceConflict, managedServiceIden
8
8
  import { resolveAutomaticInstallTarget, } from "./automatic-install-target.js";
9
9
  import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
10
10
  import { daemonVersion } from "./machine-info.js";
11
+ import { daemonReleaseProvenance } from "./release-provenance.js";
12
+ import { daemonReleaseChannel, isDaemonReleaseVersion } from "./daemon-release-version.js";
13
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
11
14
  import { discoverExactLegacyService, } from "./automatic-service-migration.js";
12
15
  function profileStorage(platform, userHome, runner) {
13
16
  return {
@@ -175,7 +178,7 @@ export function stableDaemonEntry(target, platform) {
175
178
  ? paths.join(target.npmPrefix, "node_modules", "@nowcrew", "daemon", "dist", "main.js")
176
179
  : paths.join(target.npmPrefix, "lib", "node_modules", "@nowcrew", "daemon", "dist", "main.js");
177
180
  }
178
- export async function installExactDaemonPackage(npmPrefix, version, platform, runner) {
181
+ export async function installExactDaemonPackage(npmPrefix, version, platform, runner, registryUrl) {
179
182
  const command = platform === "win32" ? "npm.cmd" : "npm";
180
183
  const result = await runner(command, [
181
184
  "install",
@@ -185,6 +188,9 @@ export async function installExactDaemonPackage(npmPrefix, version, platform, ru
185
188
  "--ignore-scripts",
186
189
  "--no-audit",
187
190
  "--no-fund",
191
+ ...(registryUrl === undefined ? [] : ["--registry", normalizeDaemonNpmRegistry(registryUrl, {
192
+ allowLoopbackHttp: daemonReleaseChannel(version) === "beta",
193
+ })]),
188
194
  `@nowcrew/daemon@${version}`,
189
195
  ]);
190
196
  if (result.exitCode !== 0) {
@@ -217,9 +223,18 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
217
223
  const target = resolveAutomaticInstallTarget({
218
224
  serverUrl: input.serverUrl,
219
225
  ...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
226
+ ...(input.requestedReleaseTag === undefined ? {} : { requestedReleaseTag: input.requestedReleaseTag }),
227
+ ...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
220
228
  userHome: dependencies.userHome,
221
229
  platform: dependencies.platform,
222
230
  });
231
+ if (!isDaemonReleaseVersion(dependencies.version)) {
232
+ throw new Error(`Invoking daemon package has an invalid release version '${dependencies.version}'`);
233
+ }
234
+ const invokingTag = daemonReleaseProvenance(dependencies.version).releaseTag;
235
+ if (invokingTag !== target.releaseTag) {
236
+ throw new Error(`Invoking daemon release tag '${invokingTag}' does not match target '${target.releaseTag}'`);
237
+ }
223
238
  const spec = buildServiceSpec({
224
239
  platform: dependencies.platform,
225
240
  profile: target.profile,
@@ -251,7 +266,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
251
266
  const lease = await dependencies.acquireInstallationLease(target.npmPrefix);
252
267
  let installFailure;
253
268
  try {
254
- await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner);
269
+ await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner, target.registryUrl);
255
270
  }
256
271
  catch (error) {
257
272
  installFailure = error;
@@ -280,6 +295,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
280
295
  serverUrl: target.serverUrl,
281
296
  machineToken,
282
297
  agentsRoot: target.agentsRoot,
298
+ releaseTag: target.releaseTag,
299
+ ...(target.registryUrl === undefined ? {} : { registryUrl: target.registryUrl }),
283
300
  ...(dependencies.runtimePath === undefined ? {} : { runtimePath: dependencies.runtimePath }),
284
301
  }, target.daemonHome);
285
302
  serviceReplacementStarted = true;
@@ -1,5 +1,6 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
3
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
3
4
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
4
5
  const KNOWN_TARGETS = {
5
6
  "nowwork-in.nowcoder.com": {
@@ -13,6 +14,23 @@ const KNOWN_TARGETS = {
13
14
  agentsDirectory: "agents-dev",
14
15
  },
15
16
  };
17
+ export function releaseTagForServerUrl(serverUrl) {
18
+ const parsed = parseServerUrl(serverUrl);
19
+ const known = KNOWN_TARGETS[parsed.hostname.toLowerCase()];
20
+ return known?.environment === "dev" ? "beta" : "latest";
21
+ }
22
+ export function assertReleaseTagForServerUrl(serverUrl, requested) {
23
+ if (requested === undefined)
24
+ return;
25
+ const parsed = parseServerUrl(serverUrl);
26
+ const known = KNOWN_TARGETS[parsed.hostname.toLowerCase()];
27
+ if (known === undefined)
28
+ return;
29
+ const expected = known.environment === "dev" ? "beta" : "latest";
30
+ if (requested !== expected) {
31
+ throw new Error(`Server hostname '${parsed.hostname}' requires release tag '${expected}'`);
32
+ }
33
+ }
16
34
  function parseServerUrl(value) {
17
35
  let parsed;
18
36
  try {
@@ -52,6 +70,7 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
52
70
  // Keep the no-flag target readable by old daemons; any workspace-scoped profile below
53
71
  // uses the canonical environment token (production, never in).
54
72
  const legacyProfile = environment === "production" ? "in" : environment;
73
+ assertReleaseTagForServerUrl(input.serverUrl, input.requestedReleaseTag);
55
74
  if (requestedProfile === undefined || requestedProfile === legacyProfile) {
56
75
  return Object.freeze({
57
76
  kind: "known",
@@ -60,6 +79,7 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
60
79
  daemonHome: paths.join(input.userHome, ".crew", environment === "production" ? "daemon" : "daemon-dev"),
61
80
  agentsRoot: paths.join(input.userHome, ".crew", environment === "production" ? "agents" : "agents-dev"),
62
81
  npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
82
+ releaseTag: environment === "dev" ? "beta" : "latest",
63
83
  });
64
84
  }
65
85
  if (requestedProfile === "in" || requestedProfile === "dev") {
@@ -88,6 +108,7 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
88
108
  daemonHome: layout.daemonHome,
89
109
  agentsRoot: layout.agentsRoot,
90
110
  npmPrefix: layout.npmPrefix,
111
+ releaseTag: environment === "dev" ? "beta" : "latest",
91
112
  });
92
113
  }
93
114
  export function resolveAutomaticInstallTarget(input) {
@@ -95,8 +116,15 @@ export function resolveAutomaticInstallTarget(input) {
95
116
  const hostname = parsed.hostname.toLowerCase();
96
117
  const paths = input.platform === "win32" ? win32 : posix;
97
118
  const known = KNOWN_TARGETS[hostname];
119
+ const targetReleaseTag = known?.environment === "dev"
120
+ ? "beta"
121
+ : input.requestedReleaseTag ?? "latest";
122
+ const registryUrl = input.requestedRegistryUrl === undefined
123
+ ? undefined
124
+ : normalizeDaemonNpmRegistry(input.requestedRegistryUrl, { allowLoopbackHttp: targetReleaseTag === "beta" });
98
125
  if (known !== undefined) {
99
- return knownTargetForProfile(known.environment, input.requestedProfile, input, parsed);
126
+ const target = knownTargetForProfile(known.environment, input.requestedProfile, input, parsed);
127
+ return registryUrl === undefined ? target : Object.freeze({ ...target, registryUrl });
100
128
  }
101
129
  const profile = requireCustomProfile(hostname, input.requestedProfile);
102
130
  return Object.freeze({
@@ -106,5 +134,7 @@ export function resolveAutomaticInstallTarget(input) {
106
134
  daemonHome: paths.join(input.userHome, ".crew", `daemon-${profile}`),
107
135
  agentsRoot: paths.join(input.userHome, ".crew", `agents-${profile}`),
108
136
  npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
137
+ releaseTag: input.requestedReleaseTag ?? "latest",
138
+ ...(registryUrl === undefined ? {} : { registryUrl }),
109
139
  });
110
140
  }
@@ -4,10 +4,14 @@ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
6
6
  import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, readServiceDescriptor, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
7
+ import { releaseTagForServerUrl, assertReleaseTagForServerUrl } from "./automatic-install-target.js";
8
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
7
9
  import { defaultProcessController } from "./execution-journal.js";
8
10
  import { inspectJournalLock } from "./execution-journal-lock.js";
9
11
  import { detectDaemonLang, formatDaemonText } from "./i18n.js";
10
12
  import { daemonGlobalInstallation } from "./daemon-installation.js";
13
+ import { daemonVersion } from "./machine-info.js";
14
+ import { daemonReleaseProvenance } from "./release-provenance.js";
11
15
  import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
12
16
  import { assertManagedServiceIdentity, buildManagedServiceRecord, installManagedService, uninstallUnregisteredManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
13
17
  import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
@@ -57,11 +61,11 @@ export function isGlobalDaemonEntry(entryPath, globalNodeModules) {
57
61
  function usage(td) {
58
62
  return [
59
63
  td("Computer lifecycle:"),
60
- " crew-daemon profile save <name> --server-url <url> [--agents-root <path>] [--token-stdin]",
64
+ " crew-daemon profile save <name> --server-url <url> [--release-tag <latest|beta>] [--registry <https-url>] [--agents-root <path>] [--token-stdin]",
61
65
  " crew-daemon profile list|show|remove [<name>]",
62
- " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>]",
66
+ " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
63
67
  " crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
64
- " crew-daemon upgrade [--profile <name>]",
68
+ " crew-daemon upgrade [--profile <name>] [--release-tag <latest|beta>] [--registry <https-url>]",
65
69
  "",
66
70
  td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
67
71
  ].join("\n") + "\n";
@@ -105,6 +109,8 @@ export async function runComputerCommand(argv, overrides = {}) {
105
109
  options: {
106
110
  profile: { type: "string" },
107
111
  "server-url": { type: "string" },
112
+ "release-tag": { type: "string" },
113
+ registry: { type: "string" },
108
114
  "agents-root": { type: "string" },
109
115
  "token-stdin": { type: "boolean", default: false },
110
116
  "api-key": { type: "string" },
@@ -118,6 +124,14 @@ export async function runComputerCommand(argv, overrides = {}) {
118
124
  }
119
125
  const [, action, positionalName] = parsed.positionals;
120
126
  const profileName = parsed.values.profile;
127
+ const requestedReleaseTag = parsed.values["release-tag"];
128
+ const requestedRegistryUrl = parsed.values.registry;
129
+ const configuredRegistryUrl = requestedRegistryUrl === undefined
130
+ ? deps.env.CREW_NPM_REGISTRY?.trim() || undefined
131
+ : requestedRegistryUrl;
132
+ if (requestedReleaseTag !== undefined && requestedReleaseTag !== "latest" && requestedReleaseTag !== "beta") {
133
+ throw new Error(td("Invalid release tag; expected latest or beta"));
134
+ }
121
135
  const automaticInstall = command === "install" && parsed.values["server-url"] !== undefined;
122
136
  const tokenOption = parsed.values["api-key"] !== undefined
123
137
  ? "--api-key"
@@ -143,6 +157,10 @@ export async function runComputerCommand(argv, overrides = {}) {
143
157
  serverUrl: parsed.values["server-url"],
144
158
  machineToken,
145
159
  ...(profileName === undefined ? {} : { requestedProfile: profileName }),
160
+ ...(requestedReleaseTag === undefined ? {} : { requestedReleaseTag }),
161
+ ...(configuredRegistryUrl === undefined
162
+ ? {}
163
+ : { requestedRegistryUrl: configuredRegistryUrl }),
146
164
  });
147
165
  deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
148
166
  return 0;
@@ -187,10 +205,19 @@ export async function runComputerCommand(argv, overrides = {}) {
187
205
  const machineToken = (fromStdin ? await deps.readStdin() : deps.env.CREW_MACHINE_TOKEN)?.trim();
188
206
  if (!machineToken)
189
207
  throw new Error(td("Missing machine token; use CREW_MACHINE_TOKEN or --token-stdin"));
208
+ const serverUrl = requireValue(parsed.values["server-url"], "--server-url", td);
209
+ assertReleaseTagForServerUrl(serverUrl, requestedReleaseTag);
210
+ const profileReleaseTag = requestedReleaseTag ?? releaseTagForServerUrl(serverUrl);
190
211
  const profile = {
191
212
  name,
192
- serverUrl: requireValue(parsed.values["server-url"], "--server-url", td),
213
+ serverUrl,
193
214
  machineToken,
215
+ releaseTag: profileReleaseTag,
216
+ ...(configuredRegistryUrl === undefined ? {} : {
217
+ registryUrl: normalizeDaemonNpmRegistry(configuredRegistryUrl, {
218
+ allowLoopbackHttp: profileReleaseTag === "beta",
219
+ }),
220
+ }),
194
221
  ...(parsed.values["agents-root"]
195
222
  ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
196
223
  : {}),
@@ -205,6 +232,19 @@ export async function runComputerCommand(argv, overrides = {}) {
205
232
  const restartProfile = profileName
206
233
  ? await loadProfile(profileName, home, profileStorage)
207
234
  : null;
235
+ if (!restartProfile && requestedReleaseTag !== undefined) {
236
+ const installedReleaseTag = daemonReleaseProvenance(daemonVersion(), deps.env).releaseTag;
237
+ if (requestedReleaseTag !== installedReleaseTag) {
238
+ throw new Error(td("Cross-channel daemon upgrade requires --profile so the server environment can be verified"));
239
+ }
240
+ }
241
+ const releaseTag = requestedReleaseTag
242
+ ?? restartProfile?.releaseTag
243
+ ?? (restartProfile === null
244
+ ? daemonReleaseProvenance(daemonVersion(), deps.env).releaseTag
245
+ : releaseTagForServerUrl(restartProfile.serverUrl));
246
+ if (restartProfile)
247
+ assertReleaseTagForServerUrl(restartProfile.serverUrl, releaseTag);
208
248
  const spec = restartProfile
209
249
  ? buildServiceSpec({
210
250
  ...deps,
@@ -215,7 +255,7 @@ export async function runComputerCommand(argv, overrides = {}) {
215
255
  : null;
216
256
  if (spec && restartProfile) {
217
257
  if (installation === null && !isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
218
- throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
258
+ throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@{{tag}}", { tag: releaseTag }));
219
259
  }
220
260
  const before = await serviceStatus(spec, deps.runner);
221
261
  if (!before.installed)
@@ -236,7 +276,10 @@ export async function runComputerCommand(argv, overrides = {}) {
236
276
  installationLease = installation === null
237
277
  ? null
238
278
  : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome });
239
- await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix);
279
+ await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix, releaseTag, configuredRegistryUrl === undefined
280
+ ? restartProfile?.registryUrl
281
+ ?? configuredRegistryUrl
282
+ : normalizeDaemonNpmRegistry(configuredRegistryUrl, { allowLoopbackHttp: releaseTag === "beta" }));
240
283
  }
241
284
  catch (error) {
242
285
  upgradeError = error;
@@ -274,6 +317,14 @@ export async function runComputerCommand(argv, overrides = {}) {
274
317
  }
275
318
  const name = requireValue(profileName, "--profile", td);
276
319
  const profile = await loadProfile(name, home, profileStorage);
320
+ const profileReleaseTag = requestedReleaseTag ?? profile.releaseTag ?? releaseTagForServerUrl(profile.serverUrl);
321
+ if (command === "install") {
322
+ assertReleaseTagForServerUrl(profile.serverUrl, profileReleaseTag);
323
+ const installedReleaseTag = daemonReleaseProvenance(daemonVersion(), deps.env).releaseTag;
324
+ if (installedReleaseTag !== profileReleaseTag) {
325
+ throw new Error(td("Installed daemon release tag '{{installed}}' does not match profile '{{profile}}' release tag '{{target}}'", { installed: installedReleaseTag, profile: name, target: profileReleaseTag }));
326
+ }
327
+ }
277
328
  if (command === "install" || command === "start" || command === "restart") {
278
329
  await assertProfileAgentsRootUnique(profile, home, deps.userHome, profileStorage);
279
330
  }
@@ -296,7 +347,8 @@ export async function runComputerCommand(argv, overrides = {}) {
296
347
  : null;
297
348
  if (command === "install") {
298
349
  if (installation === null && !isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
299
- throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
350
+ const releaseTag = requestedReleaseTag ?? profile.releaseTag ?? releaseTagForServerUrl(profile.serverUrl);
351
+ throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@{{tag}}", { tag: releaseTag }));
300
352
  }
301
353
  if (managedRecord === null)
302
354
  await installService(spec, deps.runner);
@@ -8,33 +8,57 @@ import { z } from "zod";
8
8
  import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
9
9
  import { formatDaemonText } from "./i18n.js";
10
10
  import { atomicPrivateWrite } from "./atomic-private-write.js";
11
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
11
12
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
12
13
  export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
13
14
  const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
14
15
  const url = new URL(value);
15
16
  return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
16
17
  }, "serverUrl must be an HTTP(S) URL without embedded credentials");
17
- const ProfileSchema = z.object({
18
+ const ProfileFields = z.object({
18
19
  version: z.literal(1),
19
20
  name: z.string().regex(PROFILE_NAME),
20
21
  serverUrl: ServerUrlSchema,
21
22
  machineToken: MachineTokenSchema,
22
23
  agentsRoot: z.string().min(1).optional(),
23
24
  runtimePath: z.string().min(1).max(32768).refine((value) => !value.includes("\0") && !value.includes("\n") && !value.includes("\r"), "runtimePath must be a single line").optional(),
25
+ releaseTag: z.enum(["latest", "beta"]).optional(),
26
+ registryUrl: z.string().url().optional(),
24
27
  codexHomeMode: z.enum(["shared", "private"]).optional(),
25
28
  }).strict();
26
- const ProfileAgentsRootCandidateSchema = ProfileSchema.pick({
29
+ function validateProfileRegistry(value, context) {
30
+ const fields = value;
31
+ if (fields.registryUrl === undefined)
32
+ return;
33
+ try {
34
+ const normalized = normalizeDaemonNpmRegistry(fields.registryUrl, {
35
+ allowLoopbackHttp: fields.releaseTag === "beta",
36
+ });
37
+ if (normalized !== fields.registryUrl) {
38
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["registryUrl"], message: "registryUrl must be normalized" });
39
+ }
40
+ }
41
+ catch (error) {
42
+ context.addIssue({
43
+ code: z.ZodIssueCode.custom,
44
+ path: ["registryUrl"],
45
+ message: error instanceof Error ? error.message : "registryUrl must be HTTPS",
46
+ });
47
+ }
48
+ }
49
+ const ProfileSchema = ProfileFields.superRefine(validateProfileRegistry);
50
+ const ProfileAgentsRootCandidateSchema = ProfileFields.pick({
27
51
  name: true,
28
52
  serverUrl: true,
29
53
  agentsRoot: true,
30
54
  }).strict();
31
55
  const StoredPlainProfileSchema = ProfileSchema;
32
- const StoredProtectedProfileSchema = ProfileSchema.omit({ machineToken: true }).extend({
56
+ const StoredProtectedProfileSchema = ProfileFields.omit({ machineToken: true }).extend({
33
57
  machineTokenProtected: z.object({
34
58
  scheme: z.literal("dpapi-current-user"),
35
59
  ciphertext: z.string().min(1).max(16384),
36
60
  }).strict(),
37
- }).strict();
61
+ }).strict().superRefine(validateProfileRegistry);
38
62
  const StoredProfileSchema = z.union([StoredPlainProfileSchema, StoredProtectedProfileSchema]);
39
63
  export const PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE = "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}";
40
64
  export class ProfileAgentsRootConflictError extends Error {
@@ -218,6 +242,8 @@ export async function saveProfile(input, home = daemonHome(), options = {}) {
218
242
  serverUrl: profile.serverUrl,
219
243
  ...(profile.agentsRoot ? { agentsRoot: profile.agentsRoot } : {}),
220
244
  ...(profile.runtimePath ? { runtimePath: profile.runtimePath } : {}),
245
+ ...(profile.releaseTag === undefined ? {} : { releaseTag: profile.releaseTag }),
246
+ ...(profile.registryUrl === undefined ? {} : { registryUrl: profile.registryUrl }),
221
247
  ...(profile.codexHomeMode === undefined ? {} : { codexHomeMode: profile.codexHomeMode }),
222
248
  machineTokenProtected: { scheme: "dpapi-current-user", ciphertext },
223
249
  });
@@ -261,6 +287,8 @@ export async function loadProfile(name, home = daemonHome(), options = {}) {
261
287
  serverUrl: stored.serverUrl,
262
288
  ...(stored.agentsRoot ? { agentsRoot: stored.agentsRoot } : {}),
263
289
  ...(stored.runtimePath ? { runtimePath: stored.runtimePath } : {}),
290
+ ...(stored.releaseTag === undefined ? {} : { releaseTag: stored.releaseTag }),
291
+ ...(stored.registryUrl === undefined ? {} : { registryUrl: stored.registryUrl }),
264
292
  machineToken: await configured.protector.unprotect(stored.machineTokenProtected.ciphertext),
265
293
  });
266
294
  }
@@ -355,5 +383,13 @@ export function applyProfileToEnv(profile, env) {
355
383
  delete env.CREW_AGENTS_ROOT;
356
384
  if (profile.runtimePath)
357
385
  env.PATH = profile.runtimePath;
386
+ if (profile.releaseTag)
387
+ env.CREW_DAEMON_RELEASE_TAG = profile.releaseTag;
388
+ else
389
+ delete env.CREW_DAEMON_RELEASE_TAG;
390
+ if (profile.registryUrl)
391
+ env.CREW_NPM_REGISTRY = profile.registryUrl;
392
+ else
393
+ delete env.CREW_NPM_REGISTRY;
358
394
  env.CREW_CODEX_HOME_MODE = profile.codexHomeMode ?? "shared";
359
395
  }
@@ -4,6 +4,7 @@ import { execFile, spawn } from "node:child_process";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { promisify } from "node:util";
6
6
  import { dirname, resolve } from "node:path";
7
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  const WINDOWS_STOP_TIMEOUT_MS = 5_000;
9
10
  const WINDOWS_STOP_POLL_MS = 100;
@@ -541,19 +542,23 @@ export async function doctorService(spec, profilePrivate, runner = systemCommand
541
542
  { name: "service-running", ok: status.running === true, detail: status.running ? "running" : "not running" },
542
543
  ];
543
544
  }
544
- export function buildUpgradeCommand(platform, npmPrefix) {
545
+ export function buildUpgradeCommand(platform, npmPrefix, releaseTag = "latest", registryUrl) {
545
546
  return [
546
547
  platform === "win32" ? "npm.cmd" : "npm",
547
548
  [
548
549
  "install",
549
550
  "--global",
551
+ "--ignore-scripts",
552
+ "--no-audit",
553
+ "--no-fund",
550
554
  ...(npmPrefix === undefined ? [] : ["--prefix", npmPrefix]),
551
- "@nowcrew/daemon@latest",
555
+ ...(registryUrl === undefined ? [] : ["--registry", normalizeDaemonNpmRegistry(registryUrl, { allowLoopbackHttp: releaseTag === "beta" })]),
556
+ `@nowcrew/daemon@${releaseTag}`,
552
557
  ],
553
558
  ];
554
559
  }
555
- export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix) {
556
- const [command, args] = buildUpgradeCommand(platform, npmPrefix);
560
+ export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix, releaseTag = "latest", registryUrl) {
561
+ const [command, args] = buildUpgradeCommand(platform, npmPrefix, releaseTag, registryUrl);
557
562
  await checked(runner, command, args, "Daemon upgrade");
558
563
  }
559
564
  const WINDOWS_SID_SCRIPT = "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
@@ -119,10 +119,12 @@ export function createDaemonMigrationController(input) {
119
119
  let currentSpec;
120
120
  let currentService;
121
121
  let managedRecord = null;
122
+ let currentRegistryUrl;
122
123
  try {
123
124
  const currentProfile = await (input.loadCurrentProfile ?? loadProfile)(input.profileName, input.currentDaemonHome);
124
125
  if (currentProfile.machineToken !== input.machineToken)
125
126
  throw new Error("active profile token does not match this daemon");
127
+ currentRegistryUrl = currentProfile.registryUrl;
126
128
  const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: message.targetProfile, userHome, platform });
127
129
  if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
128
130
  || resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
@@ -165,6 +167,7 @@ export function createDaemonMigrationController(input) {
165
167
  serverUrl: input.serverUrl,
166
168
  machineToken: input.machineToken,
167
169
  requestedProfile: message.targetProfile,
170
+ ...(currentRegistryUrl === undefined ? {} : { requestedRegistryUrl: currentRegistryUrl }),
168
171
  });
169
172
  if (result.profile !== message.targetProfile || result.serviceId.length === 0) {
170
173
  throw new Error("target service identity did not match the requested profile");
@@ -0,0 +1,22 @@
1
+ import { compare, prerelease, valid } from "semver";
2
+ const CANONICAL_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta(?:\.|-)(0|[1-9]\d*))?$/u;
3
+ function normalizedVersion(input) {
4
+ return input.replace(/-beta-(0|[1-9]\d*)$/u, "-beta.$1");
5
+ }
6
+ export function isDaemonReleaseVersion(input) {
7
+ const raw = input.trim();
8
+ if (raw !== input || !CANONICAL_VERSION.test(raw) || valid(raw) !== raw)
9
+ return false;
10
+ const parts = prerelease(raw);
11
+ return parts === null || normalizedVersion(raw).includes("-beta.");
12
+ }
13
+ export function daemonReleaseChannel(version) {
14
+ if (!isDaemonReleaseVersion(version))
15
+ return null;
16
+ return prerelease(version) === null ? "stable" : "beta";
17
+ }
18
+ export function compareDaemonReleaseVersions(left, right) {
19
+ return isDaemonReleaseVersion(left) && isDaemonReleaseVersion(right)
20
+ ? compare(normalizedVersion(left), normalizedVersion(right))
21
+ : 0;
22
+ }
@@ -1,13 +1,14 @@
1
1
  import { z } from "zod";
2
+ import { isDaemonReleaseVersion } from "./daemon-release-version.js";
2
3
  const DaemonUpdateMessageSchema = z.object({
3
4
  type: z.literal("daemon:update"),
4
5
  updateId: z.string().uuid(),
5
- targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
6
+ targetVersion: z.string().refine(isDaemonReleaseVersion, "invalid daemon release version"),
6
7
  }).strict();
7
8
  const DaemonRestartMessageSchema = z.object({
8
9
  type: z.literal("daemon:restart"),
9
10
  updateId: z.string().uuid(),
10
- targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
11
+ targetVersion: z.string().refine(isDaemonReleaseVersion, "invalid daemon release version"),
11
12
  }).strict();
12
13
  const DaemonControlMessageSchema = z.union([DaemonUpdateMessageSchema, DaemonRestartMessageSchema]);
13
14
  export function createDaemonUpdateController(deps) {
@@ -2,7 +2,19 @@ import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { systemCommandRunner } from "./computer-service.js";
4
4
  import { daemonGlobalInstallation } from "./daemon-installation.js";
5
- const RELEASED_VERSION_RE = /^\d+\.\d+\.\d+$/;
5
+ import { daemonReleaseChannel, isDaemonReleaseVersion } from "./daemon-release-version.js";
6
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
7
+ function configuredRegistryArgs(allowLoopbackHttp) {
8
+ try {
9
+ return process.env.CREW_NPM_REGISTRY === undefined ? [] : [
10
+ "--registry",
11
+ normalizeDaemonNpmRegistry(process.env.CREW_NPM_REGISTRY, { allowLoopbackHttp }),
12
+ ];
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
6
18
  export async function prepareDaemonRestart(input) {
7
19
  const local = input.localSlots.tryAcquireExclusive();
8
20
  if (local === null)
@@ -28,8 +40,32 @@ async function readPackageVersion(packageRoot) {
28
40
  const body = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
29
41
  return typeof body.version === "string" ? body.version : "";
30
42
  }
43
+ async function restoreInstalledVersion(input, runner, version, registryArgs) {
44
+ if (!isDaemonReleaseVersion(version))
45
+ return false;
46
+ const result = await runner((input.platform ?? process.platform) === "win32" ? "npm.cmd" : "npm", [
47
+ "install",
48
+ "--global",
49
+ "--prefix",
50
+ input.npmPrefix,
51
+ "--ignore-scripts",
52
+ "--no-audit",
53
+ "--no-fund",
54
+ ...registryArgs,
55
+ `@nowcrew/daemon@${version}`,
56
+ ]).catch(() => null);
57
+ if (result?.exitCode !== 0)
58
+ return false;
59
+ try {
60
+ const installedVersion = await (input.readInstalledVersion ?? readPackageVersion)(input.packageRoot);
61
+ return installedVersion === version;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
31
67
  export async function installExactDaemonUpdate(input) {
32
- if (!RELEASED_VERSION_RE.test(input.targetVersion)) {
68
+ if (!isDaemonReleaseVersion(input.targetVersion)) {
33
69
  return { ok: false, errorCode: "ineligible" };
34
70
  }
35
71
  const installation = daemonGlobalInstallation(input.currentEntryPath ?? process.argv[1] ?? "", input.platform ?? process.platform);
@@ -62,6 +98,25 @@ export async function installExactDaemonUpdate(input) {
62
98
  return { ok: false, errorCode: "ineligible" };
63
99
  }
64
100
  const runner = input.runner ?? systemCommandRunner;
101
+ let previousVersion = "";
102
+ try {
103
+ previousVersion = await (input.readInstalledVersion ?? readPackageVersion)(input.packageRoot);
104
+ }
105
+ catch {
106
+ // Do not replace a package when there is no verified version to restore.
107
+ await release();
108
+ return { ok: false, errorCode: "install_failed" };
109
+ }
110
+ if (!isDaemonReleaseVersion(previousVersion)) {
111
+ await release();
112
+ return { ok: false, errorCode: "install_failed" };
113
+ }
114
+ const registryArgs = configuredRegistryArgs(daemonReleaseChannel(previousVersion) === "beta"
115
+ || daemonReleaseChannel(input.targetVersion) === "beta");
116
+ if (registryArgs === null) {
117
+ await release();
118
+ return { ok: false, errorCode: "install_failed" };
119
+ }
65
120
  try {
66
121
  await input.onInstalling?.();
67
122
  }
@@ -77,6 +132,7 @@ export async function installExactDaemonUpdate(input) {
77
132
  "--ignore-scripts",
78
133
  "--no-audit",
79
134
  "--no-fund",
135
+ ...registryArgs,
80
136
  `@nowcrew/daemon@${input.targetVersion}`,
81
137
  ]).catch(() => null);
82
138
  if (result === null || result.exitCode !== 0) {
@@ -91,6 +147,10 @@ export async function installExactDaemonUpdate(input) {
91
147
  // Version verification is authoritative; unreadable package metadata is a mismatch.
92
148
  }
93
149
  if (installedVersion !== input.targetVersion) {
150
+ const restored = await restoreInstalledVersion(input, runner, previousVersion, registryArgs);
151
+ if (!restored) {
152
+ console.error(`[daemon-update] version verification failed; restore of ${previousVersion} was unsuccessful`);
153
+ }
94
154
  await release();
95
155
  return { ok: false, errorCode: "version_mismatch" };
96
156
  }
package/dist/i18n.js CHANGED
@@ -51,12 +51,14 @@ const zh = {
51
51
  "Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.": "daemon 已升级,并已请求重启 '{{name}}';请用 status 确认。",
52
52
  "Refused daemon upgrade for '{{name}}': {{reason}}": "已拒绝升级 daemon '{{name}}':{{reason}}",
53
53
  "Upgraded daemon. Installed services were not restarted; pass --profile to restart one.": "daemon 已升级;已安装服务尚未重启,可传入 --profile 重启指定服务。",
54
+ "Cross-channel daemon upgrade requires --profile so the server environment can be verified": "跨发布渠道升级 daemon 需要传入 --profile,以便校验服务端环境。",
54
55
  "Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry": "服务生命周期必须使用已构建的 daemon 入口(.js),不能使用 TypeScript 开发入口",
56
+ "Installed daemon release tag '{{installed}}' does not match profile '{{profile}}' release tag '{{target}}'": "已安装 daemon 的发布标签 '{{installed}}' 与配置 '{{profile}}' 要求的发布标签 '{{target}}' 不一致",
55
57
  "Installed '{{id}}'. Use status to confirm runtime state.": "已安装 '{{id}}';请用 status 确认运行状态。",
56
58
  "Uninstalled '{{id}}'.": "已卸载 '{{id}}'。",
57
59
  "{{action}} request accepted for '{{id}}'. Verify with status.": "已接受对 '{{id}}' 的 {{action}} 请求;请用 status 确认。",
58
60
  "--token-stdin requires a token on standard input": "--token-stdin 需要从标准输入读取令牌",
59
- "Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest": "服务生命周期需要全局安装 @nowcrew/daemon;请运行 npm install --global @nowcrew/daemon@latest",
61
+ "Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@{{tag}}": "服务生命周期需要全局安装 @nowcrew/daemon;请运行 npm install --global @nowcrew/daemon@{{tag}}",
60
62
  "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}": "配置 '{{profile}}' 与配置 '{{conflict}}' 解析到了同一个 agents root '{{agentsRoot}}'。请保存为唯一目录,例如:{{command}}",
61
63
  "Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If this profile is managed as a service, use 'crew-daemon stop --profile {{profileName}}' or 'crew-daemon restart --profile {{profileName}}'.": "daemon 已在运行(PID={{ownerPid}}),agents root 为 '{{agentsRoot}}'。日志目录:'{{journalPath}}'。不要删除活跃进程持有的 journal 锁。请先停止现有 daemon 再重试;如果此 profile 由系统服务管理,请使用 'crew-daemon stop --profile {{profileName}}' 或 'crew-daemon restart --profile {{profileName}}'。",
62
64
  "Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If it is managed as a service, use 'crew-daemon stop --profile <name>' or 'crew-daemon restart --profile <name>'.": "daemon 已在运行(PID={{ownerPid}}),agents root 为 '{{agentsRoot}}'。日志目录:'{{journalPath}}'。不要删除活跃进程持有的 journal 锁。请先停止现有 daemon 再重试;如果它由系统服务管理,请使用 'crew-daemon stop --profile <name>' 或 'crew-daemon restart --profile <name>'。",
@@ -20,6 +20,7 @@ import { probeOpenCodeRun } from "./runtimes/opencode.js";
20
20
  import { RUNTIME_HEALTH_PROBE_CAPABILITY } from "./runtime-health.js";
21
21
  import { probeDeepSeekHarnessAcp } from "./runtimes/deepseek-harness.js";
22
22
  import { daemonHome } from "./computer-profile.js";
23
+ import { daemonReleaseProvenance } from "./release-provenance.js";
23
24
  export { executableRuntimes } from "./runtime-capabilities.js";
24
25
  const execFileP = promisify(execFile);
25
26
  export const PROJECT_WORKSPACES_CAPABILITY = "project_workspaces_v1";
@@ -182,6 +183,15 @@ export async function collectMachineHello(agentsRoot, executionLimits, runtimePl
182
183
  hostname: hostname(),
183
184
  os: `${osPlatform()} ${arch()}`,
184
185
  daemonVersion: daemonVersion(),
186
+ ...(() => {
187
+ const provenance = daemonReleaseProvenance(daemonVersion());
188
+ return {
189
+ releaseChannel: provenance.releaseChannel,
190
+ releaseTag: provenance.releaseTag,
191
+ ...(provenance.gitSha === null ? {} : { gitSha: provenance.gitSha }),
192
+ ...(provenance.artifactDigest === null ? {} : { artifactDigest: provenance.artifactDigest }),
193
+ };
194
+ })(),
185
195
  runtimes,
186
196
  executionRuntimes,
187
197
  capabilities,
package/dist/main.js CHANGED
@@ -11,6 +11,8 @@ import { join } from "node:path";
11
11
  import { loadConfig, ConfigError } from "./config.js";
12
12
  import { detectDaemonLang, formatDaemonText, translateDaemon } from "./i18n.js";
13
13
  import { cliVersion, daemonVersion } from "./machine-info.js";
14
+ import { daemonReleaseProvenance } from "./release-provenance.js";
15
+ import { assertReleaseTagForServerUrl } from "./automatic-install-target.js";
14
16
  import { runAgent } from "./runner.js";
15
17
  import { serve } from "./serve.js";
16
18
  import { initSlog, flushSlog } from "./slog.js";
@@ -57,11 +59,12 @@ async function main() {
57
59
  "daemon-home": { type: "string" },
58
60
  },
59
61
  });
60
- // 无子命令时默认 serve(对齐 `npx @nowcrew/daemon@latest --server-url ... --api-key ...`)
62
+ // 无子命令时默认 serve(对齐当前发布 channel 的 npx 入口)
61
63
  const cmd = positionals[0] ?? "serve";
62
64
  if (cmd !== "run" && cmd !== "serve") {
65
+ const releaseTag = daemonReleaseProvenance(daemonVersion()).releaseTag;
63
66
  process.stderr.write(td("Usage:") + "\n" +
64
- " npx @nowcrew/daemon@latest --server-url <url> --api-key <sk_machine_*> # " + td("connect and stay resident") + "\n" +
67
+ ` npx @nowcrew/daemon@${releaseTag} --server-url <url> --api-key <sk_machine_*> # ` + td("connect and stay resident") + "\n" +
65
68
  " crew-daemon run --agent <h> --channel <id> [--wake ...] # " + td("run once manually") + "\n");
66
69
  process.exit(2);
67
70
  }
@@ -112,8 +115,10 @@ async function main() {
112
115
  }
113
116
  throw e;
114
117
  }
118
+ const provenance = daemonReleaseProvenance(daemonVersion());
119
+ assertReleaseTagForServerUrl(config.serverUrl, provenance.releaseTag);
115
120
  if (cmd === "serve") {
116
- process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
121
+ process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}, ${provenance.releaseChannel}/${provenance.releaseTag}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
117
122
  const installation = values.profile === undefined
118
123
  ? null
119
124
  : daemonGlobalInstallation(builtDaemonEntry(), process.platform);
@@ -199,6 +204,10 @@ async function main() {
199
204
  initSlog(config.serverUrl, config.machineToken, {
200
205
  daemonVersion: daemonVersion(),
201
206
  cliVersion: cliVersion(),
207
+ releaseChannel: provenance.releaseChannel,
208
+ releaseTag: provenance.releaseTag,
209
+ gitSha: provenance.gitSha,
210
+ artifactDigest: provenance.artifactDigest,
202
211
  ...(values.profile === undefined ? {} : { profileName: values.profile }),
203
212
  agentsRoot: config.agentsRoot,
204
213
  }); // 一次性 run 模式也上报 SLS(runner 里的埋点生效)
@@ -0,0 +1,32 @@
1
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
2
+ function isLoopbackHostname(hostname) {
3
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
4
+ }
5
+ export function normalizeDaemonNpmRegistry(value, options = {}) {
6
+ const raw = value?.trim();
7
+ if (!raw)
8
+ return DEFAULT_NPM_REGISTRY;
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(raw);
12
+ }
13
+ catch {
14
+ throw new Error("npm registry must be an HTTPS URL without credentials");
15
+ }
16
+ if (!/^[A-Za-z0-9._~/%-]*$/u.test(parsed.pathname)) {
17
+ throw new Error("npm registry must be an HTTPS URL without credentials or unsafe path characters");
18
+ }
19
+ if ((parsed.protocol !== "https:" && !(parsed.protocol === "http:"
20
+ && isLoopbackHostname(parsed.hostname)
21
+ && options.allowLoopbackHttp === true))
22
+ || parsed.username !== ""
23
+ || parsed.password !== ""
24
+ || parsed.search !== ""
25
+ || parsed.hash !== "") {
26
+ throw new Error("npm registry must be an HTTPS URL without credentials");
27
+ }
28
+ return parsed.toString().replace(/\/+$/u, "");
29
+ }
30
+ export function npmRegistryArgs(value) {
31
+ return ["--registry", normalizeDaemonNpmRegistry(value)];
32
+ }
@@ -0,0 +1,60 @@
1
+ import { cliVersion, daemonVersion } from "./machine-info.js";
2
+ import { daemonReleaseProvenance } from "./release-provenance.js";
3
+ import { dslog, initSlog } from "./slog.js";
4
+ export function initReleaseSlog(input) {
5
+ const provenance = daemonReleaseProvenance(daemonVersion());
6
+ initSlog(input.serverUrl, input.machineToken, {
7
+ daemonVersion: daemonVersion(),
8
+ cliVersion: cliVersion(),
9
+ releaseChannel: provenance.releaseChannel,
10
+ releaseTag: provenance.releaseTag,
11
+ gitSha: provenance.gitSha,
12
+ artifactDigest: provenance.artifactDigest,
13
+ ...(input.profileName === undefined ? {} : { profileName: input.profileName }),
14
+ agentsRoot: input.agentsRoot,
15
+ });
16
+ }
17
+ export function reportReleaseChannelError(log) {
18
+ const provenance = daemonReleaseProvenance(daemonVersion());
19
+ log(`🛑 线上环境只允许正式版 daemon,当前 ${provenance.releaseTag} 包不可连接。`);
20
+ dslog("daemon.ws_release_rejected", "线上控制面拒绝非正式 daemon 包", {
21
+ level: "ERROR",
22
+ release_channel: provenance.releaseChannel,
23
+ release_tag: provenance.releaseTag,
24
+ });
25
+ }
26
+ export function reportReleaseArtifactError(log) {
27
+ log("🛑 daemon artifact digest 未通过 server allowlist,当前安装包不能连接控制面。");
28
+ dslog("daemon.ws_artifact_rejected", "控制面拒绝未获批准的 daemon artifact", {
29
+ level: "ERROR",
30
+ });
31
+ }
32
+ export function reportReleaseChannelClose(code, log) {
33
+ if (code !== 4002)
34
+ return false;
35
+ log("🛑 线上环境只允许正式版 daemon,beta 包不能连接控制面 (code 4002)。");
36
+ return true;
37
+ }
38
+ export function reportReleaseError(code, log) {
39
+ if (code === "DAEMON_RELEASE_CHANNEL_MISMATCH") {
40
+ reportReleaseChannelError(log);
41
+ return true;
42
+ }
43
+ if (code === "DAEMON_RELEASE_ARTIFACT_MISMATCH") {
44
+ reportReleaseArtifactError(log);
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ export function reportDaemonConnectionClose(code, log) {
50
+ if (code === 4001) {
51
+ log("🛑 控制面以鉴权失败关闭连接 (code 4001):机器凭证无效或已吊销。");
52
+ log(" 请在 NowWork 重新 \"Add Computer\" 获取新连接命令再重跑(当前 --api-key 已失效)。");
53
+ return true;
54
+ }
55
+ if (code === 4003) {
56
+ reportReleaseArtifactError(log);
57
+ return true;
58
+ }
59
+ return reportReleaseChannelClose(code, log);
60
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { daemonReleaseChannel } from "./daemon-release-version.js";
6
+ function validSha(value) {
7
+ return value !== undefined && /^[a-f0-9]{40}$/u.test(value) ? value : null;
8
+ }
9
+ function validDigest(value) {
10
+ return value !== undefined && /^[a-f0-9]{64}$/u.test(value) ? value : null;
11
+ }
12
+ function collectArtifactFiles(root, directory, files) {
13
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
14
+ const absolutePath = resolve(directory, entry.name);
15
+ if (entry.isDirectory()) {
16
+ if (!collectArtifactFiles(root, absolutePath, files))
17
+ return false;
18
+ continue;
19
+ }
20
+ // Reject links and special files so local and packed artifacts share one
21
+ // regular-file-only digest contract.
22
+ if (!entry.isFile())
23
+ return false;
24
+ files.push({
25
+ relativePath: relative(root, absolutePath).split(sep).join("/"),
26
+ bytes: readFileSync(absolutePath),
27
+ });
28
+ }
29
+ return true;
30
+ }
31
+ function artifactDigestState(distDirectory) {
32
+ let distStat;
33
+ try {
34
+ distStat = lstatSync(distDirectory);
35
+ }
36
+ catch (error) {
37
+ return error.code === "ENOENT"
38
+ ? { exists: false, digest: null }
39
+ : { exists: true, digest: null };
40
+ }
41
+ if (!distStat.isDirectory())
42
+ return { exists: true, digest: null };
43
+ try {
44
+ const files = [];
45
+ if (!collectArtifactFiles(distDirectory, distDirectory, files))
46
+ return { exists: true, digest: null };
47
+ files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
48
+ const hash = createHash("sha256");
49
+ for (const file of files) {
50
+ hash.update(file.relativePath, "utf8");
51
+ hash.update(Buffer.from([0]));
52
+ hash.update(file.bytes);
53
+ hash.update(Buffer.from([0]));
54
+ }
55
+ return { exists: true, digest: hash.digest("hex") };
56
+ }
57
+ catch {
58
+ return { exists: true, digest: null };
59
+ }
60
+ }
61
+ /** Compute the canonical content digest for a daemon dist directory. */
62
+ export function computeArtifactContentSha256(distDirectory) {
63
+ return artifactDigestState(distDirectory).digest;
64
+ }
65
+ function defaultDistDirectory() {
66
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
67
+ return basename(moduleDirectory) === "dist" ? moduleDirectory : resolve(moduleDirectory, "../dist");
68
+ }
69
+ export function daemonReleaseProvenance(version, env = process.env, options = {}) {
70
+ const channel = daemonReleaseChannel(version) ?? "stable";
71
+ const canonicalTag = channel === "beta" ? "beta" : "latest";
72
+ // The package version is the artifact identity. An inherited environment tag
73
+ // is advisory and must not create a stable/beta mismatch in hello logs.
74
+ const configuredTag = env.CREW_DAEMON_RELEASE_TAG;
75
+ const releaseTag = configuredTag === canonicalTag ? configuredTag : canonicalTag;
76
+ const localArtifact = artifactDigestState(options.distDirectory ?? defaultDistDirectory());
77
+ return Object.freeze({
78
+ packageName: "@nowcrew/daemon",
79
+ packageVersion: version,
80
+ releaseChannel: channel,
81
+ releaseTag,
82
+ gitSha: validSha(env.CREW_DAEMON_GIT_SHA),
83
+ artifactDigest: localArtifact.exists
84
+ ? localArtifact.digest
85
+ : validDigest(env.CREW_DAEMON_ARTIFACT_DIGEST),
86
+ });
87
+ }
package/dist/serve.js CHANGED
@@ -5,10 +5,11 @@
5
5
  import { WebSocket } from "ws";
6
6
  import { join } from "node:path";
7
7
  import { randomUUID } from "node:crypto";
8
- import { initSlog, dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
8
+ import { dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
9
9
  import { mergeRunAgentResults, reportScheduledStartFailure, runAgent } from "./runner.js";
10
10
  import { buildOriginDecisionRetryPrompt, buildScheduledPrompt } from "./prompt.js";
11
- import { collectMachineHello, cliVersion, daemonCapabilities, daemonVersion, detectExecutionRuntimesWithSignal, } from "./machine-info.js";
11
+ import { collectMachineHello, daemonCapabilities, detectExecutionRuntimesWithSignal, } from "./machine-info.js";
12
+ import { initReleaseSlog, reportDaemonConnectionClose, reportReleaseError } from "./release-channel-guard.js";
12
13
  import { conservativeExecutionRuntimes, createRuntimeProbeCoordinator, } from "./runtime-probe.js";
13
14
  import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
14
15
  import { listSkills } from "./skills.js";
@@ -311,7 +312,7 @@ export function serve(config, opts = {}) {
311
312
  reservation.release();
312
313
  };
313
314
  let connectedAt = 0; // 本次 WS 连接建立时刻(断开日志算在线时长用)
314
- initSlog(config.serverUrl, config.machineToken, { daemonVersion: daemonVersion(), cliVersion: cliVersion(), ...(opts.profileName === undefined ? {} : { profileName: opts.profileName }), agentsRoot: config.agentsRoot });
315
+ initReleaseSlog({ serverUrl: config.serverUrl, machineToken: config.machineToken, ...(opts.profileName === undefined ? {} : { profileName: opts.profileName }), agentsRoot: config.agentsRoot });
315
316
  dslog("daemon.start", "daemon 常驻模式启动", { server_url: config.serverUrl, runtime: config.runtimeBin });
316
317
  // 并行调度:同一 agent 可并行处理多个【不同任务】(线程/频道),每任务隔离 cwd+work-log。
317
318
  // scheduled 重复 run 仍由 running 去重;普通同线程 wake 用 legacyTaskTails 串成 FIFO。
@@ -854,6 +855,8 @@ export function serve(config, opts = {}) {
854
855
  dslog("daemon.ws_auth_rejected", "控制面拒绝鉴权:机器凭证无效或已吊销", { level: "ERROR" });
855
856
  backoff = maxBackoff; // 退避到最大,停止每秒重连刷屏
856
857
  }
858
+ if (reportReleaseError(msg.code, log))
859
+ backoff = maxBackoff;
857
860
  return;
858
861
  }
859
862
  if (msg.type === "project:add"
@@ -1309,11 +1312,8 @@ export function serve(config, opts = {}) {
1309
1312
  completionRetransmitter.pause();
1310
1313
  // 4001 = 控制面应用级「鉴权失败」关闭码(见 server control-plane.ts)。即使上面的
1311
1314
  // error 帧因 close 抢先而丢失,也能据关闭码识别这是凭证失效——退避拉满,不再每秒热循环。
1312
- if (code === 4001) {
1315
+ if (reportDaemonConnectionClose(code, log))
1313
1316
  backoff = maxBackoff;
1314
- log(`🛑 控制面以鉴权失败关闭连接 (code 4001):机器凭证无效或已吊销。`);
1315
- log(` 请在 NowWork 重新 "Add Computer" 获取新连接命令再重跑(当前 --api-key 已失效)。`);
1316
- }
1317
1317
  log(`🔁 控制面断开,${Math.round(backoff / 1000)}s 后重连`);
1318
1318
  // 此刻 server 大概率不可达 → 这条会落 spool,重连 drainSpool 时补传;
1319
1319
  // ts 是现在(断开时刻),排查「daemon 为什么断」以它对齐 server 侧 machine_disconnected。
package/dist/slog.js CHANGED
@@ -59,6 +59,10 @@ export function initSlog(serverUrl, machineToken, identity = {}) {
59
59
  pid: process.pid,
60
60
  daemon_version: identity.daemonVersion,
61
61
  cli_version: identity.cliVersion,
62
+ release_channel: identity.releaseChannel,
63
+ release_tag: identity.releaseTag,
64
+ daemon_git_sha: identity.gitSha,
65
+ daemon_artifact_digest: identity.artifactDigest,
62
66
  daemon_profile: identity.profileName,
63
67
  server_host: serverHost,
64
68
  env: deploymentEnvironment(serverHost),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.74",
3
+ "version": "0.6.75",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -18,8 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@agentclientprotocol/sdk": "1.2.1",
21
- "@nowcrew/cli": "^0.4.13",
21
+ "@nowcrew/cli": "0.4.13",
22
22
  "cross-spawn": "^7.0.6",
23
+ "semver": "7.8.5",
23
24
  "ws": "^8",
24
25
  "yaml": "^2.8.1",
25
26
  "zod": "^3.23.0"
@@ -30,6 +31,7 @@
30
31
  "devDependencies": {
31
32
  "@types/cross-spawn": "^6.0.6",
32
33
  "@types/node": "^22.0.0",
34
+ "@types/semver": "7.7.1",
33
35
  "@types/ws": "^8",
34
36
  "tsx": "^4.19.0",
35
37
  "typescript": "^5.6.0",