@nowcrew/daemon 0.5.36 → 0.5.37

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,285 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { chmod, mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, resolve } from "node:path";
6
+ import { z } from "zod";
7
+ import { acquireProfileSaveLock } from "./computer-profile-lock.js";
8
+ const AbsolutePathSchema = z.string().min(1).refine(isAbsolute, "must be an absolute path");
9
+ const ManagedServiceRecordSchema = z.object({
10
+ version: z.literal(1),
11
+ generation: z.string().uuid(),
12
+ platform: z.enum(["darwin", "linux"]),
13
+ serviceId: z.string().min(1).max(256),
14
+ profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/),
15
+ daemonHome: AbsolutePathSchema,
16
+ agentsRoot: AbsolutePathSchema,
17
+ nodePath: AbsolutePathSchema,
18
+ entryPath: AbsolutePathSchema,
19
+ packageRoot: AbsolutePathSchema,
20
+ npmPrefix: AbsolutePathSchema,
21
+ descriptorPath: AbsolutePathSchema,
22
+ descriptorSha256: z.string().regex(/^[0-9a-f]{64}$/),
23
+ createdAt: z.string().datetime({ offset: true }),
24
+ }).strict();
25
+ const ManagedServiceRegistrySchema = z.object({
26
+ version: z.literal(1),
27
+ services: z.array(ManagedServiceRecordSchema).max(256),
28
+ }).strict();
29
+ export class ManagedServiceRegistryError extends Error {
30
+ constructor(message, options) {
31
+ super(message, options);
32
+ this.name = "ManagedServiceRegistryError";
33
+ }
34
+ }
35
+ export class ManagedServiceConflictError extends ManagedServiceRegistryError {
36
+ resource;
37
+ owner;
38
+ constructor(resource, candidate, owner) {
39
+ super(`Managed service '${candidate.serviceId}' conflicts with '${owner.serviceId}' on ${resource}`);
40
+ this.name = "ManagedServiceConflictError";
41
+ this.resource = resource;
42
+ this.owner = owner;
43
+ }
44
+ }
45
+ function registryDirectory(userHome) {
46
+ return resolve(userHome, ".crew", "daemon", "managed-services");
47
+ }
48
+ export function managedServiceRegistryPath(userHome = homedir()) {
49
+ return resolve(registryDirectory(userHome), "registry.json");
50
+ }
51
+ export async function listManagedServiceDescriptorPaths(platform, userHome = homedir()) {
52
+ const directory = platform === "darwin"
53
+ ? resolve(userHome, "Library", "LaunchAgents")
54
+ : platform === "linux"
55
+ ? resolve(userHome, ".config", "systemd", "user")
56
+ : null;
57
+ if (directory === null)
58
+ return [];
59
+ const pattern = platform === "darwin"
60
+ ? /^com\.nowcrew\.daemon\.[a-z0-9][a-z0-9_-]{0,47}(?:\.[0-9a-f]{12})?\.plist$/
61
+ : /^nowcrew-daemon-[a-z0-9][a-z0-9_-]{0,47}(?:\.[0-9a-f]{12})?\.service$/;
62
+ try {
63
+ return (await readdir(directory, { withFileTypes: true }))
64
+ .filter((entry) => entry.isFile() && pattern.test(entry.name))
65
+ .map((entry) => resolve(directory, entry.name))
66
+ .sort();
67
+ }
68
+ catch (error) {
69
+ if (error.code === "ENOENT")
70
+ return [];
71
+ throw error;
72
+ }
73
+ }
74
+ export function assertRegistryCoversDescriptors(services, descriptorPaths) {
75
+ const registered = new Set(services.map((record) => canonicalizePath(record.descriptorPath)));
76
+ const unregistered = descriptorPaths.map(canonicalizePath).find((path) => !registered.has(path));
77
+ if (unregistered !== undefined) {
78
+ throw new ManagedServiceRegistryError(`Unregistered NowCrew service descriptor: ${unregistered}`);
79
+ }
80
+ }
81
+ export function normalizeManagedServiceRecord(input) {
82
+ const record = ManagedServiceRecordSchema.parse(input);
83
+ return ManagedServiceRecordSchema.parse({
84
+ ...record,
85
+ daemonHome: canonicalizePath(record.daemonHome),
86
+ agentsRoot: canonicalizePath(record.agentsRoot),
87
+ nodePath: canonicalizePath(record.nodePath),
88
+ entryPath: canonicalizePath(record.entryPath),
89
+ packageRoot: canonicalizePath(record.packageRoot),
90
+ npmPrefix: canonicalizePath(record.npmPrefix),
91
+ descriptorPath: canonicalizePath(record.descriptorPath),
92
+ });
93
+ }
94
+ function canonicalizePath(path) {
95
+ const absolute = resolve(path);
96
+ try {
97
+ return realpathSync.native(absolute);
98
+ }
99
+ catch (error) {
100
+ const code = error.code;
101
+ if (code === "ENOTDIR")
102
+ return absolute;
103
+ if (code !== "ENOENT")
104
+ throw error;
105
+ }
106
+ const missing = [];
107
+ let ancestor = absolute;
108
+ for (;;) {
109
+ const parent = dirname(ancestor);
110
+ if (parent === ancestor)
111
+ return absolute;
112
+ missing.unshift(ancestor.slice(parent.length + (parent.endsWith("/") ? 0 : 1)));
113
+ ancestor = parent;
114
+ try {
115
+ return resolve(realpathSync.native(ancestor), ...missing);
116
+ }
117
+ catch (error) {
118
+ const code = error.code;
119
+ if (code === "ENOTDIR")
120
+ return absolute;
121
+ if (code !== "ENOENT")
122
+ throw error;
123
+ }
124
+ }
125
+ }
126
+ function parseRegistry(path, raw) {
127
+ try {
128
+ const parsed = ManagedServiceRegistrySchema.parse(JSON.parse(raw));
129
+ const services = parsed.services.map(normalizeManagedServiceRecord);
130
+ for (let index = 0; index < services.length; index += 1) {
131
+ const candidate = services[index];
132
+ const conflict = managedServiceConflict(services.slice(0, index), candidate);
133
+ if (conflict !== null) {
134
+ throw new ManagedServiceConflictError(conflict.resource, candidate, conflict.owner);
135
+ }
136
+ }
137
+ return services;
138
+ }
139
+ catch (error) {
140
+ throw new ManagedServiceRegistryError(`Invalid managed service registry: ${path}`, { cause: error });
141
+ }
142
+ }
143
+ export function serviceDescriptorSha256(descriptor) {
144
+ return createHash("sha256").update(descriptor).digest("hex");
145
+ }
146
+ export function managedServiceIdentityMatches(record, expected) {
147
+ const normalized = normalizeManagedServiceRecord(record);
148
+ return normalized.version === expected.version
149
+ && normalized.platform === expected.platform
150
+ && normalized.serviceId === expected.serviceId
151
+ && normalized.profile === expected.profile
152
+ && normalized.daemonHome === canonicalizePath(expected.daemonHome)
153
+ && normalized.agentsRoot === canonicalizePath(expected.agentsRoot)
154
+ && normalized.nodePath === canonicalizePath(expected.nodePath)
155
+ && normalized.entryPath === canonicalizePath(expected.entryPath)
156
+ && normalized.packageRoot === canonicalizePath(expected.packageRoot)
157
+ && normalized.npmPrefix === canonicalizePath(expected.npmPrefix)
158
+ && normalized.descriptorPath === canonicalizePath(expected.descriptorPath)
159
+ && normalized.descriptorSha256 === expected.descriptorSha256;
160
+ }
161
+ async function readRegistryFile(path) {
162
+ try {
163
+ return parseRegistry(path, await readFile(path, "utf8"));
164
+ }
165
+ catch (error) {
166
+ if (error.code === "ENOENT")
167
+ return [];
168
+ throw error;
169
+ }
170
+ }
171
+ async function syncDirectory(path, platform) {
172
+ if (platform === "win32")
173
+ return;
174
+ const handle = await open(path, "r");
175
+ try {
176
+ await handle.sync();
177
+ }
178
+ finally {
179
+ await handle.close();
180
+ }
181
+ }
182
+ async function writeRegistryFile(path, services) {
183
+ const directory = dirname(path);
184
+ await mkdir(directory, { recursive: true, mode: 0o700 });
185
+ await chmod(directory, 0o700);
186
+ const temporary = `${path}.${randomUUID()}.tmp`;
187
+ let handle = null;
188
+ try {
189
+ handle = await open(temporary, "wx", 0o600);
190
+ await handle.writeFile(`${JSON.stringify({ version: 1, services }, null, 2)}\n`, "utf8");
191
+ await handle.sync();
192
+ await handle.close();
193
+ handle = null;
194
+ await rename(temporary, path);
195
+ await chmod(path, 0o600);
196
+ await syncDirectory(directory, process.platform);
197
+ }
198
+ finally {
199
+ await handle?.close().catch(() => undefined);
200
+ await rm(temporary, { force: true }).catch(() => undefined);
201
+ }
202
+ }
203
+ function sameRecord(left, right) {
204
+ return JSON.stringify(left) === JSON.stringify(right);
205
+ }
206
+ export function managedServiceConflict(services, candidate) {
207
+ for (const owner of services) {
208
+ if (owner.serviceId === candidate.serviceId)
209
+ return { resource: "serviceId", owner };
210
+ if (owner.daemonHome === candidate.daemonHome && owner.profile === candidate.profile) {
211
+ return { resource: "daemonProfile", owner };
212
+ }
213
+ if (owner.agentsRoot === candidate.agentsRoot)
214
+ return { resource: "agentsRoot", owner };
215
+ if (owner.npmPrefix === candidate.npmPrefix)
216
+ return { resource: "npmPrefix", owner };
217
+ }
218
+ return null;
219
+ }
220
+ export async function withManagedServiceRegistryLock(options, operation) {
221
+ const userHome = options.userHome ?? homedir();
222
+ const directory = registryDirectory(userHome);
223
+ const release = await acquireProfileSaveLock(directory, {
224
+ ...(options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs }),
225
+ ...(options.lockRetryMs === undefined ? {} : { retryMs: options.lockRetryMs }),
226
+ });
227
+ let operationError;
228
+ try {
229
+ const path = managedServiceRegistryPath(userHome);
230
+ const services = await readRegistryFile(path);
231
+ return await operation({
232
+ services,
233
+ replace: async (replacement) => {
234
+ const normalized = replacement.map(normalizeManagedServiceRecord);
235
+ for (let index = 0; index < normalized.length; index += 1) {
236
+ const candidate = normalized[index];
237
+ const conflict = managedServiceConflict(normalized.slice(0, index), candidate);
238
+ if (conflict !== null) {
239
+ throw new ManagedServiceConflictError(conflict.resource, candidate, conflict.owner);
240
+ }
241
+ }
242
+ await writeRegistryFile(path, normalized);
243
+ },
244
+ });
245
+ }
246
+ catch (error) {
247
+ operationError = error;
248
+ throw error;
249
+ }
250
+ finally {
251
+ try {
252
+ await release();
253
+ }
254
+ catch (releaseError) {
255
+ if (operationError === undefined)
256
+ throw releaseError;
257
+ }
258
+ }
259
+ }
260
+ export async function readManagedServiceRegistry(options = {}) {
261
+ return readRegistryFile(managedServiceRegistryPath(options.userHome ?? homedir()));
262
+ }
263
+ export async function registerManagedService(input, options = {}) {
264
+ const candidate = normalizeManagedServiceRecord(input);
265
+ await withManagedServiceRegistryLock(options, async ({ services, replace }) => {
266
+ const conflict = managedServiceConflict(services, candidate);
267
+ if (conflict !== null)
268
+ throw new ManagedServiceConflictError(conflict.resource, candidate, conflict.owner);
269
+ await replace([...services, candidate].sort((left, right) => left.serviceId.localeCompare(right.serviceId)));
270
+ });
271
+ }
272
+ export async function unregisterManagedService(input, options = {}) {
273
+ const candidate = normalizeManagedServiceRecord(input);
274
+ await withManagedServiceRegistryLock(options, async ({ services, replace }) => {
275
+ const index = services.findIndex((record) => record.serviceId === candidate.serviceId
276
+ && record.daemonHome === candidate.daemonHome
277
+ && record.profile === candidate.profile);
278
+ if (index < 0)
279
+ throw new ManagedServiceRegistryError(`Managed service '${candidate.serviceId}' is not registered`);
280
+ if (!sameRecord(services[index], candidate)) {
281
+ throw new ManagedServiceRegistryError(`Managed service '${candidate.serviceId}' identity changed before removal`);
282
+ }
283
+ await replace(services.filter((_, candidateIndex) => candidateIndex !== index));
284
+ });
285
+ }
@@ -0,0 +1,86 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+ import { buildServiceSpec, readServiceDescriptor } from "./computer-service.js";
4
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
5
+ import { ManagedServiceRegistryError, assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, readManagedServiceRegistry, serviceDescriptorSha256, } from "./managed-service-registry.js";
6
+ export async function assertManagedServiceStartup(input) {
7
+ if (input.platform !== "darwin" && input.platform !== "linux")
8
+ return;
9
+ const spec = input.spec ?? buildServiceSpec({
10
+ platform: input.platform,
11
+ profile: input.profile,
12
+ userHome: input.userHome,
13
+ uid: input.uid,
14
+ nodePath: input.nodePath,
15
+ entryPath: input.entryPath,
16
+ profileHome: input.daemonHome,
17
+ });
18
+ const readRegistry = input.readManagedServices
19
+ ?? (() => readManagedServiceRegistry({ userHome: input.userHome }));
20
+ const listDescriptors = input.listManagedDescriptorPaths
21
+ ?? (() => listManagedServiceDescriptorPaths(input.platform, input.userHome));
22
+ const readDescriptor = input.readServiceDescriptor ?? readServiceDescriptor;
23
+ const [services, descriptorPaths, descriptor] = await Promise.all([
24
+ readRegistry(),
25
+ listDescriptors(),
26
+ readDescriptor(spec),
27
+ ]);
28
+ const registered = services.find((record) => record.serviceId === spec.id
29
+ || (record.daemonHome === input.daemonHome && record.profile === input.profile));
30
+ const defaultDaemonHome = resolve(input.userHome, ".crew", "daemon");
31
+ if (registered === undefined
32
+ && resolve(input.daemonHome) === defaultDaemonHome
33
+ && descriptor === spec.descriptor) {
34
+ return;
35
+ }
36
+ if (registered === undefined && descriptor === null) {
37
+ const legacySpec = buildServiceSpec({
38
+ platform: input.platform,
39
+ profile: input.profile,
40
+ userHome: input.userHome,
41
+ uid: input.uid,
42
+ nodePath: input.nodePath,
43
+ entryPath: input.entryPath,
44
+ profileHome: input.daemonHome,
45
+ legacyServiceId: true,
46
+ });
47
+ const legacyDescriptor = await readDescriptor(legacySpec);
48
+ if (legacyDescriptor === legacySpec.descriptor)
49
+ return;
50
+ if (descriptorPaths.length === 0)
51
+ return;
52
+ }
53
+ if (registered === undefined) {
54
+ throw new ManagedServiceRegistryError(`Managed service '${spec.id}' is installed but not registered`);
55
+ }
56
+ assertRegistryCoversDescriptors(services, descriptorPaths);
57
+ const installation = daemonGlobalInstallation(input.entryPath, input.platform);
58
+ if (installation === null || descriptor === null || spec.descriptorPath === null || spec.descriptor === null
59
+ || descriptor !== spec.descriptor
60
+ || !managedServiceIdentityMatches(registered, {
61
+ version: 1,
62
+ platform: input.platform,
63
+ serviceId: spec.id,
64
+ profile: input.profile,
65
+ daemonHome: input.daemonHome,
66
+ agentsRoot: input.agentsRoot,
67
+ nodePath: input.nodePath,
68
+ entryPath: input.entryPath,
69
+ packageRoot: installation.packageRoot,
70
+ npmPrefix: installation.npmPrefix,
71
+ descriptorPath: spec.descriptorPath,
72
+ descriptorSha256: serviceDescriptorSha256(descriptor),
73
+ })) {
74
+ throw new ManagedServiceRegistryError(`Managed service '${spec.id}' identity does not match its runtime`);
75
+ }
76
+ }
77
+ export function defaultManagedServiceStartupInput(input) {
78
+ return {
79
+ ...input,
80
+ platform: process.platform,
81
+ userHome: homedir(),
82
+ uid: process.getuid?.(),
83
+ nodePath: process.execPath,
84
+ entryPath: process.argv[1] ?? "",
85
+ };
86
+ }
package/dist/serve.js CHANGED
@@ -1120,6 +1120,7 @@ export function serve(config, opts = {}) {
1120
1120
  const webSocketClosed = closeWebSocketWithinDeadline(ws, deadline.signal);
1121
1121
  const pending = [
1122
1122
  webSocketClosed,
1123
+ updateController.drain(),
1123
1124
  executionFrameQueue,
1124
1125
  ...executionRuns.values(),
1125
1126
  ...[...legacyRuns.values()].map((run) => run.done),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.5.36",
3
+ "version": "0.5.37",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -16,12 +16,19 @@
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
+ "prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit"
25
+ },
19
26
  "dependencies": {
20
27
  "@agentclientprotocol/sdk": "1.2.1",
28
+ "@nowcrew/cli": "workspace:^",
21
29
  "cross-spawn": "^7.0.6",
22
30
  "ws": "^8",
23
- "zod": "^3.23.0",
24
- "@nowcrew/cli": "^0.4.13"
31
+ "zod": "^3.23.0"
25
32
  },
26
33
  "optionalDependencies": {
27
34
  "koffi": "^2.9.0"
@@ -33,11 +40,5 @@
33
40
  "tsx": "^4.19.0",
34
41
  "typescript": "^5.6.0",
35
42
  "vitest": "^2.1.0"
36
- },
37
- "scripts": {
38
- "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
39
- "build": "tsc -p tsconfig.json",
40
- "test": "vitest run",
41
- "typecheck": "tsc --noEmit"
42
43
  }
43
- }
44
+ }