@nowcrew/daemon 0.6.77-beta.7 → 0.6.77-beta.9

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.
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { defaultProcessController } from "./execution-journal.js";
6
6
  import { createJournalLease, createJournalLeaseRegistry, JournalLockedError, } from "./execution-journal-lock.js";
7
7
  import { dslog } from "./slog.js";
8
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
8
9
  const HOST_EXECUTION_SLOT_COUNT = 10;
9
10
  const HOST_STARTUP_SLOT_COUNT = 10;
10
11
  const HOST_STARTUP_GAP_MS = 500;
@@ -16,10 +17,10 @@ export class HostReservationCancelledError extends Error {
16
17
  super(message);
17
18
  }
18
19
  }
19
- export function defaultHostExecutionCoordinatorRoot(userHome = homedir()) {
20
+ export function defaultHostExecutionCoordinatorRoot(userHome = homedir(), localHome) {
20
21
  // Deliberately independent of CREW_DAEMON_HOME and agentsRoot: every profile
21
22
  // owned by this OS user must share the same physical-compute safety boundary.
22
- return resolve(userHome, ".crew", "daemon", "compute-execution");
23
+ return resolve(localHome ?? resolveDaemonLocalStateRoot({ userHome, platform: process.platform }), "daemon", "compute-execution");
23
24
  }
24
25
  async function syncDirectory(path) {
25
26
  if (process.platform === "win32")
@@ -132,7 +133,7 @@ async function writeLaunchTimestamp(path, value) {
132
133
  }
133
134
  }
134
135
  export function createHostExecutionCoordinator(options = {}) {
135
- const root = resolve(options.root ?? defaultHostExecutionCoordinatorRoot());
136
+ const root = resolve(options.root ?? defaultHostExecutionCoordinatorRoot(options.userHome, options.localHome));
136
137
  const executionSlots = options.executionSlots ?? HOST_EXECUTION_SLOT_COUNT;
137
138
  const startupSlots = options.startupSlots ?? HOST_STARTUP_SLOT_COUNT;
138
139
  const startupGapMs = options.startupGapMs ?? HOST_STARTUP_GAP_MS;
package/dist/main.js CHANGED
@@ -30,6 +30,7 @@ import { storageMigrationStatusReplay } from "./storage-status-replay.js";
30
30
  import { runStorageWorkerCommand } from "./storage-worker-command.js";
31
31
  import { probeStorageMigrationRuntime } from "./storage-runtime-readiness.js";
32
32
  import { createStorageTargetConfirmationHandler } from "./storage-target-confirmation.js";
33
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
33
34
  async function main() {
34
35
  const workerResult = await runStorageWorkerCommand(process.argv.slice(2));
35
36
  if (workerResult !== null) {
@@ -73,8 +74,16 @@ async function main() {
73
74
  if (values["daemon-home"])
74
75
  process.env.CREW_DAEMON_HOME = values["daemon-home"];
75
76
  let activeDaemonHome;
77
+ const localHome = resolveDaemonLocalStateRoot({
78
+ userHome: homedir(),
79
+ platform: process.platform,
80
+ explicit: process.env.NOWWORK_LOCAL_HOME,
81
+ mode: process.env.NOWWORK_LOCAL_HOME_MODE
82
+ ?? process.env.NOWWORK_STORAGE_MODE
83
+ ?? (values["daemon-home"] === undefined ? undefined : "legacy"),
84
+ });
76
85
  if (values.profile) {
77
- const home = daemonHome();
86
+ const home = daemonHome(process.env, values.profile);
78
87
  activeDaemonHome = home;
79
88
  const profile = await loadProfile(values.profile, home);
80
89
  if (cmd === "serve") {
@@ -83,6 +92,7 @@ async function main() {
83
92
  platform: process.platform,
84
93
  profile: profile.name,
85
94
  userHome: homedir(),
95
+ localHome,
86
96
  uid: process.getuid?.(),
87
97
  daemonHome: home,
88
98
  agentsRoot: resolveAgentsRoot(profile.agentsRoot, homedir(), process.platform),
@@ -141,7 +151,7 @@ async function main() {
141
151
  : daemonGlobalInstallation(builtDaemonEntry(), process.platform);
142
152
  const installationLease = installation === null
143
153
  ? null
144
- : await acquireDaemonInstallationLease(installation.npmPrefix);
154
+ : await acquireDaemonInstallationLease(installation.npmPrefix, { localHome });
145
155
  const migrationRuntime = values.profile === undefined || activeDaemonHome === undefined || storageRuntime !== null
146
156
  ? { ready: false, reason: "not_legacy_source" }
147
157
  : await probeStorageMigrationRuntime({
@@ -198,6 +208,7 @@ async function main() {
198
208
  await runWithDaemonInstallationLease(installationLease, async () => {
199
209
  const service = serve(runtimeConfig, {
200
210
  ...(values.profile === undefined ? {} : { profileName: values.profile }),
211
+ localHome,
201
212
  ...(storageMigration === undefined ? {} : { storageMigration }),
202
213
  });
203
214
  await runServeLifecycle(service);
@@ -16,6 +16,7 @@ export async function inspectManagedServiceDiagnostics(input) {
16
16
  const inspectLease = input.inspectInstallationLease
17
17
  ?? (() => inspectDaemonInstallationLease(input.expectedRecord.npmPrefix, {
18
18
  ...(userHome === undefined ? {} : { userHome }),
19
+ ...(registry.localHome === undefined ? {} : { localHome: registry.localHome }),
19
20
  }));
20
21
  let services = null;
21
22
  let registryCheck;
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, resolve } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { acquireProfileSaveLock } from "./computer-profile-lock.js";
8
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
8
9
  const AbsolutePathSchema = z.string().min(1).refine(isAbsolute, "must be an absolute path");
9
10
  const ManagedServiceRecordSchema = z.object({
10
11
  version: z.literal(1),
@@ -42,11 +43,15 @@ export class ManagedServiceConflictError extends ManagedServiceRegistryError {
42
43
  this.owner = owner;
43
44
  }
44
45
  }
45
- function registryDirectory(userHome) {
46
- return resolve(userHome, ".crew", "daemon", "managed-services");
46
+ function registryDirectory(userHome, localHome) {
47
+ const root = localHome ?? resolveDaemonLocalStateRoot({
48
+ userHome,
49
+ platform: process.platform,
50
+ });
51
+ return resolve(root, "daemon", "managed-services");
47
52
  }
48
- export function managedServiceRegistryPath(userHome = homedir()) {
49
- return resolve(registryDirectory(userHome), "registry.json");
53
+ export function managedServiceRegistryPath(userHome = homedir(), localHome) {
54
+ return resolve(registryDirectory(userHome, localHome), "registry.json");
50
55
  }
51
56
  export async function listManagedServiceDescriptorPaths(platform, userHome = homedir()) {
52
57
  const directory = platform === "darwin"
@@ -219,14 +224,14 @@ export function managedServiceConflict(services, candidate) {
219
224
  }
220
225
  export async function withManagedServiceRegistryLock(options, operation) {
221
226
  const userHome = options.userHome ?? homedir();
222
- const directory = registryDirectory(userHome);
227
+ const directory = registryDirectory(userHome, options.localHome);
223
228
  const release = await acquireProfileSaveLock(directory, {
224
229
  ...(options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs }),
225
230
  ...(options.lockRetryMs === undefined ? {} : { retryMs: options.lockRetryMs }),
226
231
  });
227
232
  let operationError;
228
233
  try {
229
- const path = managedServiceRegistryPath(userHome);
234
+ const path = managedServiceRegistryPath(userHome, options.localHome);
230
235
  const services = await readRegistryFile(path);
231
236
  return await operation({
232
237
  services,
@@ -258,7 +263,7 @@ export async function withManagedServiceRegistryLock(options, operation) {
258
263
  }
259
264
  }
260
265
  export async function readManagedServiceRegistry(options = {}) {
261
- return readRegistryFile(managedServiceRegistryPath(options.userHome ?? homedir()));
266
+ return readRegistryFile(managedServiceRegistryPath(options.userHome ?? homedir(), options.localHome));
262
267
  }
263
268
  export async function registerManagedService(input, options = {}) {
264
269
  const candidate = normalizeManagedServiceRecord(input);
@@ -17,7 +17,10 @@ export async function assertManagedServiceStartup(input) {
17
17
  ...(input.environment === undefined ? {} : { environment: input.environment }),
18
18
  });
19
19
  const readRegistry = input.readManagedServices
20
- ?? (() => readManagedServiceRegistry({ userHome: input.userHome }));
20
+ ?? (() => readManagedServiceRegistry({
21
+ userHome: input.userHome,
22
+ ...(input.localHome === undefined ? {} : { localHome: input.localHome }),
23
+ }));
21
24
  const listDescriptors = input.listManagedDescriptorPaths
22
25
  ?? (() => listManagedServiceDescriptorPaths(input.platform, input.userHome));
23
26
  const readDescriptor = input.readServiceDescriptor ?? readServiceDescriptor;
package/dist/serve.js CHANGED
@@ -53,6 +53,7 @@ import { handleRuntimeHealthProbeFrame } from "./runtime-health-handler.js";
53
53
  import { buildControlPlaneUrl } from "./control-plane-url.js";
54
54
  import { createServeMigrationController, handleMigrationControlMessage } from "./daemon-migration-wiring.js";
55
55
  import { createCodexHomeModeController } from "./codex-home-mode-controller.js";
56
+ import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
56
57
  import { attachStorageMigrationRuntime, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
57
58
  export { buildControlPlaneUrl } from "./control-plane-url.js";
58
59
  // normalize.ts 的活动种类 → activity 枚举
@@ -83,8 +84,10 @@ export function serve(config, opts = {}) {
83
84
  let detectedExecutionRuntimes = [], executionRuntimesKnown = false;
84
85
  let runtimeFacts = null;
85
86
  const runtimeProbe = createRuntimeProbeCoordinator();
87
+ const localHome = opts.localHome ?? resolveDaemonLocalStateRootFromEnv();
86
88
  const hostCoordinator = opts.execution?.hostCoordinator
87
89
  ?? createHostExecutionCoordinator({
90
+ localHome,
88
91
  executionSlots: config.executionLimits.maxParallelTotal,
89
92
  startupSlots: config.executionLimits.maxStartingTotal,
90
93
  startupGapMs: config.executionLimits.startupGapMs,
@@ -825,9 +828,9 @@ export function serve(config, opts = {}) {
825
828
  // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
826
829
  const r = msg;
827
830
  setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
828
- if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
831
+ if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
829
832
  try {
830
- opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
833
+ opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId, machineId: r.machineId });
831
834
  machineHelloPublisher.republish();
832
835
  }
833
836
  catch (error) {
package/dist/slog.js CHANGED
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
15
15
  import { join, resolve } from "node:path";
16
16
  import { createHash } from "node:crypto";
17
17
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
18
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
18
19
  const FLUSH_INTERVAL_MS = 1500;
19
20
  const BATCH_SIZE = 50;
20
21
  const SPOOL_MAX_FILES = 50; // spool 目录文件数上限,超出丢最旧(日志非关键数据,防无限膨胀)
@@ -27,6 +28,7 @@ let flushPromise = null;
27
28
  let inFlightBatch = [];
28
29
  let lastErrorAt = 0;
29
30
  let exitHookInstalled = false;
31
+ let localStateRoot;
30
32
  function deploymentEnvironment(serverHost) {
31
33
  if (serverHost === "nowwork-in.nowcoder.com")
32
34
  return "prod";
@@ -40,12 +42,23 @@ function deploymentEnvironment(serverHost) {
40
42
  return "unknown";
41
43
  }
42
44
  function spoolDir() {
43
- return process.env.CREW_SLS_SPOOL_DIR ?? join(homedir(), ".crew", "logs", "sls-spool");
45
+ return process.env.CREW_SLS_SPOOL_DIR ?? join(localStateRoot ?? resolveDaemonLocalStateRoot({
46
+ userHome: homedir(),
47
+ platform: process.platform,
48
+ explicit: process.env.NOWWORK_LOCAL_HOME,
49
+ mode: process.env.NOWWORK_LOCAL_HOME_MODE ?? process.env.NOWWORK_STORAGE_MODE,
50
+ }), "logs", "sls-spool");
44
51
  }
45
52
  /** serve/run 启动时调用一次;disabled(CREW_SLS_LOG=off)则保持 no-op。 */
46
53
  export function initSlog(serverUrl, machineToken, identity = {}) {
47
54
  if (process.env.CREW_SLS_LOG === "off" || process.env.CREW_SLS_LOG === "0")
48
55
  return;
56
+ localStateRoot = resolveDaemonLocalStateRoot({
57
+ userHome: homedir(),
58
+ platform: process.platform,
59
+ explicit: process.env.NOWWORK_LOCAL_HOME,
60
+ mode: process.env.NOWWORK_LOCAL_HOME_MODE ?? process.env.NOWWORK_STORAGE_MODE,
61
+ });
49
62
  cfg = { serverUrl: serverUrl.replace(/\/+$/, ""), token: machineToken };
50
63
  let serverHost;
51
64
  try {
@@ -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,12 @@ 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";
13
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
12
14
  const notReady = (reason) => Object.freeze({ ready: false, reason });
13
15
  function canonicalPath(value) {
14
16
  const absolute = resolve(value);
@@ -73,6 +75,12 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
73
75
  serverUrl: z.string().url().parse(rawInput.serverUrl),
74
76
  platform: rawInput.platform,
75
77
  };
78
+ const localHome = resolveDaemonLocalStateRoot({
79
+ userHome: rawInput.userHome,
80
+ platform: rawInput.platform,
81
+ explicit: rawInput.environment.NOWWORK_LOCAL_HOME,
82
+ mode: rawInput.environment.NOWWORK_LOCAL_HOME_MODE ?? rawInput.environment.NOWWORK_STORAGE_MODE,
83
+ });
76
84
  let nowworkHome;
77
85
  let environment;
78
86
  try {
@@ -106,11 +114,12 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
106
114
  assertManagedServiceIdentity,
107
115
  readPolicy: readStorageMigrationPolicy,
108
116
  createController: createStorageMigrationRuntimeController,
117
+ readOwnership: readStorageOwnershipClaim,
109
118
  ...injected,
110
119
  };
111
120
  let sourceRecord;
112
121
  try {
113
- const records = await dependencies.readManagedServices({ userHome: input.userHome });
122
+ const records = await dependencies.readManagedServices({ userHome: input.userHome, localHome });
114
123
  const found = findSourceRecord(records, input);
115
124
  if (found === null)
116
125
  return notReady("source_service_record_missing");
@@ -132,7 +141,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
132
141
  const assertSourceIdentity = () => dependencies.assertManagedServiceIdentity({
133
142
  spec: sourceSpec,
134
143
  record: sourceRecord,
135
- registry: { userHome: input.userHome },
144
+ registry: { userHome: input.userHome, localHome },
136
145
  });
137
146
  try {
138
147
  await assertSourceIdentity();
@@ -153,6 +162,17 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
153
162
  catch {
154
163
  return notReady("migration_policy_invalid");
155
164
  }
165
+ let ownership;
166
+ try {
167
+ ownership = await dependencies.readOwnership(nowworkHome);
168
+ }
169
+ catch (error) {
170
+ return notReady(error.code === "ENOENT"
171
+ ? "storage_ownership_claim_missing"
172
+ : "storage_ownership_claim_invalid");
173
+ }
174
+ if (ownership.environment !== environment)
175
+ return notReady("storage_ownership_claim_invalid");
156
176
  const local = resolveLocalStorageRoots({
157
177
  platform: input.platform,
158
178
  userHome: input.userHome,
@@ -169,6 +189,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
169
189
  sourceRecord,
170
190
  local,
171
191
  policy,
192
+ ownership,
172
193
  serviceEnvironment: {
173
194
  NOWWORK_HOME: nowworkHome,
174
195
  NOWWORK_ENV_TAG: environment,
@@ -177,7 +198,8 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
177
198
  backupArtifacts: [
178
199
  { name: "profile.json", path: profilePath(input.profile, input.currentDaemonHome) },
179
200
  { name: "service-descriptor", path: sourceRecord.descriptorPath },
180
- { name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome) },
201
+ { name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome, localHome) },
202
+ { name: "installations.json", path: installationsPath(local.configRoot) },
181
203
  ],
182
204
  }, { assertSourceIdentity });
183
205
  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
  }