@nowcrew/daemon 0.6.77-beta.6 → 0.6.77-beta.8

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
@@ -479,6 +479,26 @@ failure matrix, rollout limits, and the server-native follow-up design.
479
479
 
480
480
  ## Code Map
481
481
 
482
+ ## Local daemon installation root (experimental)
483
+
484
+ New installations use `~/.nowwork` for machine-local daemon configuration and npm runtimes. The
485
+ Workspace persistence root is still selected independently by `NOWWORK_HOME`; it may be a local
486
+ directory or an ECS-specific NAS directory. Runtime packages, locks, sockets, PIDs and temporary
487
+ files must remain on the machine running the daemon.
488
+
489
+ The selector is controlled by `NOWWORK_LOCAL_HOME_MODE`:
490
+
491
+ - `auto` (default): use an existing `.nowwork` installation, otherwise preserve an existing `.crew`
492
+ installation; a brand-new profile is created under `.nowwork`.
493
+ - `nowwork`: require the `.nowwork` installation root.
494
+ - `legacy`: force the old `.crew` root for rollback.
495
+
496
+ `NOWWORK_STORAGE_MODE` is accepted as a compatibility alias. `NOWWORK_LOCAL_HOME` can override the
497
+ local root and should point to a local filesystem, never to the NAS Workspace root; the mount/device
498
+ check remains a doctor/migration gate. If the same profile
499
+ is found in both roots, startup/install is refused until the duplicate is resolved explicitly. The
500
+ old `.crew` tree is not deleted by this compatibility layer.
501
+
482
502
  - `src/serve.ts`: connection, negotiation, routing, sync, and legacy boundary.
483
503
  - `src/daemon-update-eligibility.ts`, `src/daemon-updater.ts`, and `src/daemon-update-controller.ts`:
484
504
  managed-update eligibility, exact npm replacement, host-wide admission barrier, and restart handoff.
@@ -39,6 +39,7 @@ function defaultDependencies() {
39
39
  platform,
40
40
  userHome,
41
41
  uid: process.getuid?.(),
42
+ environment: process.env,
42
43
  nodePath: process.execPath,
43
44
  version: daemonVersion(),
44
45
  ...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
@@ -231,6 +232,10 @@ function recoveryError(primary, failures) {
231
232
  }
232
233
  export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
233
234
  const machineToken = MachineTokenSchema.parse(input.machineToken);
235
+ const requestedLocalHome = input.requestedLocalHome ?? dependencies.environment.NOWWORK_LOCAL_HOME;
236
+ const requestedLocalHomeMode = input.requestedLocalHomeMode
237
+ ?? dependencies.environment.NOWWORK_LOCAL_HOME_MODE
238
+ ?? dependencies.environment.NOWWORK_STORAGE_MODE;
234
239
  const target = resolveAutomaticInstallTarget({
235
240
  serverUrl: input.serverUrl,
236
241
  ...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
@@ -238,6 +243,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
238
243
  ...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
239
244
  userHome: dependencies.userHome,
240
245
  platform: dependencies.platform,
246
+ ...(requestedLocalHome === undefined ? {} : { requestedLocalHome }),
247
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
241
248
  });
242
249
  if (!isDaemonReleaseVersion(dependencies.version)) {
243
250
  throw new Error(`Invoking daemon package has an invalid release version '${dependencies.version}'`);
@@ -1,6 +1,7 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
3
3
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
4
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
4
5
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
5
6
  const KNOWN_TARGETS = {
6
7
  "nowwork-in.nowcoder.com": {
@@ -72,13 +73,21 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
72
73
  const legacyProfile = environment === "production" ? "in" : environment;
73
74
  assertReleaseTagForServerUrl(input.serverUrl, input.requestedReleaseTag);
74
75
  if (requestedProfile === undefined || requestedProfile === legacyProfile) {
76
+ const local = resolveDaemonLocalHome({
77
+ userHome: input.userHome,
78
+ platform: input.platform,
79
+ profile: legacyProfile,
80
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
81
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
82
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
83
+ });
75
84
  return Object.freeze({
76
85
  kind: "known",
77
86
  profile: legacyProfile,
78
87
  serverUrl: normalizedServerUrl(parsed),
79
- daemonHome: paths.join(input.userHome, ".crew", environment === "production" ? "daemon" : "daemon-dev"),
80
- agentsRoot: paths.join(input.userHome, ".crew", environment === "production" ? "agents" : "agents-dev"),
81
- npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
88
+ daemonHome: local.daemonHome,
89
+ agentsRoot: paths.join(local.root, environment === "production" ? "agents" : "agents-dev"),
90
+ npmPrefix: local.runtimePrefix,
82
91
  releaseTag: environment === "dev" ? "beta" : "latest",
83
92
  });
84
93
  }
@@ -99,15 +108,23 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
99
108
  userHome: input.userHome,
100
109
  platform: input.platform,
101
110
  });
111
+ const local = resolveDaemonLocalHome({
112
+ userHome: input.userHome,
113
+ platform: input.platform,
114
+ profile: layout.profile,
115
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
116
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
117
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
118
+ });
102
119
  return Object.freeze({
103
120
  kind: "known",
104
121
  environment,
105
122
  localInstanceName,
106
123
  profile: layout.profile,
107
124
  serverUrl: normalizedServerUrl(parsed),
108
- daemonHome: layout.daemonHome,
109
- agentsRoot: layout.agentsRoot,
110
- npmPrefix: layout.npmPrefix,
125
+ daemonHome: local.daemonHome,
126
+ agentsRoot: paths.join(local.root, "agents-" + layout.environment + "-" + layout.localInstanceName),
127
+ npmPrefix: local.runtimePrefix,
111
128
  releaseTag: environment === "dev" ? "beta" : "latest",
112
129
  });
113
130
  }
@@ -127,13 +144,21 @@ export function resolveAutomaticInstallTarget(input) {
127
144
  return registryUrl === undefined ? target : Object.freeze({ ...target, registryUrl });
128
145
  }
129
146
  const profile = requireCustomProfile(hostname, input.requestedProfile);
147
+ const local = resolveDaemonLocalHome({
148
+ userHome: input.userHome,
149
+ platform: input.platform,
150
+ profile,
151
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
152
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
153
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
154
+ });
130
155
  return Object.freeze({
131
156
  kind: "custom",
132
157
  profile,
133
158
  serverUrl: normalizedServerUrl(parsed),
134
- daemonHome: paths.join(input.userHome, ".crew", `daemon-${profile}`),
135
- agentsRoot: paths.join(input.userHome, ".crew", `agents-${profile}`),
136
- npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
159
+ daemonHome: local.daemonHome,
160
+ agentsRoot: paths.join(local.root, `agents-${profile}`),
161
+ npmPrefix: local.runtimePrefix,
137
162
  releaseTag: input.requestedReleaseTag ?? "latest",
138
163
  ...(registryUrl === undefined ? {} : { registryUrl }),
139
164
  });
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { homedir } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { posix, resolve, win32 } 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";
@@ -18,6 +18,7 @@ import { assertManagedServiceIdentity, buildManagedServiceRecord, installManaged
18
18
  import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
19
19
  import { readManagedServiceRegistry } from "./managed-service-registry.js";
20
20
  import { installDaemonAutomatically, } from "./automatic-daemon-installer.js";
21
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
21
22
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
22
23
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
23
24
  return moduleUrl.endsWith(".js")
@@ -67,6 +68,7 @@ function usage(td) {
67
68
  " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
68
69
  " crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
69
70
  " crew-daemon upgrade [--profile <name>] [--release-tag <latest|beta>] [--registry <https-url>]",
71
+ " NOWWORK_LOCAL_HOME_MODE=auto|nowwork|legacy NOWWORK_LOCAL_HOME=~/.nowwork",
70
72
  "",
71
73
  td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
72
74
  ].join("\n") + "\n";
@@ -154,6 +156,7 @@ export async function runComputerCommand(argv, overrides = {}) {
154
156
  ?? deps.env.CREW_MACHINE_TOKEN)?.trim();
155
157
  if (!machineToken)
156
158
  throw new Error(td("Missing machine token for automatic installation"));
159
+ const requestedLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
157
160
  const result = await deps.installAutomatically({
158
161
  serverUrl: parsed.values["server-url"],
159
162
  machineToken,
@@ -162,11 +165,28 @@ export async function runComputerCommand(argv, overrides = {}) {
162
165
  ...(configuredRegistryUrl === undefined
163
166
  ? {}
164
167
  : { requestedRegistryUrl: configuredRegistryUrl }),
168
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined
169
+ ? {}
170
+ : { requestedLocalHome: deps.env.NOWWORK_LOCAL_HOME }),
171
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
165
172
  });
166
173
  deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
167
174
  return 0;
168
175
  }
169
- const home = daemonHome(deps.env);
176
+ const resolvedProfile = profileName ?? positionalName;
177
+ const configuredLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
178
+ const home = resolvedProfile !== undefined
179
+ && (deps.env.NOWWORK_LOCAL_HOME !== undefined
180
+ || deps.env.NOWWORK_LOCAL_HOME_MODE !== undefined
181
+ || deps.env.NOWWORK_STORAGE_MODE !== undefined)
182
+ ? resolveDaemonLocalHome({
183
+ userHome: deps.userHome,
184
+ platform: deps.platform,
185
+ profile: resolvedProfile,
186
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: deps.env.NOWWORK_LOCAL_HOME }),
187
+ ...(configuredLocalHomeMode === undefined ? {} : { mode: configuredLocalHomeMode }),
188
+ }).daemonHome
189
+ : daemonHome(deps.env);
170
190
  const profileStorage = {
171
191
  platform: deps.platform,
172
192
  userHome: deps.userHome,
@@ -221,7 +241,9 @@ export async function runComputerCommand(argv, overrides = {}) {
221
241
  }),
222
242
  ...(parsed.values["agents-root"]
223
243
  ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
224
- : {}),
244
+ : (deps.env.NOWWORK_LOCAL_HOME !== undefined || configuredLocalHomeMode !== undefined)
245
+ ? { agentsRoot: resolveAgentsRoot((deps.platform === "win32" ? win32 : posix).join((deps.platform === "win32" ? win32 : posix).dirname(home), `agents-${name}`), deps.userHome, deps.platform) }
246
+ : {}),
225
247
  ...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
226
248
  };
227
249
  await saveProfile(profile, home, profileStorage);
@@ -9,6 +9,7 @@ import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
9
9
  import { formatDaemonText } from "./i18n.js";
10
10
  import { atomicPrivateWrite } from "./atomic-private-write.js";
11
11
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
12
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
12
13
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
13
14
  export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
14
15
  const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
@@ -94,8 +95,20 @@ export class ProfileAgentsRootConflictError extends Error {
94
95
  }
95
96
  const quotePosixArgument = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
96
97
  const quotePowerShellArgument = (value) => `'${value.replaceAll("'", "''")}'`;
97
- export function daemonHome(env = process.env) {
98
- return resolve(env.CREW_DAEMON_HOME ?? resolve(homedir(), ".crew/daemon"));
98
+ export function daemonHome(env = process.env, profile) {
99
+ if (env.CREW_DAEMON_HOME !== undefined)
100
+ return resolve(env.CREW_DAEMON_HOME);
101
+ const mode = env.NOWWORK_LOCAL_HOME_MODE ?? env.NOWWORK_STORAGE_MODE;
102
+ if (profile !== undefined && (env.NOWWORK_LOCAL_HOME !== undefined || mode !== undefined)) {
103
+ return resolveDaemonLocalHome({
104
+ userHome: homedir(),
105
+ platform: process.platform,
106
+ profile,
107
+ ...(env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: env.NOWWORK_LOCAL_HOME }),
108
+ ...(mode === undefined ? {} : { mode }),
109
+ }).daemonHome;
110
+ }
111
+ return resolve(homedir(), ".crew/daemon");
99
112
  }
100
113
  export function resolveAgentsRoot(configured, userHome = homedir(), platform = process.platform) {
101
114
  const pathApi = platform === "win32" ? win32 : posix;
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** daemon 配置:从 env 读取,解析 crew CLI 路径 (注入给 agent 的 wrapper 用)。 */
2
2
  import { fileURLToPath } from "node:url";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import { detectDaemonLang, translateDaemon } from "./i18n.js";
@@ -28,6 +28,23 @@ export const MIN_EXECUTION_EVENT_BYTES = 512;
28
28
  export const MAX_EXECUTION_TIMEOUT_MS = 2_147_483_647;
29
29
  export class ConfigError extends Error {
30
30
  }
31
+ /**
32
+ * Resolve the shared workspace repository root from the daemon's agent root.
33
+ *
34
+ * Standard NowWork storage keeps `agent-items`, `daemon`, `repos`, and the QA
35
+ * report hub as siblings. Legacy/standalone profiles may still point at an
36
+ * arbitrary agents directory, for which the historical `<agentsRoot>/repos`
37
+ * layout remains the compatible fallback.
38
+ */
39
+ export function resolveReposRoot(agentsRoot, configured) {
40
+ const explicit = configured?.trim();
41
+ if (explicit)
42
+ return resolve(explicit);
43
+ const resolvedAgentsRoot = resolve(agentsRoot);
44
+ return basename(resolvedAgentsRoot) === "agent-items"
45
+ ? join(dirname(resolvedAgentsRoot), "repos")
46
+ : join(resolvedAgentsRoot, "repos");
47
+ }
31
48
  function positiveIntegerEnv(env, field, fallback) {
32
49
  const raw = env[field];
33
50
  const value = raw === undefined ? fallback : Number(raw);
@@ -136,16 +153,19 @@ export function loadConfig(env = process.env) {
136
153
  });
137
154
  const agentsRoot = resolveAgentsRoot(env.CREW_AGENTS_ROOT, homedir());
138
155
  const configuredReposRoot = env.NOWWORK_REPOS_ROOT?.trim();
139
- const reposRoot = configuredReposRoot ? resolve(configuredReposRoot) : undefined;
156
+ const reposRoot = resolveReposRoot(agentsRoot, configuredReposRoot);
140
157
  const configuredQaReportRoot = env.QA_REPORT_HUB?.trim();
158
+ const defaultQaReportRoot = configuredReposRoot
159
+ ? join(dirname(reposRoot), "qa-report-server-v2")
160
+ : join(dirname(agentsRoot), "qa-report-server-v2");
141
161
  return {
142
162
  serverUrl,
143
163
  machineToken,
144
164
  agentsRoot,
145
- ...(reposRoot === undefined ? {} : { reposRoot }),
165
+ reposRoot,
146
166
  qaReportRoot: configuredQaReportRoot
147
167
  ? resolve(configuredQaReportRoot)
148
- : join(dirname(reposRoot ?? agentsRoot), "qa-report-server-v2"),
168
+ : defaultQaReportRoot,
149
169
  cliPath: env.CREW_CLI_PATH ?? defaultCliPath(),
150
170
  runtimeBin: env.CREW_RUNTIME ?? "claude",
151
171
  codexHomeMode: codexHomeModeEnv(env),
@@ -0,0 +1,73 @@
1
+ import { existsSync } from "node:fs";
2
+ import { posix, win32 } from "node:path";
3
+ const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
4
+ const CONTROL = /[\0\r\n]/u;
5
+ const pathsFor = (platform) => platform === "win32" ? win32 : posix;
6
+ export function parseDaemonLocalHomeMode(value) {
7
+ if (value === undefined || value.trim() === "")
8
+ return "auto";
9
+ const normalized = value.trim().toLowerCase();
10
+ if (normalized === "auto" || normalized === "nowwork" || normalized === "legacy")
11
+ return normalized;
12
+ throw new Error("NOWWORK_LOCAL_HOME_MODE must be auto, nowwork or legacy");
13
+ }
14
+ function absoluteRoot(value, userHome, platform) {
15
+ const paths = pathsFor(platform);
16
+ if (value.length === 0 || CONTROL.test(value))
17
+ throw new Error("NOWWORK_LOCAL_HOME must be a non-empty single-line path");
18
+ const expanded = value === "~"
19
+ ? userHome
20
+ : value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))
21
+ ? paths.join(userHome, value.slice(2))
22
+ : value;
23
+ if (!paths.isAbsolute(expanded))
24
+ throw new Error("NOWWORK_LOCAL_HOME must be absolute or start with '~/'");
25
+ return paths.normalize(expanded);
26
+ }
27
+ function legacyDaemonDirectory(profile) {
28
+ if (profile === "in" || profile === "production")
29
+ return "daemon";
30
+ if (profile === "dev")
31
+ return "daemon-dev";
32
+ if (profile.startsWith("nw-"))
33
+ return `daemon-${profile.slice(3)}`;
34
+ return `daemon-${profile}`;
35
+ }
36
+ function resolution(mode, root, profile, platform, source) {
37
+ const paths = pathsFor(platform);
38
+ return Object.freeze({
39
+ mode,
40
+ root,
41
+ daemonHome: mode === "nowwork" ? paths.join(root, "config") : paths.join(root, legacyDaemonDirectory(profile)),
42
+ runtimePrefix: paths.join(root, "daemon-runtimes", profile),
43
+ source,
44
+ });
45
+ }
46
+ export function resolveDaemonLocalHome(input) {
47
+ if (!PROFILE.test(input.profile))
48
+ throw new Error("daemon profile is invalid");
49
+ const mode = parseDaemonLocalHomeMode(input.mode);
50
+ const paths = pathsFor(input.platform);
51
+ const nowworkRoot = absoluteRoot(input.explicit ?? paths.join(input.userHome, ".nowwork"), input.userHome, input.platform);
52
+ const legacyRoot = absoluteRoot(paths.join(input.userHome, ".crew"), input.userHome, input.platform);
53
+ if (input.explicit !== undefined && mode !== "legacy") {
54
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "explicit");
55
+ }
56
+ if (mode === "nowwork")
57
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "nowwork_forced");
58
+ if (mode === "legacy")
59
+ return resolution("legacy", legacyRoot, input.profile, input.platform, "legacy_forced");
60
+ const exists = input.exists ?? existsSync;
61
+ const nowwork = resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_nowwork_existing");
62
+ const legacy = resolution("legacy", legacyRoot, input.profile, input.platform, "auto_legacy_existing");
63
+ const hasNowwork = exists(nowwork.runtimePrefix) || exists(paths.join(nowwork.daemonHome, "profiles", `${input.profile}.json`));
64
+ const hasLegacy = exists(legacy.runtimePrefix) || exists(paths.join(legacy.daemonHome, "profiles", `${input.profile}.json`));
65
+ if (hasNowwork && hasLegacy) {
66
+ throw new Error(`Daemon local home conflict: profile '${input.profile}' exists under both .crew and .nowwork`);
67
+ }
68
+ if (hasNowwork)
69
+ return nowwork;
70
+ if (hasLegacy)
71
+ return legacy;
72
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_new");
73
+ }
@@ -125,7 +125,15 @@ export function createDaemonMigrationController(input) {
125
125
  if (currentProfile.machineToken !== input.machineToken)
126
126
  throw new Error("active profile token does not match this daemon");
127
127
  currentRegistryUrl = currentProfile.registryUrl;
128
- const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: message.targetProfile, userHome, platform });
128
+ const target = resolveAutomaticInstallTarget({
129
+ serverUrl: input.serverUrl,
130
+ requestedProfile: message.targetProfile,
131
+ userHome,
132
+ platform,
133
+ // This controller migrates the legacy service layout; the new local-home
134
+ // standard is handled by the storage migration controller.
135
+ requestedLocalHomeMode: "legacy",
136
+ });
129
137
  if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
130
138
  || resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
131
139
  || resolve(message.targetAgentsRoot) !== resolve(target.agentsRoot))
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { resolveReposRoot } from "./config.js";
3
4
  import { DaemonToServerExecutionFrameSchema, ExecutionCompletedSchema, ExecutionRejectedSchema, ExecutionStartSchema, } from "./execution-protocol.js";
4
5
  import { JournalConflictError } from "./execution-journal.js";
5
6
  import { boundExecutionFrame } from "./execution-event-limit.js";
@@ -524,7 +525,7 @@ export async function runExecution(config, input, dependencies) {
524
525
  if (dependencies.abilityRelease !== undefined) {
525
526
  await cancellable(dependencies.abilityRelease.apply(config.agentsRoot, spec.agent.handle, spec.agent.abilityRelease), dependencies.cancellation);
526
527
  }
527
- const reposRoot = config.reposRoot ?? join(config.agentsRoot, "repos");
528
+ const reposRoot = resolveReposRoot(config.agentsRoot, config.reposRoot);
528
529
  const preparedProjectDependencies = await cancellable(dependencies.projectDependencies.prepare({
529
530
  trainingRoot: join(config.agentsRoot, spec.agent.handle, "training"),
530
531
  reposRoot,
@@ -863,7 +864,7 @@ export async function runExecution(config, input, dependencies) {
863
864
  token: credential.token,
864
865
  agentId: credential.agentId,
865
866
  agentsRoot: config.agentsRoot,
866
- ...(config.reposRoot === undefined ? {} : { reposRoot: config.reposRoot }),
867
+ reposRoot: resolveReposRoot(config.agentsRoot, config.reposRoot),
867
868
  ...(config.qaReportRoot === undefined ? {} : { qaReportRoot: config.qaReportRoot }),
868
869
  cliPath: config.cliPath,
869
870
  providerConfig,
@@ -466,6 +466,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
466
466
  CREW_TOKEN: input.launch.token,
467
467
  CREW_CHANNEL: input.channelId,
468
468
  CREW_HOME: workspace.dir,
469
+ // Keep the daemon-selected storage root authoritative for runtimes.
470
+ // In profile-backed NowWork sessions the daemon config may differ from
471
+ // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
472
+ // sufficient for QA/project diagnostics.
473
+ CREW_AGENTS_ROOT: input.launch.agentsRoot,
469
474
  ...(input.launch.reposRoot === undefined ? {} : { NOWWORK_REPOS_ROOT: input.launch.reposRoot }),
470
475
  ...(input.launch.qaReportRoot === undefined ? {} : { QA_REPORT_HUB: input.launch.qaReportRoot }),
471
476
  // Expose only the training root; ability-specific workspace layout stays
package/dist/main.js CHANGED
@@ -74,7 +74,7 @@ async function main() {
74
74
  process.env.CREW_DAEMON_HOME = values["daemon-home"];
75
75
  let activeDaemonHome;
76
76
  if (values.profile) {
77
- const home = daemonHome();
77
+ const home = daemonHome(process.env, values.profile);
78
78
  activeDaemonHome = home;
79
79
  const profile = await loadProfile(values.profile, home);
80
80
  if (cmd === "serve") {
@@ -16,12 +16,18 @@ export async function initializeAfterProjectSkillLeaseProtection(input) {
16
16
  /**
17
17
  * Runs only after execution-journal restart reconciliation has settled. A lease without a terminal
18
18
  * journal fact remains durable and prevents the caller from marking Project Skill v2 ready.
19
+ *
20
+ * 重连时本进程仍在跑的 execution 合法持有 lease(`liveExecutionIds`):既不释放,也不算 unresolved。
21
+ * 否则一次网络抖动就会让 daemon 在整条连接上摘掉 Project Skills 能力(incident: 2026-09-02,
22
+ * 绑定项目的 Agent 全部 execution_spec_incompatible)。
19
23
  */
20
24
  export async function recoverProjectSkillRuntimeRoots(input) {
21
25
  for (const agentRoot of input.agentRoots) {
22
26
  await input.roots.recover(agentRoot);
23
27
  let unresolvedLease = false;
24
28
  await input.leases.recover(agentRoot, async (executionId) => {
29
+ if (input.liveExecutionIds?.has(executionId))
30
+ return false;
25
31
  const entry = await input.journal.get(executionId);
26
32
  const releasable = entry?.state === "completed" || entry?.state === "interrupted";
27
33
  if (!releasable)
@@ -22,13 +22,14 @@ export function createProjectSkillRuntimeRootStartup() {
22
22
  leases,
23
23
  initialize,
24
24
  }),
25
- recover: async (agentsRoot, journal, protectedExecutionIds) => {
25
+ recover: async (agentsRoot, journal, protectedExecutionIds, liveExecutionIds) => {
26
26
  await recoverProjectSkillRuntimeRoots({
27
27
  agentRoots: await agentRoots(agentsRoot),
28
28
  journal,
29
29
  roots,
30
30
  leases,
31
31
  gc,
32
+ ...(liveExecutionIds === undefined ? {} : { liveExecutionIds }),
32
33
  currentRootId: async (agentRoot) => (await readAppliedProjectSkillManifest(agentRoot))?.rootId ?? null,
33
34
  afterLeaseRecovery: async () => {
34
35
  const remaining = await initializeAfterProjectSkillLeaseProtection({
@@ -1,6 +1,29 @@
1
1
  import { createExecutionJournal } from "../execution-journal.js";
2
2
  import { reconcileExecutionJournal } from "../execution-recovery.js";
3
3
  import { dslog, flushSlog } from "../slog.js";
4
+ /**
5
+ * 每次连接 preflight 的 Runtime root / lease 恢复。重连时本进程仍在跑的 execution 合法持有 lease,
6
+ * 必须当作存活而非孤儿;失败也必须留痕,否则整条连接静默摘掉 Project Skills 能力
7
+ * (incident: 2026-09-02,绑定项目的 Agent 全部 execution_spec_incompatible)。
8
+ */
9
+ export function createProjectSkillReconnectRecovery(startup, agentsRoot, journal, protectedExecutionIds, liveExecutionIds) {
10
+ return async () => {
11
+ const live = liveExecutionIds();
12
+ try {
13
+ await startup.recover(agentsRoot, journal(), protectedExecutionIds, live);
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ dslog("project_skills.preflight_recovery_failed", "Project Skill runtime root 恢复失败,本连接不宣告 Project Skills 能力", {
18
+ level: "ERROR",
19
+ error_code: error.code ?? "unknown",
20
+ error_message: error.message,
21
+ live_execution_count: live.size,
22
+ });
23
+ return false;
24
+ }
25
+ };
26
+ }
4
27
  export async function initializeProjectSkillProtectedExecutionJournal(input) {
5
28
  return input.startup.initializeAfterLeaseProtection(input.config.agentsRoot, async (snapshot) => {
6
29
  input.protectedExecutionIds.clear();
package/dist/runner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Legacy agent orchestration around the business-neutral local executor. */
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
+ import { resolveReposRoot } from "./config.js";
4
5
  import { mintAgentToken } from "./token.js";
5
6
  import { buildSystemPrompt, buildWakePrompt, capMemoryForInject, capWorkLogForInject } from "./prompt.js";
6
7
  import { deliverScheduledReport, } from "./scheduled-report.js";
@@ -84,7 +85,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
84
85
  token: credential.token,
85
86
  agentId: credential.agentId,
86
87
  agentsRoot: config.agentsRoot,
87
- ...(config.reposRoot === undefined ? {} : { reposRoot: config.reposRoot }),
88
+ reposRoot: resolveReposRoot(config.agentsRoot, config.reposRoot),
88
89
  ...(config.qaReportRoot === undefined ? {} : { qaReportRoot: config.qaReportRoot }),
89
90
  cliPath: config.cliPath,
90
91
  providerConfig,
package/dist/serve.js CHANGED
@@ -42,7 +42,7 @@ import { PROJECT_SKILLS_CAPABILITY } from "./project-skills/types.js";
42
42
  import { createAgentProjectionCoordinator } from "./project-skills/agent-projection-coordinator.js";
43
43
  import { createProjectSkillRuntimeRootStartup, } from "./project-skills/runtime-root-startup.js";
44
44
  import { createProjectSkillControllerReadiness, withProjectSkillCapabilityStatus } from "./project-skills/capability.js";
45
- import { initializeProjectSkillProtectedExecutionJournal } from "./project-skills/serve-startup.js";
45
+ import { createProjectSkillReconnectRecovery, initializeProjectSkillProtectedExecutionJournal } from "./project-skills/serve-startup.js";
46
46
  import { createProjectSkillConnectionCapabilityProvider, createProjectSkillMachineHelloPublisher } from "./project-skills/serve-capability.js";
47
47
  import { createAgentAbilityRuntime } from "./agent-ability/runtime.js";
48
48
  import { createProjectSkillsReconciler, } from "./project-skills/reconciler.js";
@@ -215,9 +215,7 @@ export function serve(config, opts = {}) {
215
215
  additionalCapabilities: () => managedDaemonCapabilities(updateEligibility),
216
216
  probeRuntimeRootStorePrimitive: projectSkillRuntimeRootStartup.probeNativePublicationPrimitiveReadiness,
217
217
  initializeController: projectSkillControllerReadiness.ensure,
218
- recoverRuntimeRootsAndLeases: async () => projectSkillRuntimeRootStartup
219
- .recover(config.agentsRoot, executionJournal, protectedExecutionIds)
220
- .then(() => true, () => false),
218
+ recoverRuntimeRootsAndLeases: createProjectSkillReconnectRecovery(projectSkillRuntimeRootStartup, config.agentsRoot, () => executionJournal, protectedExecutionIds, () => new Set(executionRuns.keys())),
221
219
  applyReadiness: projectSkillControllerReadiness.applyPreflight,
222
220
  });
223
221
  const safeExecutionSend = (frame) => {
@@ -827,9 +825,9 @@ export function serve(config, opts = {}) {
827
825
  // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
828
826
  const r = msg;
829
827
  setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
830
- if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
828
+ if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
831
829
  try {
832
- opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
830
+ opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId, machineId: r.machineId });
833
831
  machineHelloPublisher.republish();
834
832
  }
835
833
  catch (error) {
@@ -5,6 +5,8 @@ const IdentitySchema = z.object({
5
5
  migrationId: z.string().uuid(),
6
6
  environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
7
7
  workspaceId: z.string().uuid(),
8
+ ownerEpoch: z.number().int().positive().safe().optional(),
9
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
8
10
  });
9
11
  const DoctorRequestSchema = IdentitySchema.extend({ type: z.literal("daemon:storage-doctor") }).strict();
10
12
  const MigrateRequestSchema = IdentitySchema.extend({
@@ -26,6 +28,11 @@ export function createStorageControlController(input) {
26
28
  const profile = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u).parse(input.profile);
27
29
  const sourceStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.sourceStorageFingerprint);
28
30
  const targetStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.targetStorageFingerprint);
31
+ const ownership = input.ownership === undefined ? undefined : Object.freeze({
32
+ ownerEpoch: z.number().int().positive().safe().parse(input.ownership.ownerEpoch),
33
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u)
34
+ .parse(input.ownership.ownershipClaimFingerprint),
35
+ });
29
36
  const preflights = new Map();
30
37
  const preflightRuns = new Map();
31
38
  const appliedMigrationIds = new Set();
@@ -39,6 +46,7 @@ export function createStorageControlController(input) {
39
46
  environment,
40
47
  workspaceId,
41
48
  profile,
49
+ ...(ownership === undefined ? {} : ownership),
42
50
  ...extra,
43
51
  });
44
52
  const failure = (identity, errorCode, error, reopenAdmission = false, extra = {}) => {
@@ -59,7 +67,10 @@ export function createStorageControlController(input) {
59
67
  input.onStatusError?.(error, statusFrame);
60
68
  }
61
69
  };
62
- const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId;
70
+ const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId
71
+ && (ownership === undefined
72
+ || (identity.ownerEpoch === ownership.ownerEpoch
73
+ && identity.ownershipClaimFingerprint === ownership.ownershipClaimFingerprint));
63
74
  const preflightKey = (identity) => `${identity.migrationId}\0${identity.environment}\0${identity.workspaceId}`;
64
75
  const runPreflight = (identity, failureFallback = "doctor_failed") => {
65
76
  const key = preflightKey(identity);
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { STORAGE_LAYOUT_VERSION } from "./storage-layout.js";
3
+ import { createStorageOwnershipClaim } from "./storage-ownership.js";
3
4
  function fingerprint(namespace, value) {
4
5
  return `sha256:${createHash("sha256").update(`${namespace}\0${value}`).digest("hex")}`;
5
6
  }
@@ -14,14 +15,27 @@ export function storageHelloIdentity(input) {
14
15
  || installation.runtimeRoot !== layout.runtimeRoot) {
15
16
  throw new Error("installation storage identity does not match resolved layout");
16
17
  }
18
+ const workspaceRootFingerprint = fingerprint("workspace-root", layout.workspaceRoot);
19
+ let ownership;
20
+ if (input.ownership !== undefined) {
21
+ const { claimFingerprint: reportedFingerprint, ...claimIdentity } = input.ownership;
22
+ ownership = createStorageOwnershipClaim(claimIdentity);
23
+ if (ownership.claimFingerprint !== reportedFingerprint
24
+ || ownership.workspaceId !== layout.workspaceId
25
+ || ownership.environment !== layout.environment
26
+ || ownership.workspaceRootFingerprint !== workspaceRootFingerprint) {
27
+ throw new Error("storage ownership identity does not match resolved layout");
28
+ }
29
+ }
17
30
  return Object.freeze({
18
31
  storageLayoutVersion: STORAGE_LAYOUT_VERSION,
19
32
  storageEnvironment: layout.environment,
20
33
  storageWorkspaceId: layout.workspaceId,
21
34
  storageIdFingerprint: storageIdFingerprint(installation.storageId),
22
- workspaceRootFingerprint: fingerprint("workspace-root", layout.workspaceRoot),
35
+ workspaceRootFingerprint,
23
36
  daemonRootFingerprint: fingerprint("daemon-root", layout.daemonRoot),
24
37
  agentRootFingerprint: fingerprint("agent-root", layout.agentItemsRoot),
38
+ ...(ownership === undefined ? {} : { storageOwnership: ownership }),
25
39
  ...(installation.storageMigrationId === undefined
26
40
  ? {}
27
41
  : { storageMigrationId: installation.storageMigrationId }),
@@ -27,6 +27,8 @@ const ReceiptSchema = z.object({
27
27
  environment: z.string().min(1),
28
28
  workspaceId: z.string().uuid(),
29
29
  profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
30
+ ownerEpoch: z.number().int().positive().safe().optional(),
31
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
30
32
  manifestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
31
33
  targetFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
32
34
  status: ReceiptStatusSchema,
@@ -37,13 +39,23 @@ const ReceiptSchema = z.object({
37
39
  sourceStopAttempted: z.boolean().optional(),
38
40
  sourceStopped: z.boolean().optional(),
39
41
  targetTouched: z.boolean().optional(),
40
- }).strict();
42
+ }).strict().superRefine((receipt, context) => {
43
+ if ((receipt.ownerEpoch === undefined) !== (receipt.ownershipClaimFingerprint === undefined)) {
44
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "storage receipt ownership identity must be complete" });
45
+ }
46
+ });
41
47
  const MigrationLockSchema = z.object({
42
48
  version: z.literal(1),
43
49
  migrationId: z.string().uuid(),
44
50
  pid: z.number().int().positive(),
45
51
  createdAt: z.string().datetime({ offset: true }),
46
52
  }).strict();
53
+ const REQUIRED_BACKUP_ARTIFACTS = Object.freeze([
54
+ "profile.json",
55
+ "service-descriptor",
56
+ "managed-service-registry.json",
57
+ "installations.json",
58
+ ]);
47
59
  export class StorageMigrationFailure extends Error {
48
60
  code;
49
61
  receipt;
@@ -204,6 +216,16 @@ function assertOwnedStaging(input) {
204
216
  }
205
217
  }
206
218
  async function backupArtifacts(input, receipt) {
219
+ const providedNames = new Set(input.backupArtifacts.map((artifact) => artifact.name));
220
+ const missing = REQUIRED_BACKUP_ARTIFACTS.filter((name) => !providedNames.has(name));
221
+ if (missing.length > 0) {
222
+ throw new StorageMigrationFailure("backup_artifact_missing", `required rollback artifacts are missing: ${missing.join(", ")}`);
223
+ }
224
+ for (const artifact of input.backupArtifacts) {
225
+ if (!(await lstat(artifact.path)).isFile()) {
226
+ throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact must be a file");
227
+ }
228
+ }
207
229
  const directory = join(input.backupsRoot, receipt.migrationId);
208
230
  const artifactsDirectory = join(directory, "artifacts");
209
231
  await mkdir(artifactsDirectory, { recursive: true, mode: 0o700 });
@@ -217,8 +239,6 @@ async function backupArtifacts(input, receipt) {
217
239
  throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact names must be unique safe file names");
218
240
  }
219
241
  names.add(artifact.name);
220
- if (!(await lstat(artifact.path)).isFile())
221
- throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact must be a file");
222
242
  const destination = join(artifactsDirectory, artifact.name);
223
243
  await copyFile(artifact.path, destination);
224
244
  await chmod(destination, 0o600);
@@ -271,6 +291,7 @@ export async function runStorageMigration(input, dependencies) {
271
291
  environment: input.doctor.environment,
272
292
  workspaceId: input.doctor.workspaceId,
273
293
  profile: input.doctor.profile,
294
+ ...(input.ownership === undefined ? {} : input.ownership),
274
295
  manifestDigest: input.doctor.manifestDigest,
275
296
  targetFingerprint: targetFingerprint(input.targetWorkspaceRoot),
276
297
  status: "preflight_passed",
@@ -301,7 +322,7 @@ export async function runStorageMigration(input, dependencies) {
301
322
  input.assertHealthy?.();
302
323
  await transition("backing_up");
303
324
  receipt = await backupArtifacts(input, receipt).catch((error) => {
304
- failureCode = "backup_failed";
325
+ failureCode = error instanceof StorageMigrationFailure ? error.code : "backup_failed";
305
326
  throw error;
306
327
  });
307
328
  await writeReceipt(path, receipt);
@@ -0,0 +1,55 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ const FingerprintSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
6
+ const StorageOwnershipClaimSchema = z.object({
7
+ version: z.literal(1),
8
+ machineId: z.string().uuid(),
9
+ workspaceId: z.string().uuid(),
10
+ environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
11
+ volumeFingerprint: FingerprintSchema,
12
+ workspaceRootFingerprint: FingerprintSchema,
13
+ ownerEpoch: z.number().int().positive().safe(),
14
+ claimFingerprint: FingerprintSchema,
15
+ }).strict();
16
+ function claimFingerprint(identity) {
17
+ const digest = createHash("sha256").update([
18
+ "nowwork-storage-ownership-v1",
19
+ identity.machineId,
20
+ identity.workspaceId,
21
+ identity.environment,
22
+ identity.volumeFingerprint,
23
+ identity.workspaceRootFingerprint,
24
+ String(identity.ownerEpoch),
25
+ ].join("\0")).digest("hex");
26
+ return `sha256:${digest}`;
27
+ }
28
+ export function createStorageOwnershipClaim(input) {
29
+ const parsed = StorageOwnershipClaimSchema.omit({ version: true, claimFingerprint: true }).parse(input);
30
+ const identity = Object.freeze({
31
+ ...parsed,
32
+ machineId: parsed.machineId.toLowerCase(),
33
+ workspaceId: parsed.workspaceId.toLowerCase(),
34
+ environment: parsed.environment.toLowerCase(),
35
+ });
36
+ return Object.freeze({ ...identity, claimFingerprint: claimFingerprint(identity) });
37
+ }
38
+ export function storageOwnershipClaimPath(nowworkHome) {
39
+ return join(nowworkHome, "storage-layout.json");
40
+ }
41
+ export async function readStorageOwnershipClaim(nowworkHome) {
42
+ const path = storageOwnershipClaimPath(nowworkHome);
43
+ const metadata = await lstat(path);
44
+ if (!metadata.isFile())
45
+ throw new Error("storage ownership claim must be a file");
46
+ if (metadata.size > 16 * 1024)
47
+ throw new Error("storage ownership claim file is too large");
48
+ const parsed = StorageOwnershipClaimSchema.parse(JSON.parse(await readFile(path, "utf8")));
49
+ const { version: _version, claimFingerprint: reportedFingerprint, ...identity } = parsed;
50
+ const expected = createStorageOwnershipClaim(identity);
51
+ if (reportedFingerprint !== expected.claimFingerprint) {
52
+ throw new Error("storage ownership claim fingerprint is invalid");
53
+ }
54
+ return expected;
55
+ }
@@ -23,9 +23,10 @@ function deterministicUuid(value) {
23
23
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
24
24
  }
25
25
  function storageId(input, workspaceId) {
26
- return `storage_${createHash("sha256").update([
27
- input.serverUrl.replace(/\/+$/u, ""), input.profile, workspaceId, input.nowworkHome,
28
- ].join("\0")).digest("hex").slice(0, 32)}`;
26
+ if (input.ownership.workspaceId !== workspaceId || input.ownership.environment !== input.environment) {
27
+ throw new Error("Storage ownership claim does not match the migration identity");
28
+ }
29
+ return `storage_${input.ownership.claimFingerprint.slice("sha256:".length)}`;
29
30
  }
30
31
  function installationBase(input, workspaceId, migrationId, createdAt) {
31
32
  const layout = storageLayout({
@@ -102,6 +103,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
102
103
  ...injected,
103
104
  };
104
105
  let workspaceId = null;
106
+ let machineId = null;
105
107
  let identity;
106
108
  let gate = null;
107
109
  let sendStatus = null;
@@ -194,6 +196,10 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
194
196
  targetWorkspaceRoot: target.workspaceRoot,
195
197
  backupsRoot: input.local.backupsRoot,
196
198
  backupArtifacts: input.backupArtifacts.map((artifact) => ({ ...artifact })),
199
+ ownership: {
200
+ ownerEpoch: input.ownership.ownerEpoch,
201
+ ownershipClaimFingerprint: input.ownership.claimFingerprint,
202
+ },
197
203
  acceptedWarnings: [...input.policy.acceptedWarnings],
198
204
  },
199
205
  service: {
@@ -229,6 +235,10 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
229
235
  profile: input.profile,
230
236
  sourceStorageFingerprint,
231
237
  targetStorageFingerprint: identity.storageIdFingerprint,
238
+ ownership: {
239
+ ownerEpoch: input.ownership.ownerEpoch,
240
+ ownershipClaimFingerprint: input.ownership.claimFingerprint,
241
+ },
232
242
  runDoctor: async (migrationId) => dependencies.runDoctor(await doctorInput(migrationId)),
233
243
  runMigration: async (doctor) => {
234
244
  await gate.closeAndFlush();
@@ -264,6 +274,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
264
274
  environment: operation.environment,
265
275
  workspaceId: operation.workspaceId,
266
276
  profile: operation.profile,
277
+ ...(operation.migration.ownership === undefined ? {} : operation.migration.ownership),
267
278
  }, sendStatus);
268
279
  if (outcome.reopenAdmission)
269
280
  gate.reopen();
@@ -285,20 +296,31 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
285
296
  };
286
297
  const bindServerIdentity = (server) => {
287
298
  const nextWorkspaceId = UUID.parse(server.workspaceId).toLowerCase();
299
+ const nextMachineId = UUID.parse(server.machineId).toLowerCase();
288
300
  if (workspaceId !== null && workspaceId !== nextWorkspaceId) {
289
301
  throw new Error("Storage migration server workspace identity changed");
290
302
  }
291
- workspaceId = nextWorkspaceId;
303
+ if (machineId !== null && machineId !== nextMachineId) {
304
+ throw new Error("Storage migration server machine identity changed");
305
+ }
306
+ if (input.ownership.workspaceId !== nextWorkspaceId
307
+ || input.ownership.machineId !== nextMachineId
308
+ || input.ownership.environment !== input.environment) {
309
+ throw new Error("Storage ownership claim does not match authenticated server identity");
310
+ }
292
311
  const createdAt = dependencies.now().toISOString();
293
- const installation = installationBase(input, workspaceId, undefined, createdAt);
312
+ const installation = installationBase(input, nextWorkspaceId, undefined, createdAt);
294
313
  const layout = storageLayout({
295
314
  nowworkHome: input.nowworkHome,
296
315
  environment: input.environment,
297
- workspaceId,
316
+ workspaceId: nextWorkspaceId,
298
317
  runtimeBase: input.local.runtimeBase,
299
318
  platform: input.platform,
300
319
  });
301
- identity = storageHelloIdentity({ installation, layout });
320
+ const nextIdentity = storageHelloIdentity({ installation, layout, ownership: input.ownership });
321
+ workspaceId = nextWorkspaceId;
322
+ machineId = nextMachineId;
323
+ identity = nextIdentity;
302
324
  ensureControl();
303
325
  drainPendingControlMessages();
304
326
  };
@@ -5,10 +5,11 @@ import { buildServiceSpec } from "./computer-service.js";
5
5
  import { profilePath } from "./computer-profile.js";
6
6
  import { assertManagedServiceIdentity } from "./managed-service-lifecycle.js";
7
7
  import { managedServiceRegistryPath, readManagedServiceRegistry, } from "./managed-service-registry.js";
8
- import { resolveLocalStorageRoots } from "./storage-config.js";
8
+ import { installationsPath, resolveLocalStorageRoots } from "./storage-config.js";
9
9
  import { readStorageMigrationPolicy } from "./storage-migration-policy.js";
10
10
  import { createStorageMigrationRuntimeController, } from "./storage-runtime-controller.js";
11
11
  import { resolveEnvTag, resolveNowworkHome } from "./storage-layout.js";
12
+ import { readStorageOwnershipClaim } from "./storage-ownership.js";
12
13
  const notReady = (reason) => Object.freeze({ ready: false, reason });
13
14
  function canonicalPath(value) {
14
15
  const absolute = resolve(value);
@@ -106,6 +107,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
106
107
  assertManagedServiceIdentity,
107
108
  readPolicy: readStorageMigrationPolicy,
108
109
  createController: createStorageMigrationRuntimeController,
110
+ readOwnership: readStorageOwnershipClaim,
109
111
  ...injected,
110
112
  };
111
113
  let sourceRecord;
@@ -153,6 +155,17 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
153
155
  catch {
154
156
  return notReady("migration_policy_invalid");
155
157
  }
158
+ let ownership;
159
+ try {
160
+ ownership = await dependencies.readOwnership(nowworkHome);
161
+ }
162
+ catch (error) {
163
+ return notReady(error.code === "ENOENT"
164
+ ? "storage_ownership_claim_missing"
165
+ : "storage_ownership_claim_invalid");
166
+ }
167
+ if (ownership.environment !== environment)
168
+ return notReady("storage_ownership_claim_invalid");
156
169
  const local = resolveLocalStorageRoots({
157
170
  platform: input.platform,
158
171
  userHome: input.userHome,
@@ -169,6 +182,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
169
182
  sourceRecord,
170
183
  local,
171
184
  policy,
185
+ ownership,
172
186
  serviceEnvironment: {
173
187
  NOWWORK_HOME: nowworkHome,
174
188
  NOWWORK_ENV_TAG: environment,
@@ -178,6 +192,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
178
192
  { name: "profile.json", path: profilePath(input.profile, input.currentDaemonHome) },
179
193
  { name: "service-descriptor", path: sourceRecord.descriptorPath },
180
194
  { name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome) },
195
+ { name: "installations.json", path: installationsPath(local.configRoot) },
181
196
  ],
182
197
  }, { assertSourceIdentity });
183
198
  return Object.freeze({ ready: true, runtime });
@@ -1,6 +1,7 @@
1
1
  import { findInstallationForProfile, readInstallations, resolveLocalStorageRoots, } from "./storage-config.js";
2
2
  import { storageHelloIdentity } from "./storage-identity.js";
3
3
  import { storageLayout } from "./storage-layout.js";
4
+ import { readStorageOwnershipClaim } from "./storage-ownership.js";
4
5
  const canonicalServerUrl = (value) => value.replace(/\/+$/u, "");
5
6
  export function resolveStorageRuntime(input) {
6
7
  const installation = findInstallationForProfile(input.records, input.profile);
@@ -24,7 +25,11 @@ export function resolveStorageRuntime(input) {
24
25
  return Object.freeze({
25
26
  installation,
26
27
  layout,
27
- identity: storageHelloIdentity({ installation, layout }),
28
+ identity: storageHelloIdentity({
29
+ installation,
30
+ layout,
31
+ ...(input.ownership === undefined ? {} : { ownership: input.ownership }),
32
+ }),
28
33
  local,
29
34
  });
30
35
  }
@@ -35,5 +40,16 @@ export async function loadStorageRuntime(input) {
35
40
  environment: input.environment,
36
41
  });
37
42
  const records = await readInstallations({ configRoot: local.configRoot });
38
- return resolveStorageRuntime({ ...input, records });
43
+ const baseline = resolveStorageRuntime({ ...input, records });
44
+ if (baseline === null)
45
+ return null;
46
+ let ownership;
47
+ try {
48
+ ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot);
49
+ }
50
+ catch (error) {
51
+ if (error.code !== "ENOENT")
52
+ throw error;
53
+ }
54
+ return ownership === undefined ? baseline : resolveStorageRuntime({ ...input, records, ownership });
39
55
  }
@@ -18,6 +18,8 @@ const DeliveredFrameSchema = z.object({
18
18
  environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
19
19
  workspaceId: z.string().uuid(),
20
20
  profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
21
+ ownerEpoch: z.number().int().positive().safe().optional(),
22
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
21
23
  sourceStorageFingerprint: z.string().optional(),
22
24
  targetStorageFingerprint: z.string().optional(),
23
25
  manifestDigest: z.string().optional(),
@@ -45,6 +47,8 @@ export function storageStatusEventId(frame) {
45
47
  frame.environment,
46
48
  frame.workspaceId,
47
49
  frame.profile,
50
+ frame.ownerEpoch ?? null,
51
+ frame.ownershipClaimFingerprint ?? null,
48
52
  frame.sourceStorageFingerprint ?? null,
49
53
  frame.targetStorageFingerprint ?? null,
50
54
  frame.manifestDigest ?? null,
@@ -35,7 +35,10 @@ export function storageMigrationStatusReplay(receipt, expected) {
35
35
  if (expected !== undefined && (receipt.migrationId !== expected.migrationId
36
36
  || receipt.environment !== expected.environment
37
37
  || receipt.workspaceId !== expected.workspaceId
38
- || receipt.profile !== expected.profile)) {
38
+ || receipt.profile !== expected.profile
39
+ || (expected.ownerEpoch !== undefined && receipt.ownerEpoch !== expected.ownerEpoch)
40
+ || (expected.ownershipClaimFingerprint !== undefined
41
+ && receipt.ownershipClaimFingerprint !== expected.ownershipClaimFingerprint))) {
39
42
  throw new Error("storage migration receipt identity mismatch");
40
43
  }
41
44
  return Object.freeze(storageMigrationReplayStatuses(receipt).map((status) => Object.freeze({
@@ -45,6 +48,10 @@ export function storageMigrationStatusReplay(receipt, expected) {
45
48
  environment: receipt.environment,
46
49
  workspaceId: receipt.workspaceId,
47
50
  profile: receipt.profile,
51
+ ...(receipt.ownerEpoch === undefined ? {} : { ownerEpoch: receipt.ownerEpoch }),
52
+ ...(receipt.ownershipClaimFingerprint === undefined ? {} : {
53
+ ownershipClaimFingerprint: receipt.ownershipClaimFingerprint,
54
+ }),
48
55
  ...(status === "verified" ? { finalManifestDigest: receipt.manifestDigest } : {}),
49
56
  ...(status === "failed"
50
57
  ? {
@@ -98,6 +105,10 @@ export async function monitorStorageMigrationStatus(input, send, injected = {})
98
105
  environment: input.environment,
99
106
  workspaceId: input.workspaceId,
100
107
  profile: input.profile,
108
+ ...(input.ownerEpoch === undefined ? {} : { ownerEpoch: input.ownerEpoch }),
109
+ ...(input.ownershipClaimFingerprint === undefined ? {} : {
110
+ ownershipClaimFingerprint: input.ownershipClaimFingerprint,
111
+ }),
101
112
  errorCode: workerFailure.errorCode,
102
113
  errorMessage: workerFailure.errorMessage,
103
114
  ...(receipt?.sourceStopAttempted === false || receipt === null ? { reopenAdmission: true } : {}),
@@ -114,6 +125,10 @@ export async function monitorStorageMigrationStatus(input, send, injected = {})
114
125
  environment: input.environment,
115
126
  workspaceId: input.workspaceId,
116
127
  profile: input.profile,
128
+ ...(input.ownerEpoch === undefined ? {} : { ownerEpoch: input.ownerEpoch }),
129
+ ...(input.ownershipClaimFingerprint === undefined ? {} : {
130
+ ownershipClaimFingerprint: input.ownershipClaimFingerprint,
131
+ }),
117
132
  errorCode: "worker_heartbeat_stale_recovery_required",
118
133
  errorMessage: "storage migration worker heartbeat expired; explicit recovery is required",
119
134
  ...(receipt?.sourceStopAttempted === false || receipt === null ? { reopenAdmission: true } : {}),
@@ -367,6 +367,7 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
367
367
  targetWorkspaceRoot: operation.migration.targetWorkspaceRoot,
368
368
  backupsRoot: operation.migration.backupsRoot,
369
369
  backupArtifacts: operation.migration.backupArtifacts,
370
+ ...(operation.migration.ownership === undefined ? {} : { ownership: operation.migration.ownership }),
370
371
  acceptedWarnings: operation.migration.acceptedWarnings,
371
372
  onStatus: () => assertHeartbeatHealthy(),
372
373
  assertHealthy: assertHeartbeatHealthy,
@@ -118,6 +118,10 @@ const OperationSchema = z.object({
118
118
  name: z.string().regex(/^[a-z0-9][a-z0-9_.-]{0,127}$/u),
119
119
  path: AbsolutePathSchema,
120
120
  }).strict()).max(32),
121
+ ownership: z.object({
122
+ ownerEpoch: z.number().int().positive().safe(),
123
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
124
+ }).strict().optional(),
121
125
  acceptedWarnings: z.array(StorageMigrationWarningCodeSchema)
122
126
  .max(StorageMigrationWarningCodeSchema.options.length),
123
127
  }).strict(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.77-beta.6",
3
+ "version": "0.6.77-beta.8",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -16,6 +16,14 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
+ "scripts": {
20
+ "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
21
+ "build": "tsc -p tsconfig.json",
22
+ "codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
23
+ "prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
24
+ "test": "vitest run",
25
+ "typecheck": "tsc --noEmit"
26
+ },
19
27
  "dependencies": {
20
28
  "@agentclientprotocol/sdk": "1.2.1",
21
29
  "@nowcrew/cli": "0.4.13",
@@ -36,12 +44,5 @@
36
44
  "tsx": "^4.19.0",
37
45
  "typescript": "^5.6.0",
38
46
  "vitest": "^2.1.0"
39
- },
40
- "scripts": {
41
- "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
42
- "build": "tsc -p tsconfig.json",
43
- "codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
44
- "test": "vitest run",
45
- "typecheck": "tsc --noEmit"
46
47
  }
47
- }
48
+ }