@nowcrew/daemon 0.6.89 → 0.6.91-beta.1
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 +64 -3
- package/dist/agent-ability/materializer.js +21 -10
- package/dist/agent-environment-contract.js +55 -0
- package/dist/agent-environment-control.js +24 -0
- package/dist/agent-environment-execution.js +98 -0
- package/dist/agent-environments.js +275 -0
- package/dist/automatic-daemon-installer.js +49 -1
- package/dist/computer-main.js +18 -0
- package/dist/computer-profile.js +17 -4
- package/dist/computer-setup-client.js +318 -0
- package/dist/computer-setup-orchestration.js +203 -0
- package/dist/computer-setup.js +244 -0
- package/dist/config.js +30 -12
- package/dist/console-formatter.js +3 -3
- package/dist/console.js +15 -2
- package/dist/daemon-update-eligibility.js +1 -1
- package/dist/daemon-updater.js +2 -12
- package/dist/execution-journal.js +9 -6
- package/dist/execution-protocol.js +1 -0
- package/dist/execution-runner.js +13 -8
- package/dist/i18n.js +19 -0
- package/dist/list-models.js +12 -12
- package/dist/local-execution-lease.js +22 -0
- package/dist/local-executor.js +57 -84
- package/dist/local-memory/candidate.js +43 -0
- package/dist/local-memory/index-budget.js +8 -0
- package/dist/local-memory/inventory-cli.js +61 -0
- package/dist/local-memory/inventory-preview.js +159 -0
- package/dist/local-memory/inventory-source.js +159 -0
- package/dist/local-memory/pending-match.js +29 -0
- package/dist/local-memory/pending-query.js +100 -0
- package/dist/local-memory/pending-schema.js +29 -0
- package/dist/local-memory/pending-source.js +126 -0
- package/dist/local-memory/pending.js +34 -84
- package/dist/local-memory/prompt.js +19 -12
- package/dist/local-memory/read-cli.js +97 -12
- package/dist/local-memory/rollout.js +82 -0
- package/dist/local-memory/runtime.js +23 -17
- package/dist/local-memory/work-log-execution.js +31 -0
- package/dist/local-memory/work-log.js +58 -0
- package/dist/local-memory/write.js +27 -58
- package/dist/local-runtime-environment.js +50 -0
- package/dist/machine-environment-cache.js +147 -0
- package/dist/machine-environment-contract.js +10 -0
- package/dist/machine-environment-control.js +27 -0
- package/dist/machine-environment-policy.js +75 -0
- package/dist/machine-environment-process.js +110 -0
- package/dist/machine-info.js +70 -25
- package/dist/machine-runtime-environment.js +189 -0
- package/dist/machine-shell-environment-capture.js +135 -0
- package/dist/machine-windows-environment-capture.js +215 -0
- package/dist/machine-windows-environment-script.js +158 -0
- package/dist/main.js +4 -69
- package/dist/marketplace-command.js +21 -0
- package/dist/marketplace-controller.js +80 -0
- package/dist/marketplace-protocol.js +38 -0
- package/dist/marketplace-remote.js +48 -0
- package/dist/marketplace-runner.js +235 -0
- package/dist/marketplace-serve.js +41 -0
- package/dist/marketplace-source.js +103 -0
- package/dist/message-file-preview.js +246 -0
- package/dist/pre-admission-rejection.js +64 -0
- package/dist/project-skills/execution-adapter.js +3 -2
- package/dist/project-skills/runtime-launch.js +14 -0
- package/dist/provider-env.js +26 -15
- package/dist/runner.js +17 -4
- package/dist/runtime-environment-merge.js +16 -0
- package/dist/runtime-health-handler.js +10 -0
- package/dist/runtime-health.js +3 -1
- package/dist/runtime-path.js +22 -12
- package/dist/runtime-probe.js +35 -6
- package/dist/runtimes/acp-probe-transport.js +69 -0
- package/dist/runtimes/claude-models.js +20 -9
- package/dist/runtimes/deepseek-harness-launch.js +10 -0
- package/dist/runtimes/deepseek-harness-profile.js +92 -0
- package/dist/runtimes/deepseek-harness-runner.js +226 -0
- package/dist/runtimes/deepseek-harness.js +76 -15
- package/dist/runtimes/harness-probe-retry.js +42 -0
- package/dist/runtimes/hermes-models.js +2 -1
- package/dist/runtimes/kimi-acp-runner.js +78 -13
- package/dist/runtimes/opencode.js +2 -2
- package/dist/serve.js +88 -178
- package/dist/storage-identity.js +0 -3
- package/dist/storage-serve-adapter.js +6 -72
- package/dist/supervised-runtime.js +17 -2
- package/dist/token.js +0 -1
- package/dist/workspace-control-handler.js +27 -0
- package/dist/workspace-fs.js +66 -15
- package/dist/workspace.js +1 -1
- package/package.json +13 -12
- package/dist/codex-home-launch.js +0 -7
- package/dist/codex-home-mode-controller.js +0 -68
- package/dist/daemon-migration-controller.js +0 -211
- package/dist/daemon-migration-wiring.js +0 -22
- package/dist/runtimes/codex-home-migration-cli.js +0 -26
- package/dist/runtimes/codex-home-migration.js +0 -112
- package/dist/runtimes/codex-home.js +0 -236
- package/dist/storage-control.js +0 -212
- package/dist/storage-copy.js +0 -628
- package/dist/storage-credential-guard.js +0 -21
- package/dist/storage-doctor.js +0 -228
- package/dist/storage-manifest.js +0 -1027
- package/dist/storage-migration-controller.js +0 -440
- package/dist/storage-migration-manifest.js +0 -105
- package/dist/storage-migration-policy.js +0 -100
- package/dist/storage-runtime-controller.js +0 -381
- package/dist/storage-runtime-readiness.js +0 -241
- package/dist/storage-safe-fs.js +0 -302
- package/dist/storage-status-delivery.js +0 -224
- package/dist/storage-status-replay.js +0 -140
- package/dist/storage-target-confirmation.js +0 -129
- package/dist/storage-worker-command.js +0 -37
- package/dist/storage-worker-executor.js +0 -447
- package/dist/storage-worker-handoff.js +0 -151
- package/dist/storage-worker-operation.js +0 -294
- package/dist/storage-worker-status.js +0 -259
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { safeKey } from "../workspace.js";
|
|
5
|
+
/** 收尾会话可以换目录,但仍应消费同一任务已写好的日志;不迁移文件或复用会话。 */
|
|
6
|
+
export async function resolveMemoryPruneWorkLog(workspace, input) {
|
|
7
|
+
// 旧映射有损时无法证明是同一任务,不能用它查找或清理来源。
|
|
8
|
+
if (input.keyMode !== "opaque" || !input.taskKey || safeKey(input.taskKey) !== input.taskKey) {
|
|
9
|
+
return workspace;
|
|
10
|
+
}
|
|
11
|
+
const legacyPath = join(workspace.dir, "tasks", input.taskKey, "work-log.md");
|
|
12
|
+
if (legacyPath === workspace.workLogPath)
|
|
13
|
+
return workspace;
|
|
14
|
+
let legacyStat;
|
|
15
|
+
try {
|
|
16
|
+
legacyStat = await lstat(legacyPath);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return workspace;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (!legacyStat.isFile() || legacyStat.isSymbolicLink()
|
|
24
|
+
|| await realpath(legacyPath) !== join(await realpath(workspace.dir), "tasks", input.taskKey, "work-log.md")) {
|
|
25
|
+
throw new Error("local_memory_work_log_path_invalid");
|
|
26
|
+
}
|
|
27
|
+
const workLog = await readFile(legacyPath, "utf8");
|
|
28
|
+
if (workLog.length === 0)
|
|
29
|
+
return workspace;
|
|
30
|
+
if ((await readFile(workspace.workLogPath, "utf8")).length > 0) {
|
|
31
|
+
throw new Error("local_memory_work_log_source_conflict");
|
|
32
|
+
}
|
|
33
|
+
return { ...workspace, workLogPath: legacyPath, workLog };
|
|
34
|
+
}
|
|
35
|
+
export function workLogSha256(content) {
|
|
36
|
+
return createHash("sha256").update(content).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
/** 调用方须在同任务的提交互斥边界内校验;指纹对应实际注入模型的那一版内容。 */
|
|
39
|
+
export async function verifyWorkLogSource(agentDir, path, expectedSha256) {
|
|
40
|
+
if (expectedSha256 === undefined)
|
|
41
|
+
throw new Error("local_memory_work_log_version_missing");
|
|
42
|
+
const relativePath = relative(resolve(agentDir), resolve(path));
|
|
43
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
44
|
+
throw new Error("local_memory_work_log_path_invalid");
|
|
45
|
+
}
|
|
46
|
+
const before = await lstat(path);
|
|
47
|
+
if (!before.isFile() || before.isSymbolicLink()
|
|
48
|
+
|| await realpath(path) !== join(await realpath(agentDir), relativePath)) {
|
|
49
|
+
throw new Error("local_memory_work_log_path_invalid");
|
|
50
|
+
}
|
|
51
|
+
const content = await readFile(path);
|
|
52
|
+
const after = await lstat(path);
|
|
53
|
+
if (workLogSha256(content) !== expectedSha256 || before.dev !== after.dev || before.ino !== after.ino
|
|
54
|
+
|| before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
|
|
55
|
+
throw new Error("local_memory_work_log_source_changed");
|
|
56
|
+
}
|
|
57
|
+
return after;
|
|
58
|
+
}
|
|
@@ -2,29 +2,12 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { dslog } from "../slog.js";
|
|
6
|
+
import { LocalMemoryChangeSchema, LocalMemorySingleLineText, validateLocalMemoryChange, } from "./candidate.js";
|
|
7
|
+
import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderGeneratedIndex, selectLocalMemoryIndexEntries, } from "./index-budget.js";
|
|
7
8
|
import { persistPendingMemoryCandidate, readPendingMemoryCandidates, removePendingMemoryCandidate, } from "./pending.js";
|
|
8
|
-
const CategorySchema = z.enum([
|
|
9
|
-
"preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
|
|
10
|
-
]);
|
|
11
|
-
const SingleLineText = z.string().trim().min(1).max(500).regex(/^[^\r\n]+$/u);
|
|
12
|
-
const ChangeSchema = z.object({
|
|
13
|
-
operation: z.enum(["create", "update", "supersede"]),
|
|
14
|
-
memoryId: SingleLineText.regex(/^[^<>]+$/u).nullable(),
|
|
15
|
-
baseRevision: z.number().int().nonnegative().nullable(),
|
|
16
|
-
supersedesMemoryId: SingleLineText.regex(/^[^<>]+$/u).nullable(),
|
|
17
|
-
title: SingleLineText.max(120),
|
|
18
|
-
kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
|
|
19
|
-
summary: SingleLineText,
|
|
20
|
-
triggers: z.array(SingleLineText.max(80)).min(1).max(16),
|
|
21
|
-
priority: z.enum(["high", "medium", "low"]),
|
|
22
|
-
detailCategory: CategorySchema,
|
|
23
|
-
detailBody: z.string().trim().min(1).max(12_000),
|
|
24
|
-
reviewAfter: SingleLineText.nullable(),
|
|
25
|
-
}).strict();
|
|
26
9
|
const StatusChangeSchema = z.object({
|
|
27
|
-
memoryId:
|
|
10
|
+
memoryId: LocalMemorySingleLineText,
|
|
28
11
|
expectedRevision: z.number().int().positive(),
|
|
29
12
|
expectedStatus: z.enum(["candidate", "active", "pinned", "stale", "superseded", "archived"]),
|
|
30
13
|
targetStatus: z.enum(["candidate", "active", "pinned", "stale", "archived"]),
|
|
@@ -35,7 +18,7 @@ const ResultSchema = z.object({
|
|
|
35
18
|
purpose: z.literal("memory_prune"),
|
|
36
19
|
baseCatalogRevision: z.number().int().nonnegative(),
|
|
37
20
|
outcome: z.enum(["no_change", "proposed", "conflict", "failed"]),
|
|
38
|
-
changes: z.array(
|
|
21
|
+
changes: z.array(LocalMemoryChangeSchema).max(50),
|
|
39
22
|
statusChanges: z.array(StatusChangeSchema).max(50),
|
|
40
23
|
}).strict();
|
|
41
24
|
const GENERATED_INDEX_PATTERN = /<!--\s*NOWWORK:MEMORY_INDEX:BEGIN\s+schema=1\s*-->[\s\S]*?<!--\s*NOWWORK:MEMORY_INDEX:END\s*-->/u;
|
|
@@ -43,8 +26,6 @@ const INDEX_HEADING = /^##\s+Knowledge Index\s*$/mu;
|
|
|
43
26
|
const NEXT_SECTION = /^##\s+(?!#)/mu;
|
|
44
27
|
const CARD_HEADING = /^###\s+(.+?)\s*$/gmu;
|
|
45
28
|
const FIELD_LINE = /^-\s+([a-z][a-z_]*)\s*:\s*(.*?)\s*$/iu;
|
|
46
|
-
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
47
|
-
const RESERVED_MEMORY_MARKER = /NOWWORK:(?:MEMORY_ENTRY|MEMORY_INDEX):/iu;
|
|
48
29
|
function codePointLength(value) {
|
|
49
30
|
return Array.from(value).length;
|
|
50
31
|
}
|
|
@@ -117,13 +98,6 @@ function parseUnmarkedMemoryIndex(memory) {
|
|
|
117
98
|
const section = indexSection(memory).replace(GENERATED_INDEX_PATTERN, "");
|
|
118
99
|
return parseCards(section);
|
|
119
100
|
}
|
|
120
|
-
function renderGeneratedIndex(entries) {
|
|
121
|
-
const cards = entries
|
|
122
|
-
.filter((entry) => entry.status === "pinned" || entry.status === "active")
|
|
123
|
-
.map(renderLocalMemoryIndexCard)
|
|
124
|
-
.join("\n");
|
|
125
|
-
return `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=1 -->\n${cards}<!-- NOWWORK:MEMORY_INDEX:END -->`;
|
|
126
|
-
}
|
|
127
101
|
function replaceGeneratedIndex(memory, entries) {
|
|
128
102
|
const generated = renderGeneratedIndex(entries);
|
|
129
103
|
if (GENERATED_INDEX_PATTERN.test(memory))
|
|
@@ -135,9 +109,15 @@ function replaceGeneratedIndex(memory, entries) {
|
|
|
135
109
|
}
|
|
136
110
|
return `${memory.trimEnd()}\n\n## Knowledge Index\n${generated}\n`;
|
|
137
111
|
}
|
|
112
|
+
function unwrappedNoteBody(title, body) {
|
|
113
|
+
const detail = body.trim();
|
|
114
|
+
const heading = /^([^\r\n]+)\r?\n\r?\n/u.exec(detail);
|
|
115
|
+
// Agent 可能回传包含包装标题的全文;只解开一层,保留内部及历史标题。
|
|
116
|
+
return heading?.[1] === `## ${title}` ? detail.slice(heading[0].length) : detail;
|
|
117
|
+
}
|
|
138
118
|
function noteSection(id, revision, title, body) {
|
|
139
119
|
return `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${id} revision=${revision} -->\n`
|
|
140
|
-
+ `## ${title}\n\n${body
|
|
120
|
+
+ `## ${title}\n\n${unwrappedNoteBody(title, body)}\n`
|
|
141
121
|
+ `<!-- NOWWORK:MEMORY_ENTRY:END id=${id} -->\n`;
|
|
142
122
|
}
|
|
143
123
|
function notePattern(id) {
|
|
@@ -148,34 +128,12 @@ function replaceNote(content, id, replacement) {
|
|
|
148
128
|
const pattern = notePattern(id);
|
|
149
129
|
return pattern.test(content) ? content.replace(pattern, replacement) : null;
|
|
150
130
|
}
|
|
151
|
-
function containsReservedText(change) {
|
|
152
|
-
return RESERVED_MEMORY_MARKER.test([change.title, change.summary, ...change.triggers, change.detailBody].join("\n"));
|
|
153
|
-
}
|
|
154
131
|
function validateCandidate(result) {
|
|
155
132
|
if (result.outcome === "no_change" && (result.changes.length > 0 || result.statusChanges.length > 0)) {
|
|
156
133
|
throw new Error("local_memory_no_change_has_operations");
|
|
157
134
|
}
|
|
158
|
-
for (const change of result.changes)
|
|
159
|
-
|
|
160
|
-
if (containsLikelySecret(text))
|
|
161
|
-
throw new Error("local_memory_secret_candidate");
|
|
162
|
-
if (CONTROL_CHARACTER_PATTERN.test(text))
|
|
163
|
-
throw new Error("local_memory_control_character");
|
|
164
|
-
if (containsReservedText(change))
|
|
165
|
-
throw new Error("local_memory_reserved_marker");
|
|
166
|
-
if (change.operation === "create"
|
|
167
|
-
&& (change.memoryId !== null || change.baseRevision !== null || change.supersedesMemoryId !== null)) {
|
|
168
|
-
throw new Error("local_memory_create_revision_invalid");
|
|
169
|
-
}
|
|
170
|
-
if (change.operation === "update"
|
|
171
|
-
&& (change.memoryId === null || change.baseRevision === null || change.supersedesMemoryId !== null)) {
|
|
172
|
-
throw new Error("local_memory_update_revision_missing");
|
|
173
|
-
}
|
|
174
|
-
if (change.operation === "supersede"
|
|
175
|
-
&& (change.memoryId !== null || change.baseRevision === null || change.supersedesMemoryId === null)) {
|
|
176
|
-
throw new Error("local_memory_supersede_revision_missing");
|
|
177
|
-
}
|
|
178
|
-
}
|
|
135
|
+
for (const change of result.changes)
|
|
136
|
+
validateLocalMemoryChange(change);
|
|
179
137
|
}
|
|
180
138
|
export function parseLocalMemoryResult(raw) {
|
|
181
139
|
let value;
|
|
@@ -477,7 +435,16 @@ export async function applyLocalMemoryResult(input) {
|
|
|
477
435
|
// Pending create candidates are durable overflow, not dead ends. Promote only
|
|
478
436
|
// when the current catalog has room; supersede/update candidates stay pending
|
|
479
437
|
// until a fresh revision-aware decision can safely replay them.
|
|
480
|
-
const pendingCandidatesOnDisk = await readPendingMemoryCandidates({ agentDir: input.agentDir })
|
|
438
|
+
const pendingCandidatesOnDisk = await readPendingMemoryCandidates({ agentDir: input.agentDir }).catch((error) => {
|
|
439
|
+
if (!(error instanceof Error) || error.message !== "memory_pending_directory_over_budget")
|
|
440
|
+
throw error;
|
|
441
|
+
// 自动提升是可选工作,目录查询预算不应阻断本轮已验证的更新或腾位操作。
|
|
442
|
+
dslog("local_memory.pending_promotion_skipped", "pending目录超出扫描预算,本轮跳过自动提升", {
|
|
443
|
+
level: "WARN", execution_id: input.executionId, prune_trace_id: input.pruneTraceId,
|
|
444
|
+
reason: "memory_pending_directory_over_budget",
|
|
445
|
+
});
|
|
446
|
+
return [];
|
|
447
|
+
});
|
|
481
448
|
for (const pending of pendingCandidatesOnDisk) {
|
|
482
449
|
const change = pending.candidate;
|
|
483
450
|
if (change.operation !== "create")
|
|
@@ -494,8 +461,10 @@ export async function applyLocalMemoryResult(input) {
|
|
|
494
461
|
if (duplicate !== undefined) {
|
|
495
462
|
const duplicatePath = resolve(input.agentDir, duplicate.file);
|
|
496
463
|
const duplicateBody = readNoteBody(noteUpdates.get(duplicatePath) ?? await readNote(duplicatePath), duplicate.id);
|
|
497
|
-
if (duplicateBody === change.detailBody
|
|
464
|
+
if (duplicateBody === change.detailBody
|
|
465
|
+
|| duplicateBody === unwrappedNoteBody(change.title, change.detailBody).trim()) {
|
|
498
466
|
resolvedPendingIds.add(pending.pendingId);
|
|
467
|
+
}
|
|
499
468
|
continue;
|
|
500
469
|
}
|
|
501
470
|
const entry = entryFromChange(change, id, 1, "active");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { delimiter, join } from "node:path";
|
|
2
|
+
import { environmentForExecution } from "./agent-environments.js";
|
|
3
|
+
import { augmentedPath } from "./runtime-path.js";
|
|
4
|
+
import { sanitizeEnvVars } from "./runtime-env.js";
|
|
5
|
+
import { machinePackageManagerEnv } from "./package-manager-env.js";
|
|
6
|
+
import { KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
|
|
7
|
+
import { mergeRuntimeEnvironments } from "./runtime-environment-merge.js";
|
|
8
|
+
/** 机器、目录与受控运行字段分层合并,Windows 不依赖对象中键名的偶然顺序。 */
|
|
9
|
+
export function buildLocalRuntimeEnvironment(context) {
|
|
10
|
+
const { input, workspace, executionWorkspace, inheritedEnv, providerConfig, crewHome, contextThreadId, nativeThreadContextPath, reportContext, runtime } = context;
|
|
11
|
+
const agentEnvironment = sanitizeEnvVars(providerConfig.environmentPlan === undefined ? providerConfig.envVars
|
|
12
|
+
: environmentForExecution(providerConfig.environmentPlan, workspace.dir, input.projectContext?.primary?.root ?? executionWorkspace.runDir, input.projectContext?.primary?.projectId));
|
|
13
|
+
return mergeRuntimeEnvironments([
|
|
14
|
+
inheritedEnv, agentEnvironment,
|
|
15
|
+
input.trainingProjectContext === undefined ? {} : machinePackageManagerEnv(inheritedEnv),
|
|
16
|
+
input.launch.systemEnv ?? {}, {
|
|
17
|
+
PATH: `${workspace.crewDir}${delimiter}${augmentedPath(inheritedEnv, {}, process.platform, "append")}`,
|
|
18
|
+
CREW_SERVER_URL: input.launch.serverUrl,
|
|
19
|
+
CREW_TOKEN: input.launch.token,
|
|
20
|
+
CREW_CHANNEL: input.channelId,
|
|
21
|
+
CREW_HOME: crewHome,
|
|
22
|
+
// Empty values override any stale inherited hints from an enclosing runtime.
|
|
23
|
+
CREW_THREAD_ROOT: contextThreadId ?? "",
|
|
24
|
+
CREW_THREAD_CONTEXT_FILE: nativeThreadContextPath ?? "",
|
|
25
|
+
// Keep the daemon-selected storage root authoritative for runtimes.
|
|
26
|
+
// In profile-backed NowWork sessions the daemon config may differ from
|
|
27
|
+
// the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
|
|
28
|
+
// sufficient for QA/project diagnostics.
|
|
29
|
+
CREW_AGENTS_ROOT: input.launch.agentsRoot,
|
|
30
|
+
...(input.launch.reposRoot === undefined ? {} : { NOWWORK_REPOS_ROOT: input.launch.reposRoot }),
|
|
31
|
+
...(input.launch.qaReportRoot === undefined ? {} : { QA_REPORT_HUB: input.launch.qaReportRoot }),
|
|
32
|
+
// Expose only the training root; ability-specific workspace layout stays
|
|
33
|
+
// owned by the training package rather than the NowWork daemon.
|
|
34
|
+
QA_RUNTIME_ROOT: join(crewHome, "training"),
|
|
35
|
+
QA_REPORT_DELIVERY_CONTEXT: JSON.stringify(reportContext),
|
|
36
|
+
CREW_TASK_DIR: executionWorkspace.runDir,
|
|
37
|
+
CREW_TASK_LOG: executionWorkspace.workLogPath,
|
|
38
|
+
...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
|
|
39
|
+
XDG_CONFIG_HOME: join(workspace.homeDir, ".config"),
|
|
40
|
+
XDG_DATA_HOME: join(workspace.homeDir, ".local", "share"),
|
|
41
|
+
XDG_CACHE_HOME: join(workspace.homeDir, ".cache"),
|
|
42
|
+
GH_CONFIG_DIR: join(workspace.homeDir, ".config", "gh"),
|
|
43
|
+
CLOUDSDK_CONFIG: join(workspace.homeDir, ".config", "gcloud"),
|
|
44
|
+
...(runtime.name === "kimi" && runtime.reasoning
|
|
45
|
+
&& KIMI_EFFORT_LEVELS.includes(runtime.reasoning)
|
|
46
|
+
? { KIMI_MODEL_THINKING_EFFORT: runtime.reasoning }
|
|
47
|
+
: {}),
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { MachineEnvironmentCaptureError } from "./machine-environment-contract.js";
|
|
3
|
+
import { copyMachineEnvironment, machineCaptureStartupEnvironment, resolveMachineAccount, restoreMachineEnvironmentOwnership, selectMachineEnvironmentSource, validateMachineEnvironment } from "./machine-environment-policy.js";
|
|
4
|
+
import { captureShellMachineEnvironment, machineEnvironmentSourceFingerprint, machineShellSourceLocations } from "./machine-shell-environment-capture.js";
|
|
5
|
+
function frozenStatus(status) {
|
|
6
|
+
return Object.freeze({ ...status, sourceLocations: Object.freeze([...new Set(status.sourceLocations)].slice(0, 8)) });
|
|
7
|
+
}
|
|
8
|
+
/** 测试或明确关闭机器环境时使用;永远不会启动 shell,也不读取真实用户 rc。 */
|
|
9
|
+
export function createStaticMachineEnvironmentProvider(startupEnv = {}) {
|
|
10
|
+
const snapshot = Object.freeze({ env: copyMachineEnvironment(startupEnv), status: frozenStatus({ bootId: randomUUID(), generation: 0, state: "disabled", source: null, sourceLocations: [], capturedAt: null, attemptedAt: null, errorCode: null }) });
|
|
11
|
+
return { acquire: async () => snapshot, status: () => snapshot.status, stop: async () => { } };
|
|
12
|
+
}
|
|
13
|
+
const sameEnvironment = (left, right) => {
|
|
14
|
+
const keys = Object.keys(left);
|
|
15
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && left[key] === right[key]);
|
|
16
|
+
};
|
|
17
|
+
export function createMachineEnvironmentProvider(options = {}) {
|
|
18
|
+
const startup = copyMachineEnvironment(options.startupEnv ?? process.env);
|
|
19
|
+
const mode = options.mode ?? "auto";
|
|
20
|
+
const platform = options.platform ?? process.platform;
|
|
21
|
+
const now = options.now ?? Date.now;
|
|
22
|
+
let account = Object.freeze({ ...(options.account ?? resolveMachineAccount()) });
|
|
23
|
+
const selected = selectMachineEnvironmentSource(mode, account, startup, platform);
|
|
24
|
+
const baseline = startup;
|
|
25
|
+
const captureStartup = machineCaptureStartupEnvironment(startup, account, platform);
|
|
26
|
+
let watchedLocations = machineShellSourceLocations({ source: selected.source ?? "windows-registry", account, startupEnv: captureStartup });
|
|
27
|
+
let sourceFingerprint = null;
|
|
28
|
+
let snapshot = Object.freeze({ env: baseline, status: frozenStatus({ bootId: randomUUID(), generation: 0, state: mode === "off" ? "disabled" : selected.source === null ? "unsupported" : "loading", source: selected.source, sourceLocations: watchedLocations, capturedAt: null, attemptedAt: null, errorCode: null }) });
|
|
29
|
+
let lastGood = null;
|
|
30
|
+
let lastSuccessTime = -Infinity;
|
|
31
|
+
let lastFailureTime = -Infinity;
|
|
32
|
+
let stopped = false;
|
|
33
|
+
let unsafeCleanup = false;
|
|
34
|
+
let flight;
|
|
35
|
+
let controller;
|
|
36
|
+
let captureEpoch = 0;
|
|
37
|
+
let capturing = false;
|
|
38
|
+
let forcedFollowup;
|
|
39
|
+
const sourceOwnedKeys = new Set();
|
|
40
|
+
const publish = (env, patch) => {
|
|
41
|
+
snapshot = Object.freeze({ env, status: frozenStatus({ ...snapshot.status, ...patch }) });
|
|
42
|
+
try {
|
|
43
|
+
options.onChange?.(snapshot);
|
|
44
|
+
}
|
|
45
|
+
catch { /* 观察者失败不能污染已获取的机器环境。 */ }
|
|
46
|
+
return snapshot;
|
|
47
|
+
};
|
|
48
|
+
const captureWindows = options.captureWindows ?? (async (request) => {
|
|
49
|
+
const module = await import("./machine-windows-environment-capture.js");
|
|
50
|
+
return module.captureWindowsMachineEnvironment(request);
|
|
51
|
+
});
|
|
52
|
+
const refresh = async (force, signal) => {
|
|
53
|
+
if (!selected.source || stopped || unsafeCleanup)
|
|
54
|
+
return snapshot;
|
|
55
|
+
if (!force && now() - lastFailureTime < (options.backoffMs ?? 30_000))
|
|
56
|
+
return snapshot;
|
|
57
|
+
const attemptedAt = new Date(now()).toISOString();
|
|
58
|
+
try {
|
|
59
|
+
if (!force && lastGood && now() - lastSuccessTime < (options.ttlMs ?? 60_000)) {
|
|
60
|
+
if (selected.source === "windows-registry" || sourceFingerprint === await machineEnvironmentSourceFingerprint(watchedLocations))
|
|
61
|
+
return snapshot;
|
|
62
|
+
}
|
|
63
|
+
if (stopped || signal.aborted)
|
|
64
|
+
return snapshot;
|
|
65
|
+
publish(snapshot.env, { state: "loading", attemptedAt, errorCode: null });
|
|
66
|
+
capturing = true;
|
|
67
|
+
const deadline = Date.now() + (options.captureTimeoutMs ?? (selected.source === "windows-registry" ? 8_000 : 5_000));
|
|
68
|
+
const capture = selected.source === "windows-registry" ? captureWindows : options.captureShell ?? captureShellMachineEnvironment;
|
|
69
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const before = selected.source === "windows-registry" ? null : await machineEnvironmentSourceFingerprint(watchedLocations);
|
|
72
|
+
captureEpoch++;
|
|
73
|
+
const captured = await capture({ startupEnv: captureStartup, account, source: selected.source, ...(selected.shellPath ? { shellPath: selected.shellPath } : {}), signal, deadline });
|
|
74
|
+
if (signal.aborted)
|
|
75
|
+
throw new MachineEnvironmentCaptureError("shell_capture_timeout");
|
|
76
|
+
if (before !== null && before !== await machineEnvironmentSourceFingerprint(watchedLocations))
|
|
77
|
+
throw new MachineEnvironmentCaptureError("shell_files_changed");
|
|
78
|
+
let env = validateMachineEnvironment(captured.env);
|
|
79
|
+
let currentOwned;
|
|
80
|
+
if (selected.source === "windows-registry") {
|
|
81
|
+
currentOwned = new Set((captured.sourceOwnedKeys ?? []).map((key) => key.toUpperCase()));
|
|
82
|
+
const merged = new Map();
|
|
83
|
+
for (const [key, value] of [...Object.entries(startup), ...Object.entries(env)]) {
|
|
84
|
+
if (!sourceOwnedKeys.has(key.toUpperCase()) || currentOwned.has(key.toUpperCase()))
|
|
85
|
+
merged.set(key.toUpperCase(), [key, value]);
|
|
86
|
+
}
|
|
87
|
+
env = Object.fromEntries(merged.values());
|
|
88
|
+
}
|
|
89
|
+
env = validateMachineEnvironment(restoreMachineEnvironmentOwnership(env, startup, account, platform));
|
|
90
|
+
const unchanged = lastGood !== null && sameEnvironment(lastGood.env, env);
|
|
91
|
+
const generation = unchanged && lastGood ? lastGood.status.generation : (lastGood?.status.generation ?? 0) + 1;
|
|
92
|
+
const locations = [...new Set(captured.sourceLocations)].slice(0, 8);
|
|
93
|
+
watchedLocations = locations;
|
|
94
|
+
sourceFingerprint = selected.source === "windows-registry" ? null : await machineEnvironmentSourceFingerprint(locations);
|
|
95
|
+
for (const key of currentOwned ?? [])
|
|
96
|
+
sourceOwnedKeys.add(key);
|
|
97
|
+
if (selected.source === "windows-registry" && captured.accountSid)
|
|
98
|
+
account = Object.freeze({ ...account, sid: captured.accountSid });
|
|
99
|
+
lastSuccessTime = now();
|
|
100
|
+
lastFailureTime = -Infinity;
|
|
101
|
+
lastGood = publish(unchanged && lastGood ? lastGood.env : env, { state: "ready", generation, sourceLocations: locations, capturedAt: new Date(lastSuccessTime).toISOString(), errorCode: null });
|
|
102
|
+
return lastGood;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof MachineEnvironmentCaptureError && error.code === "shell_files_changed" && attempt === 0 && Date.now() < deadline && !signal.aborted)
|
|
106
|
+
continue;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new MachineEnvironmentCaptureError("shell_files_changed");
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const errorCode = error instanceof MachineEnvironmentCaptureError ? error.code : selected.source === "windows-registry" ? "windows_environment_unavailable" : "shell_capture_failed";
|
|
114
|
+
if (errorCode === "shell_cleanup_failed")
|
|
115
|
+
unsafeCleanup = true;
|
|
116
|
+
lastFailureTime = now();
|
|
117
|
+
return publish(lastGood?.env ?? baseline, { state: lastGood ? "stale" : "fallback", errorCode, attemptedAt });
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
capturing = false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const acquire = (acquireOptions) => {
|
|
124
|
+
if (flight) {
|
|
125
|
+
if (acquireOptions?.force && !capturing) {
|
|
126
|
+
const requestedEpoch = captureEpoch;
|
|
127
|
+
forcedFollowup ??= flight.then((value) => captureEpoch > requestedEpoch ? value : acquire({ force: true })).finally(() => { forcedFollowup = undefined; });
|
|
128
|
+
return forcedFollowup;
|
|
129
|
+
}
|
|
130
|
+
return flight;
|
|
131
|
+
}
|
|
132
|
+
if (stopped || unsafeCleanup || selected.source === null)
|
|
133
|
+
return Promise.resolve(snapshot);
|
|
134
|
+
controller = new AbortController();
|
|
135
|
+
flight = refresh(acquireOptions?.force === true, controller.signal).finally(() => { flight = undefined; controller = undefined; });
|
|
136
|
+
return flight;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
acquire,
|
|
140
|
+
status: () => snapshot.status,
|
|
141
|
+
async stop() {
|
|
142
|
+
stopped = true;
|
|
143
|
+
controller?.abort();
|
|
144
|
+
await flight;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export class MachineEnvironmentCaptureError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code) {
|
|
4
|
+
super(code);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "MachineEnvironmentCaptureError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export const MACHINE_ENVIRONMENT_MAX_BYTES = 1024 * 1024;
|
|
10
|
+
export const MACHINE_ENVIRONMENT_MAX_ENTRIES = 4096;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const Request = z.object({ type: z.enum(["machine:environment:get", "machine:environment:refresh"]), reqId: z.string().min(1).max(200) }).strict();
|
|
3
|
+
/** 发送回调由连接持有,刷新完成后不得把旧请求回复到新 socket。 */
|
|
4
|
+
export async function handleMachineEnvironmentFrame(decoded, provider, send) {
|
|
5
|
+
if (typeof decoded !== "object" || decoded === null || !("type" in decoded)
|
|
6
|
+
|| (decoded.type !== "machine:environment:get" && decoded.type !== "machine:environment:refresh"))
|
|
7
|
+
return false;
|
|
8
|
+
const reqId = "reqId" in decoded && typeof decoded.reqId === "string" ? decoded.reqId.slice(0, 200) : "invalid";
|
|
9
|
+
const reply = (result) => { try {
|
|
10
|
+
send({ type: "fs:result", reqId, ...result });
|
|
11
|
+
}
|
|
12
|
+
catch { /* 连接失效由服务端超时处理。 */ } };
|
|
13
|
+
const parsed = Request.safeParse(decoded);
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
reply({ ok: false, error: "machine_environment_request_invalid" });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
if (parsed.data.type === "machine:environment:refresh")
|
|
20
|
+
await provider.acquire({ force: true });
|
|
21
|
+
reply({ ok: true, data: provider.status() });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
reply({ ok: false, error: "machine_environment_request_failed" });
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { userInfo } from "node:os";
|
|
2
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
3
|
+
import { MACHINE_ENVIRONMENT_MAX_BYTES, MACHINE_ENVIRONMENT_MAX_ENTRIES, MachineEnvironmentCaptureError } from "./machine-environment-contract.js";
|
|
4
|
+
const ACCOUNT_KEYS = new Set(["HOME", "USER", "LOGNAME", "SHELL", "USERPROFILE", "USERNAME", "USERDOMAIN", "USERDOMAIN_ROAMINGPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "WINDIR", "COMSPEC"]);
|
|
5
|
+
const CONFIGURED_KEYS = new Set(["QA_REPORT_HUB", "QA_RUNTIME_ROOT", "QA_REPORT_DELIVERY_CONTEXT"]);
|
|
6
|
+
export const isMachineControlKey = (key) => /^(CREW_|NOWWORK_)/iu.test(key);
|
|
7
|
+
export const isMachineTransientKey = (key) => ["_", "SHLVL", "PWD", "OLDPWD"].includes(key) || key.startsWith("BASH_FUNC_");
|
|
8
|
+
export function copyMachineEnvironment(env) {
|
|
9
|
+
return Object.freeze(Object.fromEntries(Object.entries(env).filter((entry) => typeof entry[1] === "string")));
|
|
10
|
+
}
|
|
11
|
+
export function resolveMachineAccount() {
|
|
12
|
+
const info = userInfo();
|
|
13
|
+
return Object.freeze({ home: info.homedir, username: info.username, shell: info.shell, uid: info.uid });
|
|
14
|
+
}
|
|
15
|
+
/** 账号来自操作系统,不受 daemon 的 profile HOME 或 rc 改写影响。 */
|
|
16
|
+
export function restoreMachineEnvironmentOwnership(captured, startup, account, platform) {
|
|
17
|
+
const protectedKeys = new Set(Object.keys(startup).filter((key) => CONFIGURED_KEYS.has(key.toUpperCase())).map((key) => key.toUpperCase()));
|
|
18
|
+
const owns = (key) => isMachineControlKey(key) || ACCOUNT_KEYS.has(key.toUpperCase()) || protectedKeys.has(key.toUpperCase());
|
|
19
|
+
const entries = Object.entries(captured).filter(([key]) => !owns(key) && !isMachineTransientKey(key));
|
|
20
|
+
entries.push(...Object.entries(startup).filter(([key]) => owns(key)));
|
|
21
|
+
const env = Object.fromEntries(entries);
|
|
22
|
+
if (platform === "win32") {
|
|
23
|
+
for (const key of Object.keys(env))
|
|
24
|
+
if (["HOME", "USERPROFILE", "USERNAME"].includes(key.toUpperCase()))
|
|
25
|
+
delete env[key];
|
|
26
|
+
env.USERPROFILE = account.home;
|
|
27
|
+
env.USERNAME = account.username;
|
|
28
|
+
if (startup.HOME !== undefined)
|
|
29
|
+
env.HOME = account.home;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
env.HOME = account.home;
|
|
33
|
+
env.USER = account.username;
|
|
34
|
+
env.LOGNAME = account.username;
|
|
35
|
+
const shell = account.shell || startup.SHELL;
|
|
36
|
+
if (shell)
|
|
37
|
+
env.SHELL = shell;
|
|
38
|
+
else
|
|
39
|
+
delete env.SHELL;
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze(env);
|
|
42
|
+
}
|
|
43
|
+
export function machineCaptureStartupEnvironment(startup, account, platform) {
|
|
44
|
+
const owned = restoreMachineEnvironmentOwnership(startup, startup, account, platform);
|
|
45
|
+
return Object.freeze(Object.fromEntries(Object.entries(owned).filter(([key]) => !isMachineControlKey(key) && !isMachineTransientKey(key))));
|
|
46
|
+
}
|
|
47
|
+
export function selectMachineEnvironmentSource(mode, account, startup, platform) {
|
|
48
|
+
if (mode === "off")
|
|
49
|
+
return { source: null };
|
|
50
|
+
if (platform === "win32")
|
|
51
|
+
return { source: mode === "auto" || mode === "windows" ? "windows-registry" : null };
|
|
52
|
+
if (mode === "windows")
|
|
53
|
+
return { source: null };
|
|
54
|
+
const loginShell = account.shell || startup.SHELL;
|
|
55
|
+
const name = mode === "auto" ? basename(loginShell ?? "") : mode;
|
|
56
|
+
if (name !== "bash" && name !== "zsh")
|
|
57
|
+
return { source: null };
|
|
58
|
+
const shellPath = loginShell && isAbsolute(loginShell) && basename(loginShell) === name ? loginShell : join("/bin", name);
|
|
59
|
+
return { source: name, shellPath };
|
|
60
|
+
}
|
|
61
|
+
export function validateMachineEnvironment(value, bytes) {
|
|
62
|
+
if (bytes !== undefined && bytes > MACHINE_ENVIRONMENT_MAX_BYTES)
|
|
63
|
+
throw new MachineEnvironmentCaptureError("shell_capture_too_large");
|
|
64
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
65
|
+
throw new MachineEnvironmentCaptureError("shell_capture_failed");
|
|
66
|
+
const entries = Object.entries(value);
|
|
67
|
+
if (entries.length > MACHINE_ENVIRONMENT_MAX_ENTRIES)
|
|
68
|
+
throw new MachineEnvironmentCaptureError("shell_capture_too_large");
|
|
69
|
+
if (entries.some(([key, item]) => !key || key.includes("=") || key.includes("\0") || typeof item !== "string" || item.includes("\0"))) {
|
|
70
|
+
throw new MachineEnvironmentCaptureError("shell_capture_failed");
|
|
71
|
+
}
|
|
72
|
+
if (Buffer.byteLength(JSON.stringify(value), "utf8") > MACHINE_ENVIRONMENT_MAX_BYTES)
|
|
73
|
+
throw new MachineEnvironmentCaptureError("shell_capture_too_large");
|
|
74
|
+
return Object.freeze(Object.fromEntries(entries));
|
|
75
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { MACHINE_ENVIRONMENT_MAX_BYTES, MachineEnvironmentCaptureError } from "./machine-environment-contract.js";
|
|
4
|
+
/** 只接收本模块 detached spawn 所创建的进程组,绝不复用 supervisor 的 PID 清理接口。 */
|
|
5
|
+
async function cleanupOwnedGroup(pid) {
|
|
6
|
+
const exists = () => {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(-pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === "ESRCH")
|
|
13
|
+
return false;
|
|
14
|
+
throw new MachineEnvironmentCaptureError("shell_cleanup_failed");
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const signal = (value) => {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(-pid, value);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "ESRCH")
|
|
23
|
+
throw new MachineEnvironmentCaptureError("shell_cleanup_failed");
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
if (!exists())
|
|
27
|
+
return;
|
|
28
|
+
for (const value of ["SIGTERM", "SIGKILL"]) {
|
|
29
|
+
signal(value);
|
|
30
|
+
const until = Date.now() + 1_000;
|
|
31
|
+
while (exists() && Date.now() < until)
|
|
32
|
+
await delay(20);
|
|
33
|
+
if (!exists())
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
throw new MachineEnvironmentCaptureError("shell_cleanup_failed");
|
|
37
|
+
}
|
|
38
|
+
export async function runMachineEnvironmentProcess(options) {
|
|
39
|
+
if (options.signal.aborted || Date.now() >= options.deadline)
|
|
40
|
+
throw new MachineEnvironmentCaptureError("shell_capture_timeout");
|
|
41
|
+
const collectFd = options.collectFd;
|
|
42
|
+
const stdio = Array.from({ length: Math.max(3, (collectFd ?? 2) + 1, (options.metadataFd ?? 2) + 1) }, () => "ignore");
|
|
43
|
+
if (collectFd !== undefined)
|
|
44
|
+
stdio[collectFd] = "pipe";
|
|
45
|
+
if (options.metadataFd !== undefined)
|
|
46
|
+
stdio[options.metadataFd] = "pipe";
|
|
47
|
+
const child = spawn(options.file, [...options.args], { cwd: options.cwd, env: options.env, detached: true, stdio });
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
const metadata = [];
|
|
51
|
+
let metadataSize = 0;
|
|
52
|
+
let timer;
|
|
53
|
+
let abort;
|
|
54
|
+
let cleanup;
|
|
55
|
+
const cleanupOnce = () => cleanup ??= child.pid === undefined ? Promise.resolve() : cleanupOwnedGroup(child.pid);
|
|
56
|
+
try {
|
|
57
|
+
return await new Promise((resolve, reject) => {
|
|
58
|
+
const fail = (code) => reject(new MachineEnvironmentCaptureError(code));
|
|
59
|
+
abort = () => fail("shell_capture_timeout");
|
|
60
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
61
|
+
timer = setTimeout(abort, Math.max(0, options.deadline - Date.now()));
|
|
62
|
+
const channel = collectFd === undefined ? undefined : child.stdio[collectFd];
|
|
63
|
+
channel?.on("data", (chunk) => {
|
|
64
|
+
size += chunk.length;
|
|
65
|
+
if (size > (options.maxBytes ?? MACHINE_ENVIRONMENT_MAX_BYTES))
|
|
66
|
+
fail("shell_capture_too_large");
|
|
67
|
+
else
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
channel?.on("error", () => fail("shell_capture_failed"));
|
|
71
|
+
const metadataChannel = options.metadataFd === undefined ? undefined : child.stdio[options.metadataFd];
|
|
72
|
+
metadataChannel?.on("data", (chunk) => {
|
|
73
|
+
metadataSize += chunk.length;
|
|
74
|
+
if (metadataSize > 16_384)
|
|
75
|
+
fail("shell_capture_too_large");
|
|
76
|
+
else
|
|
77
|
+
metadata.push(chunk);
|
|
78
|
+
});
|
|
79
|
+
metadataChannel?.on("error", () => fail("shell_capture_failed"));
|
|
80
|
+
child.once("error", () => fail("shell_unavailable"));
|
|
81
|
+
child.once("exit", () => {
|
|
82
|
+
// rc 的后台任务会继承 fd3;主进程退出即清理,不能等所有继承者自行 close。
|
|
83
|
+
void cleanupOnce().catch(() => fail("shell_cleanup_failed"));
|
|
84
|
+
});
|
|
85
|
+
child.once("close", (code) => {
|
|
86
|
+
if (code === 0) {
|
|
87
|
+
options.onMetadata?.(Buffer.concat(metadata));
|
|
88
|
+
resolve(Buffer.concat(chunks));
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
fail(options.exitErrorCode ?? "shell_capture_failed");
|
|
92
|
+
});
|
|
93
|
+
if (options.signal.aborted)
|
|
94
|
+
abort();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (timer)
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
if (abort)
|
|
101
|
+
options.signal.removeEventListener("abort", abort);
|
|
102
|
+
try {
|
|
103
|
+
await cleanupOnce();
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
for (const stream of child.stdio)
|
|
107
|
+
stream?.destroy();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|