@nowcrew/daemon 0.6.50 → 0.6.52

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.
@@ -0,0 +1,277 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, dirname, join, posix, win32 } from "node:path";
5
+ import { z } from "zod";
6
+ import { atomicPrivateWrite } from "./atomic-private-write.js";
7
+ import { assertStorageControlMaterialTokenFree } from "./storage-credential-guard.js";
8
+ import { storageLayout } from "./storage-layout.js";
9
+ import { StorageMigrationWarningCodeSchema } from "./storage-migration-policy.js";
10
+ const CONTROL = /[\0\r\n]/u;
11
+ const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
12
+ const ENVIRONMENT = /^[a-z0-9][a-z0-9_-]{0,31}$/u;
13
+ const HASH = /^[0-9a-f]{64}$/u;
14
+ const AbsolutePathSchema = z.string().min(1).max(32768)
15
+ .refine((value) => !CONTROL.test(value), "path must be a single line")
16
+ .refine((value) => posix.isAbsolute(value) || win32.isAbsolute(value), "path must be absolute");
17
+ const RelativePathSchema = z.string().min(1).max(32768)
18
+ .refine((value) => !CONTROL.test(value), "path must be a single line")
19
+ .refine((value) => !posix.isAbsolute(value) && !win32.isAbsolute(value), "path must be relative")
20
+ .refine((value) => !value.split(/[\\/]/u).some((segment) => segment === "" || segment === "." || segment === ".."), "path must be normalized");
21
+ const SourceKindSchema = z.enum(["daemon-home", "agents-root", "legacy-agents-root"]);
22
+ const PolicySchema = z.object({
23
+ exclusions: z.array(z.object({
24
+ sourceKind: SourceKindSchema,
25
+ sourcePath: RelativePathSchema,
26
+ reason: z.string().min(1).max(256),
27
+ }).strict()).max(100_000),
28
+ transformations: z.array(z.object({
29
+ sourceKind: SourceKindSchema,
30
+ sourcePath: RelativePathSchema,
31
+ logicalDestination: RelativePathSchema,
32
+ sourceSha256: z.string().regex(HASH),
33
+ targetSha256: z.string().regex(HASH),
34
+ targetMode: z.string().regex(/^[0-7]{4}$/u),
35
+ transform: z.literal("redact_live_like_tokens_v1"),
36
+ }).strict()).max(100_000),
37
+ }).strict();
38
+ const DoctorInputSchema = z.object({
39
+ migrationId: z.string().uuid(),
40
+ generatedAt: z.string().datetime({ offset: true }),
41
+ environment: z.string().regex(ENVIRONMENT),
42
+ workspaceId: z.string().uuid(),
43
+ profile: z.string().regex(PROFILE),
44
+ targetWorkspaceRoot: AbsolutePathSchema,
45
+ sources: z.array(z.object({
46
+ kind: SourceKindSchema,
47
+ root: AbsolutePathSchema,
48
+ logicalPrefix: RelativePathSchema,
49
+ }).strict()).min(1).max(3),
50
+ policy: PolicySchema,
51
+ state: z.object({
52
+ profileIdentityExact: z.boolean(),
53
+ duplicateWorkspaceMappings: z.number().int().nonnegative(),
54
+ activeAgentExecutions: z.number().int().nonnegative(),
55
+ activeScheduledRuns: z.number().int().nonnegative(),
56
+ }).strict(),
57
+ freeSpaceMultiplier: z.number().finite().min(1).max(10).optional(),
58
+ }).strict();
59
+ const ManagedServiceRecordSchema = z.object({
60
+ version: z.literal(1),
61
+ generation: z.string().uuid(),
62
+ platform: z.enum(["darwin", "linux"]),
63
+ serviceId: z.string().min(1).max(256),
64
+ profile: z.string().regex(PROFILE),
65
+ daemonHome: AbsolutePathSchema,
66
+ agentsRoot: AbsolutePathSchema,
67
+ nodePath: AbsolutePathSchema,
68
+ entryPath: AbsolutePathSchema,
69
+ packageRoot: AbsolutePathSchema,
70
+ npmPrefix: AbsolutePathSchema,
71
+ descriptorPath: AbsolutePathSchema,
72
+ descriptorSha256: z.string().regex(HASH),
73
+ createdAt: z.string().datetime({ offset: true }),
74
+ }).strict();
75
+ const InstallationRecordSchema = z.object({
76
+ version: z.literal(1),
77
+ installationId: z.string().uuid(),
78
+ profile: z.string().regex(PROFILE),
79
+ serverUrl: z.string().url().max(2048).refine((value) => {
80
+ const url = new URL(value);
81
+ return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
82
+ }, "serverUrl must be HTTP(S) without credentials"),
83
+ environment: z.string().regex(ENVIRONMENT),
84
+ workspaceId: z.string().uuid(),
85
+ storageId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/u),
86
+ nowworkHome: AbsolutePathSchema,
87
+ runtimeRoot: AbsolutePathSchema,
88
+ credentialRef: z.string().regex(/^profile:[a-z0-9][a-z0-9_-]{0,47}$/u),
89
+ storageMigrationId: z.string().uuid(),
90
+ createdAt: z.string().datetime({ offset: true }),
91
+ updatedAt: z.string().datetime({ offset: true }),
92
+ }).strict();
93
+ const OperationSchema = z.object({
94
+ version: z.literal(1),
95
+ migrationId: z.string().uuid(),
96
+ createdAt: z.string().datetime({ offset: true }),
97
+ environment: z.string().regex(ENVIRONMENT),
98
+ workspaceId: z.string().uuid(),
99
+ profile: z.string().regex(PROFILE),
100
+ expectedManifestDigest: z.string().regex(HASH),
101
+ doctorInput: DoctorInputSchema,
102
+ migration: z.object({
103
+ sourceRoots: z.object({
104
+ "daemon-home": AbsolutePathSchema.optional(),
105
+ "agents-root": AbsolutePathSchema.optional(),
106
+ "legacy-agents-root": AbsolutePathSchema.optional(),
107
+ }).strict(),
108
+ stagingWorkspaceRoot: AbsolutePathSchema,
109
+ targetWorkspaceRoot: AbsolutePathSchema,
110
+ backupsRoot: AbsolutePathSchema,
111
+ backupArtifacts: z.array(z.object({
112
+ name: z.string().regex(/^[a-z0-9][a-z0-9_.-]{0,127}$/u),
113
+ path: AbsolutePathSchema,
114
+ }).strict()).max(32),
115
+ acceptedWarnings: z.array(StorageMigrationWarningCodeSchema)
116
+ .max(StorageMigrationWarningCodeSchema.options.length),
117
+ }).strict(),
118
+ service: z.object({
119
+ platform: z.enum(["darwin", "linux"]),
120
+ userHome: AbsolutePathSchema,
121
+ uid: z.number().int().nonnegative().optional(),
122
+ sourceWasRunning: z.boolean(),
123
+ sourceRecord: ManagedServiceRecordSchema,
124
+ targetRecord: ManagedServiceRecordSchema,
125
+ }).strict(),
126
+ installation: z.object({
127
+ configRoot: AbsolutePathSchema,
128
+ target: InstallationRecordSchema,
129
+ }).strict(),
130
+ }).strict();
131
+ const pathsFor = (_platform) => posix;
132
+ function canonicalPath(value, platform) {
133
+ const paths = pathsFor(platform);
134
+ const absolute = paths.resolve(value);
135
+ try {
136
+ return realpathSync.native(absolute);
137
+ }
138
+ catch (error) {
139
+ const code = error.code;
140
+ if (code === "ENOTDIR")
141
+ return absolute;
142
+ if (code !== "ENOENT")
143
+ throw error;
144
+ }
145
+ const missing = [];
146
+ let ancestor = absolute;
147
+ for (;;) {
148
+ const parent = paths.dirname(ancestor);
149
+ if (parent === ancestor)
150
+ return absolute;
151
+ missing.unshift(paths.basename(ancestor));
152
+ ancestor = parent;
153
+ try {
154
+ return paths.resolve(realpathSync.native(ancestor), ...missing);
155
+ }
156
+ catch (error) {
157
+ const code = error.code;
158
+ if (code === "ENOTDIR")
159
+ return absolute;
160
+ if (code !== "ENOENT")
161
+ throw error;
162
+ }
163
+ }
164
+ }
165
+ function samePath(left, right, platform) {
166
+ return canonicalPath(left, platform) === canonicalPath(right, platform);
167
+ }
168
+ function isInside(root, candidate, platform) {
169
+ const paths = pathsFor(platform);
170
+ const child = paths.relative(canonicalPath(root, platform), canonicalPath(candidate, platform));
171
+ return child === "" || (!child.startsWith("..") && !paths.isAbsolute(child));
172
+ }
173
+ function assertSemanticIdentity(operation) {
174
+ const identity = [operation.doctorInput, operation.installation.target];
175
+ if (identity.some((value) => value.workspaceId !== operation.workspaceId
176
+ || value.environment !== operation.environment || value.profile !== operation.profile)
177
+ || operation.doctorInput.migrationId !== operation.migrationId
178
+ || operation.installation.target.storageMigrationId !== operation.migrationId
179
+ || operation.installation.target.credentialRef !== `profile:${operation.profile}`) {
180
+ throw new Error("storage worker operation identity does not match");
181
+ }
182
+ if (operation.doctorInput.state.profileIdentityExact !== true
183
+ || operation.doctorInput.state.duplicateWorkspaceMappings !== 0
184
+ || operation.doctorInput.state.activeAgentExecutions !== 0
185
+ || operation.doctorInput.state.activeScheduledRuns !== 0) {
186
+ throw new Error("storage worker operation doctor state is not safe");
187
+ }
188
+ const { platform, sourceRecord, targetRecord } = operation.service;
189
+ if (sourceRecord.platform !== platform || targetRecord.platform !== platform
190
+ || sourceRecord.profile !== operation.profile || targetRecord.profile !== operation.profile
191
+ || sourceRecord.serviceId === targetRecord.serviceId
192
+ || !samePath(sourceRecord.nodePath, targetRecord.nodePath, platform)
193
+ || !samePath(sourceRecord.entryPath, targetRecord.entryPath, platform)
194
+ || !samePath(sourceRecord.packageRoot, targetRecord.packageRoot, platform)
195
+ || !samePath(sourceRecord.npmPrefix, targetRecord.npmPrefix, platform)
196
+ || !samePath(targetRecord.daemonHome, operation.installation.configRoot, platform)) {
197
+ throw new Error("storage worker service identity does not match");
198
+ }
199
+ const sourceKinds = new Set();
200
+ for (const source of operation.doctorInput.sources) {
201
+ if (sourceKinds.has(source.kind) || !samePath(operation.migration.sourceRoots[source.kind] ?? "", source.root, platform)) {
202
+ throw new Error("storage worker source roots do not match doctor input");
203
+ }
204
+ sourceKinds.add(source.kind);
205
+ }
206
+ const configuredSourceKinds = Object.entries(operation.migration.sourceRoots)
207
+ .filter(([, value]) => value !== undefined)
208
+ .map(([kind]) => kind);
209
+ if (configuredSourceKinds.length !== sourceKinds.size || configuredSourceKinds.some((kind) => !sourceKinds.has(kind))
210
+ || !samePath(operation.migration.sourceRoots["daemon-home"] ?? "", sourceRecord.daemonHome, platform)
211
+ || !samePath(operation.migration.sourceRoots["agents-root"] ?? "", sourceRecord.agentsRoot, platform)) {
212
+ throw new Error("storage worker source service roots do not match");
213
+ }
214
+ const projected = storageLayout({
215
+ nowworkHome: operation.installation.target.nowworkHome,
216
+ environment: operation.environment,
217
+ workspaceId: operation.workspaceId,
218
+ runtimeBase: join(operation.service.userHome, ".nowwork-worker-runtime-validation"),
219
+ platform,
220
+ });
221
+ if (!samePath(projected.workspaceRoot, operation.migration.targetWorkspaceRoot, platform)
222
+ || !samePath(projected.workspaceRoot, operation.doctorInput.targetWorkspaceRoot, platform)
223
+ || !samePath(projected.agentItemsRoot, targetRecord.agentsRoot, platform)) {
224
+ throw new Error("storage worker target layout does not match installation identity");
225
+ }
226
+ if (dirname(operation.migration.stagingWorkspaceRoot) !== dirname(operation.migration.targetWorkspaceRoot)
227
+ || !basename(operation.migration.stagingWorkspaceRoot).includes(operation.migrationId)) {
228
+ throw new Error("storage worker staging identity does not match target");
229
+ }
230
+ if (isInside(operation.installation.target.nowworkHome, operation.migration.backupsRoot, platform)
231
+ || isInside(operation.installation.target.nowworkHome, operation.installation.configRoot, platform)) {
232
+ throw new Error("storage worker local state must remain outside NOWWORK_HOME");
233
+ }
234
+ }
235
+ function parseOperation(raw) {
236
+ const operation = OperationSchema.parse(raw);
237
+ assertSemanticIdentity(operation);
238
+ return operation;
239
+ }
240
+ function sha256(value) {
241
+ return createHash("sha256").update(value).digest("hex");
242
+ }
243
+ export async function writeStorageWorkerOperation(raw, input) {
244
+ try {
245
+ const operation = parseOperation(raw);
246
+ const stateRoot = AbsolutePathSchema.parse(input.stateRoot);
247
+ const path = join(stateRoot, "migrations", operation.migrationId, "operation.json");
248
+ const serialized = `${JSON.stringify(operation, null, 2)}\n`;
249
+ assertStorageControlMaterialTokenFree(serialized, "storage worker operation");
250
+ await atomicPrivateWrite(path, serialized);
251
+ return Object.freeze({ path, sha256: sha256(serialized) });
252
+ }
253
+ catch (error) {
254
+ throw new Error("Invalid storage worker operation", { cause: error });
255
+ }
256
+ }
257
+ export async function readStorageWorkerOperation(path, expectedSha256) {
258
+ try {
259
+ AbsolutePathSchema.parse(path);
260
+ z.string().regex(HASH).parse(expectedSha256);
261
+ const raw = await readFile(path);
262
+ assertStorageControlMaterialTokenFree(raw.toString("utf8"), "storage worker operation");
263
+ const actual = Buffer.from(sha256(raw), "hex");
264
+ const expected = Buffer.from(expectedSha256, "hex");
265
+ if (!timingSafeEqual(actual, expected))
266
+ throw new Error("storage worker operation digest mismatch");
267
+ const operation = parseOperation(JSON.parse(raw.toString("utf8")));
268
+ if (basename(path) !== "operation.json" || basename(dirname(path)) !== operation.migrationId
269
+ || basename(dirname(dirname(path))) !== "migrations") {
270
+ throw new Error("storage worker operation path does not match migration identity");
271
+ }
272
+ return operation;
273
+ }
274
+ catch (error) {
275
+ throw new Error("Invalid storage worker operation", { cause: error });
276
+ }
277
+ }
@@ -0,0 +1,105 @@
1
+ import { readFile, unlink } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { atomicPrivateWrite } from "./atomic-private-write.js";
5
+ const WorkerFailureSchema = z.object({
6
+ version: z.literal(1),
7
+ migrationId: z.string().uuid(),
8
+ status: z.literal("failed"),
9
+ errorCode: z.string().regex(/^[a-z0-9._-]{1,128}$/u),
10
+ errorMessage: z.string().min(1).max(512),
11
+ at: z.string().datetime({ offset: true }),
12
+ }).strict();
13
+ const WorkerHeartbeatSchema = z.object({
14
+ version: z.literal(1),
15
+ migrationId: z.string().uuid(),
16
+ pid: z.number().int().positive(),
17
+ at: z.string().datetime({ offset: true }),
18
+ }).strict();
19
+ const TOKEN = /(?:\bsk_(?:machine|user|agent)_[A-Za-z0-9_-]{4,}\b|\bbearer\s+[^\s]+|(?:password|secret|token)\s*[:=]\s*[^\s]+)/giu;
20
+ function identityFromHandoffPath(handoffPath) {
21
+ if (!isAbsolute(handoffPath) || basename(handoffPath) !== "handoff.json") {
22
+ throw new Error("storage worker handoff path is invalid");
23
+ }
24
+ const migrationDirectory = dirname(handoffPath);
25
+ const migrationsDirectory = dirname(migrationDirectory);
26
+ if (basename(migrationsDirectory) !== "migrations")
27
+ throw new Error("storage worker handoff path is invalid");
28
+ return {
29
+ stateRoot: dirname(migrationsDirectory),
30
+ migrationId: z.string().uuid().parse(basename(migrationDirectory)).toLowerCase(),
31
+ };
32
+ }
33
+ export function storageWorkerFailurePath(stateRoot, migrationId) {
34
+ if (!isAbsolute(stateRoot))
35
+ throw new Error("storage worker state root must be absolute");
36
+ return join(stateRoot, "migrations", z.string().uuid().parse(migrationId), "worker-status.json");
37
+ }
38
+ export function storageWorkerHeartbeatPath(stateRoot, migrationId) {
39
+ if (!isAbsolute(stateRoot))
40
+ throw new Error("storage worker state root must be absolute");
41
+ return join(stateRoot, "migrations", z.string().uuid().parse(migrationId), "worker-heartbeat.json");
42
+ }
43
+ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
44
+ const identity = identityFromHandoffPath(handoffPath);
45
+ const intervalMs = z.number().int().min(100).max(60_000).parse(input.intervalMs ?? 5_000);
46
+ const now = input.now ?? (() => new Date());
47
+ const path = storageWorkerHeartbeatPath(identity.stateRoot, identity.migrationId);
48
+ let pending = Promise.resolve();
49
+ const write = () => {
50
+ pending = pending.catch(() => undefined).then(() => atomicPrivateWrite(path, `${JSON.stringify(WorkerHeartbeatSchema.parse({
51
+ version: 1,
52
+ migrationId: identity.migrationId,
53
+ pid: process.pid,
54
+ at: now().toISOString(),
55
+ }), null, 2)}\n`));
56
+ return pending;
57
+ };
58
+ await write();
59
+ const timer = setInterval(() => { void write(); }, intervalMs);
60
+ timer.unref?.();
61
+ return async () => {
62
+ clearInterval(timer);
63
+ await pending.catch(() => undefined);
64
+ await unlink(path).catch((error) => {
65
+ if (error.code !== "ENOENT")
66
+ throw error;
67
+ });
68
+ };
69
+ }
70
+ export async function writeStorageWorkerFailureStatus(handoffPath, error) {
71
+ const identity = identityFromHandoffPath(handoffPath);
72
+ const rawCode = error instanceof Error && "code" in error ? String(error.code) : "worker_failed";
73
+ const errorCode = /^[a-z0-9._-]{1,128}$/u.test(rawCode) ? rawCode : "worker_failed";
74
+ const rawMessage = error instanceof Error ? error.message : String(error);
75
+ const errorMessage = rawMessage.replaceAll(TOKEN, "<redacted>").slice(0, 512) || "storage worker failed";
76
+ const status = WorkerFailureSchema.parse({
77
+ version: 1,
78
+ migrationId: identity.migrationId,
79
+ status: "failed",
80
+ errorCode,
81
+ errorMessage,
82
+ at: new Date().toISOString(),
83
+ });
84
+ await atomicPrivateWrite(storageWorkerFailurePath(identity.stateRoot, identity.migrationId), `${JSON.stringify(status, null, 2)}\n`);
85
+ }
86
+ export async function readStorageWorkerFailureStatus(stateRoot, migrationId) {
87
+ try {
88
+ return WorkerFailureSchema.parse(JSON.parse(await readFile(storageWorkerFailurePath(stateRoot, migrationId), "utf8")));
89
+ }
90
+ catch (error) {
91
+ if (error.code === "ENOENT")
92
+ return null;
93
+ throw new Error("Invalid storage worker failure status", { cause: error });
94
+ }
95
+ }
96
+ export async function readStorageWorkerHeartbeat(stateRoot, migrationId) {
97
+ try {
98
+ return WorkerHeartbeatSchema.parse(JSON.parse(await readFile(storageWorkerHeartbeatPath(stateRoot, migrationId), "utf8")));
99
+ }
100
+ catch (error) {
101
+ if (error.code === "ENOENT")
102
+ return null;
103
+ throw new Error("Invalid storage worker heartbeat", { cause: error });
104
+ }
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.50",
3
+ "version": "0.6.52",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",