@nowcrew/daemon 0.5.18 → 0.5.20
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 +23 -0
- package/dist/attachments.js +196 -0
- package/dist/computer-cli.js +72 -12
- package/dist/computer-profile-lock.js +395 -0
- package/dist/computer-profile.js +189 -20
- package/dist/config.js +2 -1
- package/dist/console.js +175 -9
- package/dist/execution-event-limit.js +1 -1
- package/dist/execution-journal-lock.js +199 -40
- package/dist/execution-journal.js +42 -4
- package/dist/execution-protocol.js +21 -1
- package/dist/execution-recovery.js +71 -0
- package/dist/execution-runner.js +68 -77
- package/dist/execution-supervisor.js +79 -31
- package/dist/external-output.js +114 -0
- package/dist/i18n.js +5 -5
- package/dist/list-models.js +41 -5
- package/dist/local-executor.js +103 -14
- package/dist/machine-info.js +6 -1
- package/dist/main.js +23 -8
- package/dist/origin-decision.js +3 -1
- package/dist/prompt.js +4 -1
- package/dist/runner.js +14 -9
- package/dist/runtime-cancellation.js +74 -0
- package/dist/runtime-capabilities.js +38 -0
- package/dist/runtime-path.js +60 -0
- package/dist/runtimes/claude.js +9 -4
- package/dist/runtimes/codex-app-server-runner.js +340 -0
- package/dist/runtimes/codex.js +10 -4
- package/dist/runtimes/kimi-acp-runner.js +117 -17
- package/dist/runtimes/kimi.js +2 -0
- package/dist/runtimes/progress-watchdog.js +26 -0
- package/dist/serve-lifecycle.js +82 -0
- package/dist/serve.js +212 -212
- package/dist/session.js +1 -1
- package/dist/shared-execution-slots.js +68 -0
- package/dist/shutdown-deadline.js +32 -0
- package/dist/slog.js +34 -20
- package/dist/supervised-runtime.js +104 -0
- package/dist/websocket-shutdown.js +53 -0
- package/package.json +3 -3
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { mkdir, open, opendir, readFile, readdir, rename, rm, rmdir, stat, unlink, } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { defaultProcessController } from "./execution-journal.js";
|
|
6
|
+
const OwnerSchema = z.object({
|
|
7
|
+
pid: z.number().int().positive(),
|
|
8
|
+
processIdentity: z.string().min(1),
|
|
9
|
+
token: z.string().uuid(),
|
|
10
|
+
createdAt: z.number().int().nonnegative(),
|
|
11
|
+
}).strict();
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const DEFAULT_RETRY_MS = 25;
|
|
14
|
+
const DEFAULT_TEMP_STALE_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
15
|
+
const DEFAULT_TEMP_SCAN_LIMIT = 64;
|
|
16
|
+
const DEFAULT_TEMP_DELETE_LIMIT = 16;
|
|
17
|
+
const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
18
|
+
const OWNER_NAME_PATTERN = new RegExp(`^owner\\.${UUID_PATTERN}\\.json$`, "i");
|
|
19
|
+
const TEMP_NAME_PATTERN = new RegExp(`^\\.save\\.lock\\.(${UUID_PATTERN})\\.tmp$`, "i");
|
|
20
|
+
const codeOf = (error) => error instanceof Error && "code" in error ? error.code : undefined;
|
|
21
|
+
const ownerName = (token) => `owner.${token}.json`;
|
|
22
|
+
export const defaultProfileSaveLockFileSystem = {
|
|
23
|
+
mkdir,
|
|
24
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
25
|
+
readDirectoryEntries: async (path, limit) => {
|
|
26
|
+
const directory = await opendir(path);
|
|
27
|
+
const names = [];
|
|
28
|
+
try {
|
|
29
|
+
while (names.length < limit) {
|
|
30
|
+
const entry = await directory.read();
|
|
31
|
+
if (entry === null)
|
|
32
|
+
break;
|
|
33
|
+
names.push(entry.name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await directory.close();
|
|
38
|
+
}
|
|
39
|
+
return names;
|
|
40
|
+
},
|
|
41
|
+
readdir: async (path) => readdir(path),
|
|
42
|
+
rename,
|
|
43
|
+
rm,
|
|
44
|
+
rmdir,
|
|
45
|
+
stat,
|
|
46
|
+
unlink,
|
|
47
|
+
writeDurableFile: async (path, contents) => {
|
|
48
|
+
const handle = await open(path, "wx", 0o600);
|
|
49
|
+
try {
|
|
50
|
+
await handle.writeFile(contents, "utf8");
|
|
51
|
+
await handle.sync();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
async function readGeneration(lockDirectory, fileSystem) {
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = (await fileSystem.readdir(lockDirectory)).sort();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (codeOf(error) === "ENOENT")
|
|
65
|
+
return { status: "absent" };
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
if (names.length === 0)
|
|
69
|
+
return { status: "absent" };
|
|
70
|
+
if (names.length !== 1 || names[0] !== "generation") {
|
|
71
|
+
throw new Error(`Invalid profile save lock: ${lockDirectory}`);
|
|
72
|
+
}
|
|
73
|
+
const generationDirectory = join(lockDirectory, "generation");
|
|
74
|
+
try {
|
|
75
|
+
names = (await fileSystem.readdir(generationDirectory)).sort();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (codeOf(error) === "ENOENT")
|
|
79
|
+
return { status: "absent" };
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (names.length === 0)
|
|
83
|
+
return { status: "empty" };
|
|
84
|
+
if (names.length !== 1 || !OWNER_NAME_PATTERN.test(names[0])) {
|
|
85
|
+
throw new Error(`Invalid profile save lock generation: ${generationDirectory}`);
|
|
86
|
+
}
|
|
87
|
+
const fileName = names[0];
|
|
88
|
+
const ownerPath = join(generationDirectory, fileName);
|
|
89
|
+
try {
|
|
90
|
+
const owner = OwnerSchema.parse(JSON.parse(await fileSystem.readFile(ownerPath)));
|
|
91
|
+
if (fileName !== ownerName(owner.token))
|
|
92
|
+
throw new Error("owner token does not match filename");
|
|
93
|
+
return { status: "owned", owner, fileName };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (codeOf(error) === "ENOENT")
|
|
97
|
+
return { status: "absent" };
|
|
98
|
+
throw new Error(`Invalid profile save lock owner: ${ownerPath}`, { cause: error });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function sameOwner(left, right) {
|
|
102
|
+
return left.pid === right.pid
|
|
103
|
+
&& left.processIdentity === right.processIdentity
|
|
104
|
+
&& left.token === right.token
|
|
105
|
+
&& left.createdAt === right.createdAt;
|
|
106
|
+
}
|
|
107
|
+
async function requireOwnGeneration(lockDirectory, expected, fileSystem) {
|
|
108
|
+
const observed = await readGeneration(lockDirectory, fileSystem);
|
|
109
|
+
if (observed.status !== "owned" || !sameOwner(observed.owner, expected)) {
|
|
110
|
+
throw new Error(`Profile save lock generation changed during install: ${lockDirectory}`);
|
|
111
|
+
}
|
|
112
|
+
return { fileName: observed.fileName };
|
|
113
|
+
}
|
|
114
|
+
async function removeOwnGeneration(lockDirectory, expected, fileSystem) {
|
|
115
|
+
const observed = await readGeneration(lockDirectory, fileSystem);
|
|
116
|
+
if (observed.status !== "owned" || !sameOwner(observed.owner, expected))
|
|
117
|
+
return false;
|
|
118
|
+
const generationDirectory = join(lockDirectory, "generation");
|
|
119
|
+
try {
|
|
120
|
+
await fileSystem.unlink(join(generationDirectory, observed.fileName));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (codeOf(error) === "ENOENT")
|
|
124
|
+
return false;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await fileSystem.rmdir(generationDirectory);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await fileSystem.rmdir(lockDirectory);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function positiveInteger(value, name) {
|
|
144
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
145
|
+
throw new RangeError(`${name} must be a positive integer`);
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
async function inspectTemporaryGeneration(temporaryDirectory, token, fileSystem) {
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = (await fileSystem.readdir(temporaryDirectory)).sort();
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return codeOf(error) === "ENOENT" ? null : { status: "unavailable" };
|
|
155
|
+
}
|
|
156
|
+
if (entries.length !== 1 || entries[0] !== ownerName(token)) {
|
|
157
|
+
return { status: "missing-or-corrupt" };
|
|
158
|
+
}
|
|
159
|
+
let contents;
|
|
160
|
+
try {
|
|
161
|
+
contents = await fileSystem.readFile(join(temporaryDirectory, entries[0]));
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return codeOf(error) === "ENOENT" ? null : { status: "unavailable" };
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const owner = OwnerSchema.parse(JSON.parse(contents));
|
|
168
|
+
return owner.token.toLowerCase() === token
|
|
169
|
+
? { status: "valid", owner }
|
|
170
|
+
: { status: "missing-or-corrupt" };
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { status: "missing-or-corrupt" };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function cleanupStaleTemporaryGenerations(options) {
|
|
177
|
+
const names = await options.fileSystem.readDirectoryEntries(options.profilesDirectory, options.scanLimit);
|
|
178
|
+
let deleted = 0;
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
if (deleted >= options.deleteLimit)
|
|
181
|
+
break;
|
|
182
|
+
const match = TEMP_NAME_PATTERN.exec(name);
|
|
183
|
+
const token = match?.[1]?.toLowerCase();
|
|
184
|
+
if (token === undefined)
|
|
185
|
+
continue;
|
|
186
|
+
const temporaryDirectory = join(options.profilesDirectory, name);
|
|
187
|
+
let directoryMtimeMs;
|
|
188
|
+
try {
|
|
189
|
+
directoryMtimeMs = (await options.fileSystem.stat(temporaryDirectory)).mtimeMs;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (codeOf(error) === "ENOENT")
|
|
193
|
+
continue;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const inspection = await inspectTemporaryGeneration(temporaryDirectory, token, options.fileSystem);
|
|
197
|
+
if (inspection === null || inspection.status === "unavailable")
|
|
198
|
+
continue;
|
|
199
|
+
let removable = false;
|
|
200
|
+
if (inspection.status === "valid") {
|
|
201
|
+
try {
|
|
202
|
+
removable = await options.processController.inspectIdentity(inspection.owner.pid)
|
|
203
|
+
!== inspection.owner.processIdentity;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
removable = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
removable = options.now - directoryMtimeMs >= options.staleAgeMs;
|
|
211
|
+
}
|
|
212
|
+
if (!removable)
|
|
213
|
+
continue;
|
|
214
|
+
const confirmed = await inspectTemporaryGeneration(temporaryDirectory, token, options.fileSystem);
|
|
215
|
+
if (confirmed === null || confirmed.status !== inspection.status)
|
|
216
|
+
continue;
|
|
217
|
+
if (inspection.status === "valid") {
|
|
218
|
+
if (confirmed.status !== "valid" || !sameOwner(confirmed.owner, inspection.owner))
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
try {
|
|
223
|
+
if ((await options.fileSystem.stat(temporaryDirectory)).mtimeMs !== directoryMtimeMs)
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
await options.fileSystem.rm(temporaryDirectory, { recursive: true, force: true });
|
|
232
|
+
deleted += 1;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Stale-temp cleanup is bounded best effort and must not block a profile save.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function acquireProfileSaveLock(profilesDirectory, options = {}) {
|
|
240
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
241
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
242
|
+
const now = options.now ?? Date.now;
|
|
243
|
+
const wait = options.wait ?? ((milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)));
|
|
244
|
+
const processController = options.processController ?? defaultProcessController;
|
|
245
|
+
const fileSystem = options.fileSystem ?? defaultProfileSaveLockFileSystem;
|
|
246
|
+
const tempStaleAgeMs = options.tempStaleAgeMs ?? DEFAULT_TEMP_STALE_AGE_MS;
|
|
247
|
+
const tempScanLimit = positiveInteger(options.tempScanLimit ?? DEFAULT_TEMP_SCAN_LIMIT, "tempScanLimit");
|
|
248
|
+
const tempDeleteLimit = positiveInteger(options.tempDeleteLimit ?? DEFAULT_TEMP_DELETE_LIMIT, "tempDeleteLimit");
|
|
249
|
+
if (!Number.isFinite(tempStaleAgeMs) || tempStaleAgeMs < 0) {
|
|
250
|
+
throw new RangeError("tempStaleAgeMs must be a nonnegative finite number");
|
|
251
|
+
}
|
|
252
|
+
const deadline = now() + timeoutMs;
|
|
253
|
+
const lockDirectory = join(profilesDirectory, ".save.lock");
|
|
254
|
+
const generationDirectory = join(lockDirectory, "generation");
|
|
255
|
+
const token = randomUUID();
|
|
256
|
+
const fileName = ownerName(token);
|
|
257
|
+
const temporaryDirectory = join(profilesDirectory, `.save.lock.${token}.tmp`);
|
|
258
|
+
await fileSystem.mkdir(profilesDirectory, { recursive: true, mode: 0o700 });
|
|
259
|
+
await cleanupStaleTemporaryGenerations({
|
|
260
|
+
profilesDirectory,
|
|
261
|
+
now: now(),
|
|
262
|
+
staleAgeMs: tempStaleAgeMs,
|
|
263
|
+
scanLimit: tempScanLimit,
|
|
264
|
+
deleteLimit: tempDeleteLimit,
|
|
265
|
+
processController,
|
|
266
|
+
fileSystem,
|
|
267
|
+
});
|
|
268
|
+
const processIdentity = await processController.inspectIdentity(process.pid);
|
|
269
|
+
if (processIdentity === null || processIdentity.trim().length === 0) {
|
|
270
|
+
throw new Error("Failed to capture profile save lock owner process identity");
|
|
271
|
+
}
|
|
272
|
+
const owner = OwnerSchema.parse({ pid: process.pid, processIdentity, token, createdAt: now() });
|
|
273
|
+
let temporaryDirectoryCreated = false;
|
|
274
|
+
let installed = false;
|
|
275
|
+
let acquired = false;
|
|
276
|
+
const pause = async () => {
|
|
277
|
+
const remaining = deadline - now();
|
|
278
|
+
if (remaining <= 0)
|
|
279
|
+
throw new Error(`Timed out waiting for profile save lock: ${lockDirectory}`);
|
|
280
|
+
await wait(Math.min(retryMs, remaining));
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
await fileSystem.mkdir(temporaryDirectory, { mode: 0o700 });
|
|
284
|
+
temporaryDirectoryCreated = true;
|
|
285
|
+
await fileSystem.writeDurableFile(join(temporaryDirectory, fileName), `${JSON.stringify(owner)}\n`);
|
|
286
|
+
await options.hooks?.beforeInstall?.(owner);
|
|
287
|
+
for (;;) {
|
|
288
|
+
try {
|
|
289
|
+
await fileSystem.mkdir(lockDirectory, { mode: 0o700 });
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (codeOf(error) !== "EEXIST")
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
let renameFailure;
|
|
296
|
+
try {
|
|
297
|
+
await fileSystem.rename(temporaryDirectory, generationDirectory);
|
|
298
|
+
installed = true;
|
|
299
|
+
await requireOwnGeneration(lockDirectory, owner, fileSystem);
|
|
300
|
+
acquired = true;
|
|
301
|
+
let released = false;
|
|
302
|
+
return async () => {
|
|
303
|
+
if (released)
|
|
304
|
+
return;
|
|
305
|
+
if (!await removeOwnGeneration(lockDirectory, owner, fileSystem)) {
|
|
306
|
+
throw new Error(`Profile save lock generation changed before release: ${lockDirectory}`);
|
|
307
|
+
}
|
|
308
|
+
released = true;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (installed)
|
|
313
|
+
throw error;
|
|
314
|
+
if (codeOf(error) === "ENOENT") {
|
|
315
|
+
await pause();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (codeOf(error) !== "EEXIST"
|
|
319
|
+
&& codeOf(error) !== "ENOTEMPTY"
|
|
320
|
+
&& codeOf(error) !== "EPERM"
|
|
321
|
+
&& codeOf(error) !== "EACCES")
|
|
322
|
+
throw error;
|
|
323
|
+
renameFailure = error;
|
|
324
|
+
}
|
|
325
|
+
const permissionFailure = codeOf(renameFailure) === "EPERM"
|
|
326
|
+
|| codeOf(renameFailure) === "EACCES"
|
|
327
|
+
? renameFailure
|
|
328
|
+
: null;
|
|
329
|
+
const observed = await readGeneration(lockDirectory, fileSystem);
|
|
330
|
+
if (observed.status === "absent") {
|
|
331
|
+
if (permissionFailure !== null)
|
|
332
|
+
throw permissionFailure;
|
|
333
|
+
await pause();
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (observed.status === "empty") {
|
|
337
|
+
try {
|
|
338
|
+
await fileSystem.rmdir(generationDirectory);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (permissionFailure !== null)
|
|
342
|
+
throw permissionFailure;
|
|
343
|
+
if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
|
|
344
|
+
throw error;
|
|
345
|
+
await pause();
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
await fileSystem.rmdir(lockDirectory);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
await pause();
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const identity = await processController.inspectIdentity(observed.owner.pid);
|
|
359
|
+
if (identity === observed.owner.processIdentity) {
|
|
360
|
+
await pause();
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await fileSystem.unlink(join(generationDirectory, observed.fileName));
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (codeOf(error) === "ENOENT")
|
|
368
|
+
continue;
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
await fileSystem.rmdir(generationDirectory);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
await fileSystem.rmdir(lockDirectory);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
if (!installed && temporaryDirectoryCreated) {
|
|
389
|
+
await fileSystem.rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
|
|
390
|
+
}
|
|
391
|
+
else if (!acquired) {
|
|
392
|
+
await removeOwnGeneration(lockDirectory, owner, fileSystem).catch(() => undefined);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
package/dist/computer-profile.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { dirname, posix, resolve, win32 } from "node:path";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { spawn } from "node:child_process";
|
|
7
8
|
import { z } from "zod";
|
|
9
|
+
import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
|
|
10
|
+
import { formatDaemonText } from "./i18n.js";
|
|
8
11
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
9
12
|
const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
|
|
10
13
|
const url = new URL(value);
|
|
@@ -18,6 +21,11 @@ const ProfileSchema = z.object({
|
|
|
18
21
|
agentsRoot: z.string().min(1).optional(),
|
|
19
22
|
runtimePath: z.string().min(1).max(32768).refine((value) => !value.includes("\0") && !value.includes("\n") && !value.includes("\r"), "runtimePath must be a single line").optional(),
|
|
20
23
|
}).strict();
|
|
24
|
+
const ProfileAgentsRootCandidateSchema = ProfileSchema.pick({
|
|
25
|
+
name: true,
|
|
26
|
+
serverUrl: true,
|
|
27
|
+
agentsRoot: true,
|
|
28
|
+
}).strict();
|
|
21
29
|
const StoredPlainProfileSchema = ProfileSchema;
|
|
22
30
|
const StoredProtectedProfileSchema = ProfileSchema.omit({ machineToken: true }).extend({
|
|
23
31
|
machineTokenProtected: z.object({
|
|
@@ -26,9 +34,93 @@ const StoredProtectedProfileSchema = ProfileSchema.omit({ machineToken: true }).
|
|
|
26
34
|
}).strict(),
|
|
27
35
|
}).strict();
|
|
28
36
|
const StoredProfileSchema = z.union([StoredPlainProfileSchema, StoredProtectedProfileSchema]);
|
|
37
|
+
export const PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE = "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}";
|
|
38
|
+
export class ProfileAgentsRootConflictError extends Error {
|
|
39
|
+
profile;
|
|
40
|
+
conflict;
|
|
41
|
+
agentsRoot;
|
|
42
|
+
command;
|
|
43
|
+
constructor(input) {
|
|
44
|
+
const quote = input.platform === "win32" ? quotePowerShellArgument : quotePosixArgument;
|
|
45
|
+
const pathApi = input.platform === "win32" ? win32 : posix;
|
|
46
|
+
const suggestedRoot = resolveAgentsRoot(pathApi.join(input.userHome, ".crew", `agents-${input.profile}`), input.userHome, input.platform);
|
|
47
|
+
const command = [
|
|
48
|
+
"crew-daemon profile save",
|
|
49
|
+
quote(input.profile),
|
|
50
|
+
"--server-url",
|
|
51
|
+
quote(input.serverUrl),
|
|
52
|
+
"--agents-root",
|
|
53
|
+
quote(suggestedRoot),
|
|
54
|
+
"--token-stdin",
|
|
55
|
+
].join(" ");
|
|
56
|
+
super(formatDaemonText("en", PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, {
|
|
57
|
+
profile: input.profile,
|
|
58
|
+
conflict: input.conflict,
|
|
59
|
+
agentsRoot: input.agentsRoot,
|
|
60
|
+
command,
|
|
61
|
+
}));
|
|
62
|
+
this.name = "ProfileAgentsRootConflictError";
|
|
63
|
+
this.profile = input.profile;
|
|
64
|
+
this.conflict = input.conflict;
|
|
65
|
+
this.agentsRoot = input.agentsRoot;
|
|
66
|
+
this.command = command;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const quotePosixArgument = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
70
|
+
const quotePowerShellArgument = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
29
71
|
export function daemonHome(env = process.env) {
|
|
30
72
|
return resolve(env.CREW_DAEMON_HOME ?? resolve(homedir(), ".crew/daemon"));
|
|
31
73
|
}
|
|
74
|
+
export function resolveAgentsRoot(configured, userHome = homedir(), platform = process.platform) {
|
|
75
|
+
const pathApi = platform === "win32" ? win32 : posix;
|
|
76
|
+
const normalizedHome = pathApi.resolve(userHome);
|
|
77
|
+
if (configured === undefined)
|
|
78
|
+
return canonicalizeRoot(pathApi.resolve(normalizedHome, ".crew", "agents"), platform);
|
|
79
|
+
const expanded = configured === "~"
|
|
80
|
+
? normalizedHome
|
|
81
|
+
: configured.startsWith("~/") || (platform === "win32" && configured.startsWith("~\\"))
|
|
82
|
+
? pathApi.resolve(normalizedHome, configured.slice(2))
|
|
83
|
+
: configured;
|
|
84
|
+
return canonicalizeRoot(pathApi.resolve(expanded), platform);
|
|
85
|
+
}
|
|
86
|
+
function canonicalizeRoot(path, platform) {
|
|
87
|
+
const compatibleHost = (platform === "win32") === (process.platform === "win32");
|
|
88
|
+
if (!compatibleHost)
|
|
89
|
+
return path;
|
|
90
|
+
try {
|
|
91
|
+
return realpathSync.native(path);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const code = error.code;
|
|
95
|
+
if (code === "ENOTDIR")
|
|
96
|
+
return path;
|
|
97
|
+
if (code !== "ENOENT")
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
const pathApi = platform === "win32" ? win32 : posix;
|
|
101
|
+
const missingSegments = [];
|
|
102
|
+
let ancestor = path;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const parent = pathApi.dirname(ancestor);
|
|
105
|
+
if (parent === ancestor)
|
|
106
|
+
return path;
|
|
107
|
+
missingSegments.unshift(pathApi.basename(ancestor));
|
|
108
|
+
ancestor = parent;
|
|
109
|
+
try {
|
|
110
|
+
return pathApi.resolve(realpathSync.native(ancestor), ...missingSegments);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const code = error.code;
|
|
114
|
+
if (code === "ENOTDIR")
|
|
115
|
+
return path;
|
|
116
|
+
if (code !== "ENOENT")
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function comparableAgentsRoot(path, platform) {
|
|
122
|
+
return platform === "win32" ? win32.normalize(path).toLocaleLowerCase("en-US") : path;
|
|
123
|
+
}
|
|
32
124
|
export function validateProfileName(name) {
|
|
33
125
|
if (!PROFILE_NAME.test(name)) {
|
|
34
126
|
throw new Error("profile name must match [a-z0-9][a-z0-9_-]{0,47}");
|
|
@@ -87,29 +179,74 @@ function storageOptions(options) {
|
|
|
87
179
|
...(options.harden ? { harden: options.harden } : {}),
|
|
88
180
|
};
|
|
89
181
|
}
|
|
182
|
+
function attachCleanupError(primaryError, cleanupError) {
|
|
183
|
+
try {
|
|
184
|
+
if (!(primaryError instanceof Error)
|
|
185
|
+
|| !Object.isExtensible(primaryError)
|
|
186
|
+
|| Object.prototype.hasOwnProperty.call(primaryError, "cleanupError"))
|
|
187
|
+
return;
|
|
188
|
+
Object.defineProperty(primaryError, "cleanupError", {
|
|
189
|
+
configurable: true,
|
|
190
|
+
value: cleanupError,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Preserve the primary error even when it cannot accept diagnostics.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
90
197
|
export async function saveProfile(input, home = daemonHome(), options = {}) {
|
|
91
198
|
const profile = ProfileSchema.parse({ version: 1, ...input });
|
|
92
199
|
const configured = storageOptions(options);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
200
|
+
const release = await acquireProfileSaveLock(resolve(home, "profiles"), {
|
|
201
|
+
...(options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs }),
|
|
202
|
+
...(options.lockRetryMs === undefined ? {} : { retryMs: options.lockRetryMs }),
|
|
203
|
+
...(options.lockNow === undefined ? {} : { now: options.lockNow }),
|
|
204
|
+
...(options.lockWait === undefined ? {} : { wait: options.lockWait }),
|
|
205
|
+
...(options.lockProcessController === undefined
|
|
206
|
+
? {}
|
|
207
|
+
: { processController: options.lockProcessController }),
|
|
208
|
+
...(options.lockHooks === undefined ? {} : { hooks: options.lockHooks }),
|
|
209
|
+
});
|
|
210
|
+
let bodyFailed = false;
|
|
211
|
+
let bodyError;
|
|
212
|
+
try {
|
|
213
|
+
await assertProfileAgentsRootUnique(profile, home, options.userHome ?? homedir(), options);
|
|
214
|
+
if (configured.platform === "win32") {
|
|
215
|
+
if (!configured.protector)
|
|
216
|
+
throw new Error("Windows profile storage requires CurrentUser DPAPI");
|
|
217
|
+
if (!configured.harden)
|
|
218
|
+
throw new Error("Windows profile storage requires ACL hardening");
|
|
219
|
+
const ciphertext = await configured.protector.protect(profile.machineToken);
|
|
220
|
+
if (!ciphertext)
|
|
221
|
+
throw new Error("Windows DPAPI returned an empty ciphertext");
|
|
222
|
+
const stored = StoredProtectedProfileSchema.parse({
|
|
223
|
+
version: 1,
|
|
224
|
+
name: profile.name,
|
|
225
|
+
serverUrl: profile.serverUrl,
|
|
226
|
+
...(profile.agentsRoot ? { agentsRoot: profile.agentsRoot } : {}),
|
|
227
|
+
...(profile.runtimePath ? { runtimePath: profile.runtimePath } : {}),
|
|
228
|
+
machineTokenProtected: { scheme: "dpapi-current-user", ciphertext },
|
|
229
|
+
});
|
|
230
|
+
await atomicPrivateWrite(profilePath(profile.name, home), `${JSON.stringify(stored, null, 2)}\n`, configured.harden);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
await atomicPrivateWrite(profilePath(profile.name, home), `${JSON.stringify(profile, null, 2)}\n`);
|
|
234
|
+
}
|
|
110
235
|
}
|
|
111
|
-
|
|
112
|
-
|
|
236
|
+
catch (error) {
|
|
237
|
+
bodyFailed = true;
|
|
238
|
+
bodyError = error;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
try {
|
|
243
|
+
await release();
|
|
244
|
+
}
|
|
245
|
+
catch (cleanupError) {
|
|
246
|
+
if (!bodyFailed)
|
|
247
|
+
throw cleanupError;
|
|
248
|
+
attachCleanupError(bodyError, cleanupError);
|
|
249
|
+
}
|
|
113
250
|
}
|
|
114
251
|
return profile;
|
|
115
252
|
}
|
|
@@ -161,6 +298,38 @@ export async function listProfiles(home = daemonHome()) {
|
|
|
161
298
|
throw error;
|
|
162
299
|
}
|
|
163
300
|
}
|
|
301
|
+
export async function inspectProfileAgentsRoot(profile, home = daemonHome(), userHome = homedir(), options = {}) {
|
|
302
|
+
const platform = storageOptions(options).platform;
|
|
303
|
+
const agentsRoot = resolveAgentsRoot(profile.agentsRoot, userHome, platform);
|
|
304
|
+
const comparableRoot = comparableAgentsRoot(agentsRoot, platform);
|
|
305
|
+
const names = (await listProfiles(home)).filter((name) => name !== profile.name);
|
|
306
|
+
const profiles = await Promise.all(names.map((name) => loadProfile(name, home, options)));
|
|
307
|
+
return {
|
|
308
|
+
agentsRoot,
|
|
309
|
+
duplicateProfiles: profiles
|
|
310
|
+
.filter((candidate) => comparableAgentsRoot(resolveAgentsRoot(candidate.agentsRoot, userHome, platform), platform) === comparableRoot)
|
|
311
|
+
.map((candidate) => candidate.name),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
export async function assertProfileAgentsRootUnique(profile, home = daemonHome(), userHome = homedir(), options = {}) {
|
|
315
|
+
const candidate = ProfileAgentsRootCandidateSchema.parse({
|
|
316
|
+
name: profile.name,
|
|
317
|
+
serverUrl: profile.serverUrl,
|
|
318
|
+
...(profile.agentsRoot === undefined ? {} : { agentsRoot: profile.agentsRoot }),
|
|
319
|
+
});
|
|
320
|
+
const inspection = await inspectProfileAgentsRoot(candidate, home, userHome, options);
|
|
321
|
+
const conflict = inspection.duplicateProfiles[0];
|
|
322
|
+
if (conflict === undefined)
|
|
323
|
+
return;
|
|
324
|
+
throw new ProfileAgentsRootConflictError({
|
|
325
|
+
profile: candidate.name,
|
|
326
|
+
conflict,
|
|
327
|
+
agentsRoot: inspection.agentsRoot,
|
|
328
|
+
serverUrl: candidate.serverUrl,
|
|
329
|
+
userHome,
|
|
330
|
+
platform: storageOptions(options).platform,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
164
333
|
export async function removeProfile(name, home = daemonHome()) {
|
|
165
334
|
await rm(profilePath(name, home), { force: true });
|
|
166
335
|
}
|
package/dist/config.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, 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";
|
|
7
|
+
import { resolveAgentsRoot } from "./computer-profile.js";
|
|
7
8
|
export const DEFAULT_EXECUTION_LIMITS = Object.freeze({
|
|
8
9
|
maxPromptBytes: 256_000,
|
|
9
10
|
maxTimeoutMs: 3_600_000,
|
|
@@ -65,7 +66,7 @@ export function loadConfig(env = process.env) {
|
|
|
65
66
|
return {
|
|
66
67
|
serverUrl,
|
|
67
68
|
machineToken,
|
|
68
|
-
agentsRoot: env.CREW_AGENTS_ROOT
|
|
69
|
+
agentsRoot: resolveAgentsRoot(env.CREW_AGENTS_ROOT, homedir()),
|
|
69
70
|
cliPath: env.CREW_CLI_PATH ?? defaultCliPath(),
|
|
70
71
|
runtimeBin: env.CREW_RUNTIME ?? "claude",
|
|
71
72
|
dangerous: env.CREW_RUNTIME_SAFE !== "1", // 默认开启 (headless agent 在自有 workspace 内运行)
|