@nowcrew/daemon 0.6.98-beta.6 → 0.6.98-beta.8
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 +29 -20
- package/dist/automatic-daemon-installer.js +15 -11
- package/dist/automatic-service-capture.js +27 -0
- package/dist/computer-main.js +0 -0
- package/dist/computer-service.js +30 -5
- package/dist/console.js +17 -2
- package/dist/execution-journal-lock.js +51 -2
- package/dist/execution-protocol.js +2 -0
- package/dist/execution-runner.js +19 -13
- package/dist/host-execution-coordinator.js +10 -0
- package/dist/local-executor.js +8 -1
- package/dist/machine-info.js +6 -1
- package/dist/machine-runtime-environment.js +1 -0
- package/dist/main.js +0 -0
- package/dist/managed-service-lifecycle.js +10 -0
- package/dist/marketplace-controller.js +21 -8
- package/dist/marketplace-maintenance-bridge.js +79 -0
- package/dist/marketplace-maintenance-protocol.js +40 -0
- package/dist/marketplace-serve.js +4 -2
- package/dist/project-skills/execution-projection.js +4 -5
- package/dist/project-skills/reconciler.js +8 -9
- package/dist/project-skills/scanner.js +9 -2
- package/dist/project-skills/source-directory.js +13 -0
- package/dist/runtime-observation.js +219 -0
- package/dist/runtime-tool-update-bridge.js +126 -0
- package/dist/runtime-tool-update-controller.js +151 -0
- package/dist/runtime-tool-update-protocol.js +24 -0
- package/dist/runtime-tool-update-serve.js +54 -0
- package/dist/runtimes/claude-observation-runner.js +55 -0
- package/dist/runtimes/claude-thread-runner.js +17 -3
- package/dist/runtimes/claude.js +5 -3
- package/dist/runtimes/codex-app-server-runner.js +16 -2
- package/dist/runtimes/deepseek-harness-runner.js +16 -0
- package/dist/runtimes/kimi-acp-runner.js +19 -0
- package/dist/runtimes/observation-context.js +18 -0
- package/dist/runtimes/observation-mapper.js +314 -0
- package/dist/runtimes/opencode-runner.js +18 -4
- package/dist/runtimes/pi-runner.js +18 -1
- package/dist/runtimes/pi.js +54 -20
- package/dist/serve.js +10 -1
- package/dist/token.js +4 -1
- package/package.json +9 -9
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { augmentedPath } from "./runtime-path.js";
|
|
7
|
+
import { MaintenanceReportSchema } from "./marketplace-maintenance-protocol.js";
|
|
8
|
+
export function maintenanceEnvironment(env) {
|
|
9
|
+
// Keep the machine user's login/config, never forward control-plane credentials.
|
|
10
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !/^(CREW_|NOWCREW_|NOWWORK_)/i.test(key)));
|
|
11
|
+
}
|
|
12
|
+
async function supervise(file, id, cwd, env) {
|
|
13
|
+
await new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(process.execPath, [file, "--run", id], { cwd, env, detached: true, stdio: "ignore" });
|
|
15
|
+
let expired = false;
|
|
16
|
+
const kill = () => { if (child.pid) {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(-child.pid, "SIGKILL");
|
|
19
|
+
}
|
|
20
|
+
catch { /* already gone */ }
|
|
21
|
+
} };
|
|
22
|
+
const timer = setTimeout(() => { expired = true; kill(); }, 18 * 60_000);
|
|
23
|
+
child.once("error", () => { clearTimeout(timer); kill(); reject(new Error("worker_failed")); });
|
|
24
|
+
child.once("close", code => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
// Never release the machine lock while descendants can still write shared configuration.
|
|
27
|
+
kill();
|
|
28
|
+
if (expired || code !== 0)
|
|
29
|
+
reject(new Error("worker_failed"));
|
|
30
|
+
else
|
|
31
|
+
resolve();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export async function runMaintenanceBridge(config, request, acquireEnv) {
|
|
36
|
+
const fail = (errorCode) => ({ version: 2, operationId: request.operationId, status: "failed", results: [], errorCode });
|
|
37
|
+
let dir;
|
|
38
|
+
let started = false;
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`${config.serverUrl.replace(/\/+$/, "")}/machine/marketplace-maintenance/${request.operationId}/runner/${request.plan.runner.releaseId}`, {
|
|
41
|
+
headers: { authorization: `Bearer ${config.machineToken}` }, redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
return fail("runner_unavailable");
|
|
45
|
+
// Bound even a malformed server response before buffering or executing it.
|
|
46
|
+
if (!response.body)
|
|
47
|
+
return fail("runner_unavailable");
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
for await (const chunk of response.body) {
|
|
51
|
+
bytes += chunk.length;
|
|
52
|
+
if (bytes > 256_000)
|
|
53
|
+
return fail("runner_unavailable");
|
|
54
|
+
chunks.push(chunk);
|
|
55
|
+
}
|
|
56
|
+
const content = Buffer.concat(chunks);
|
|
57
|
+
if (createHash("sha256").update(content).digest("hex") !== request.plan.runner.sha256)
|
|
58
|
+
return fail("runner_unavailable");
|
|
59
|
+
const env = maintenanceEnvironment(await acquireEnv());
|
|
60
|
+
env.PATH = augmentedPath(env, {}, process.platform, "append");
|
|
61
|
+
dir = await mkdtemp(join(tmpdir(), "nowwork-maintenance-"));
|
|
62
|
+
const file = join(dir, "runner.mjs");
|
|
63
|
+
await writeFile(file, content, { mode: 0o600 });
|
|
64
|
+
started = true;
|
|
65
|
+
await supervise(file, request.operationId, dir, env);
|
|
66
|
+
const report = MaintenanceReportSchema.parse(JSON.parse(await readFile(join(dir, "report.json"), "utf8")));
|
|
67
|
+
if (report.operationId !== request.operationId)
|
|
68
|
+
throw new Error("operation_mismatch");
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// A killed worker may have changed files: require readback/manual confirmation before another write.
|
|
73
|
+
return started ? { ...fail("operation_unknown"), status: "unknown" } : fail("runner_unavailable");
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (dir)
|
|
77
|
+
await rm(dir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// 与 daemon 同名文件逐字一致;这是独立于旧单插件协议的版本化契约。
|
|
3
|
+
export const MAINTENANCE_CAPABILITY = "marketplace_codex_maintenance_v1";
|
|
4
|
+
export const MaintenancePlanSchema = z.object({
|
|
5
|
+
marketplace: z.literal("nowwork-pm"),
|
|
6
|
+
actions: z.tuple([z.literal("install-missing"), z.literal("update-installed")]),
|
|
7
|
+
hosts: z.tuple([z.literal("claude"), z.literal("codex"), z.literal("pi")]),
|
|
8
|
+
runner: z.object({ releaseId: z.string().regex(/^[a-f0-9]{64}$/), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
|
9
|
+
}).strict();
|
|
10
|
+
export const MaintenanceRequestSchema = z.discriminatedUnion("action", [
|
|
11
|
+
z.object({ type: z.literal("marketplace:maintenance"), action: z.literal("start"), operationId: z.string().uuid(), plan: MaintenancePlanSchema }).strict(),
|
|
12
|
+
z.object({ type: z.literal("marketplace:maintenance"), action: z.literal("status"), operationId: z.string().uuid() }).strict(),
|
|
13
|
+
]);
|
|
14
|
+
export const MaintenanceErrorSchema = z.enum(["runtime_busy", "execution_failed", "operation_unknown", "runner_unavailable",
|
|
15
|
+
"manager_missing", "cli_missing", "command_failed", "verification_failed", "source_conflict", "unsupported_platform"]);
|
|
16
|
+
const Version = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._+-]{0,79}$/).nullable();
|
|
17
|
+
export const MaintenanceResultSchema = z.object({
|
|
18
|
+
runtime: z.enum(["claude", "codex", "pi"]), plugin: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,127}$/).nullable(),
|
|
19
|
+
status: z.enum(["installed", "updated", "unchanged", "empty", "missing_cli", "failed"]),
|
|
20
|
+
beforeVersion: Version, afterVersion: Version, targetVersion: Version,
|
|
21
|
+
activation: z.enum(["reload_required", "not_changed", "unverified"]), errorCode: MaintenanceErrorSchema.nullable(),
|
|
22
|
+
}).strict();
|
|
23
|
+
export const MaintenanceReportSchema = z.object({
|
|
24
|
+
version: z.literal(2), operationId: z.string().uuid(),
|
|
25
|
+
status: z.enum(["queued", "running", "completed", "partial", "failed", "unknown"]),
|
|
26
|
+
results: z.array(MaintenanceResultSchema).max(1500), errorCode: MaintenanceErrorSchema.nullable(),
|
|
27
|
+
}).strict().superRefine((r, ctx) => {
|
|
28
|
+
const keys = r.results.map(x => `${x.runtime}:${x.plugin}`);
|
|
29
|
+
if (new Set(keys).size !== keys.length)
|
|
30
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Duplicate plugin result" });
|
|
31
|
+
for (const x of r.results) {
|
|
32
|
+
if (["installed", "updated", "unchanged"].includes(x.status) && (!x.plugin || !x.targetVersion || x.targetVersion !== x.afterVersion || x.errorCode)) {
|
|
33
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Successful plugin result requires a verified target version" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (r.status === "completed" && (r.errorCode || ["claude", "codex", "pi"].some(host => !r.results.some(x => x.runtime === host))
|
|
37
|
+
|| r.results.some(x => x.errorCode || x.status === "failed" || x.status === "missing_cli"))) {
|
|
38
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Completion requires verified coverage of every host" });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { runMaintenanceBridge } from "./marketplace-maintenance-bridge.js";
|
|
1
2
|
import { createMarketplaceController } from "./marketplace-controller.js";
|
|
2
3
|
import { prepareDaemonRestart } from "./daemon-updater.js";
|
|
3
|
-
export function createServeMarketplace(config, localSlots, hostCoordinator) {
|
|
4
|
+
export function createServeMarketplace(config, localSlots, hostCoordinator, acquireEnv = async () => ({ ...process.env })) {
|
|
4
5
|
const controller = createMarketplaceController({ agentsRoot: config.agentsRoot,
|
|
6
|
+
maintain: request => runMaintenanceBridge(config, request, acquireEnv),
|
|
5
7
|
acquire: async () => {
|
|
6
8
|
// Marketplace updates can mutate shared Codex/Claude homes across profiles.
|
|
7
9
|
const local = await prepareDaemonRestart({ localSlots });
|
|
@@ -29,7 +31,7 @@ export function createServeMarketplace(config, localSlots, hostCoordinator) {
|
|
|
29
31
|
} });
|
|
30
32
|
return {
|
|
31
33
|
handle(raw, send) {
|
|
32
|
-
if (!raw || typeof raw !== "object" || !("type" in raw) || raw.type !== "marketplace:operation")
|
|
34
|
+
if (!raw || typeof raw !== "object" || !("type" in raw) || (raw.type !== "marketplace:operation" && raw.type !== "marketplace:maintenance"))
|
|
33
35
|
return false;
|
|
34
36
|
const { reqId, ...request } = raw;
|
|
35
37
|
if (typeof reqId === "string")
|
|
@@ -85,11 +85,10 @@ export async function materializeExecutionProjection(input) {
|
|
|
85
85
|
continue;
|
|
86
86
|
let projected;
|
|
87
87
|
try {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!sourceExists)
|
|
88
|
+
// 源路径已由 reconciler 解析;拒绝解析后消失、变成文件或被替换为链接的目录。
|
|
89
|
+
// POSIX symlink() 本身允许断链,不能用“创建链接成功”代替源目录检查。
|
|
90
|
+
const source = await lstat(record.sourcePath).catch(() => null);
|
|
91
|
+
if (!source?.isDirectory() || source.isSymbolicLink())
|
|
93
92
|
throw new ExecutionProjectionError("link_failed");
|
|
94
93
|
projected = await project(record.sourcePath, join(root.codexSkillRoot, record.skillName), platform);
|
|
95
94
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Skill 解析 + 执行编排。
|
|
3
|
-
* reconcile: server
|
|
3
|
+
* reconcile: server 同步绑定时只读取"哪些 skill 在本机可用",不写入投影。
|
|
4
4
|
* prepareAndLaunch: 为这一次 execution 建独占投影目录 → 启动 runtime → 进程树确认退出后删除。
|
|
5
5
|
* 没有共享目录,所以没有锁、租约、manifest、GC。ownership 未确认时目录保留,交给启动 sweep。
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
8
8
|
import { join, resolve, sep } from "node:path";
|
|
9
9
|
import { ExecutionProjectionError, materializeExecutionProjection, removeExecutionProjection, } from "./execution-projection.js";
|
|
10
10
|
import { projectSkillResolutionRecords } from "./scanner.js";
|
|
11
|
+
import { resolveProjectSkillDirectory } from "./source-directory.js";
|
|
11
12
|
import { compareProjectSkillRefs, } from "./types.js";
|
|
12
13
|
export class ProjectProjectionError extends Error {
|
|
13
14
|
code;
|
|
@@ -46,16 +47,14 @@ const canonicalConflictFreeBindings = (bindings) => {
|
|
|
46
47
|
}
|
|
47
48
|
return Object.freeze(unique);
|
|
48
49
|
};
|
|
49
|
-
/**
|
|
50
|
+
/** 每次从扫描入口解析真实目录,固定本次执行的链接目标;不可用则 warning,不阻断。 */
|
|
50
51
|
const actualResolutionRecords = async (bindings, projects, platform) => Object.freeze(await Promise.all(projectSkillResolutionRecords(bindings, projects, platform).map(async (record) => {
|
|
51
52
|
if (record.mode === "missing" || record.sourcePath === null)
|
|
52
53
|
return record;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (source?.isDirectory() === true && !source.isSymbolicLink() && skillFile?.isFile() === true) {
|
|
58
|
-
return record;
|
|
54
|
+
const directory = await resolveProjectSkillDirectory(record.sourcePath);
|
|
55
|
+
const skillFile = directory === null ? null : await stat(join(directory, "SKILL.md")).catch(() => null);
|
|
56
|
+
if (directory !== null && skillFile?.isFile() === true) {
|
|
57
|
+
return Object.freeze({ ...record, sourcePath: directory });
|
|
59
58
|
}
|
|
60
59
|
return Object.freeze({
|
|
61
60
|
projectId: record.projectId,
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { compareProjectSkillRefs, isProjectSkillName, MAX_MACHINE_PROJECT_SKILLS, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILL_FILE_BYTES, MAX_PROJECT_SKILLS_PER_PROJECT, } from "./types.js";
|
|
4
4
|
import { parseSkillFrontmatter } from "../skill-frontmatter.js";
|
|
5
5
|
import { normalizeProjectSkillResolutionRecords } from "./resolution-records.js";
|
|
6
|
+
import { resolveProjectSkillDirectory } from "./source-directory.js";
|
|
6
7
|
const unavailable = (projectId, scannedAt, errorCode) => Object.freeze({
|
|
7
8
|
inventory: Object.freeze({
|
|
8
9
|
projectId,
|
|
@@ -45,10 +46,15 @@ export async function scanProject(project, now = () => new Date()) {
|
|
|
45
46
|
let invalidSkillCount = 0;
|
|
46
47
|
const candidates = [];
|
|
47
48
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
48
|
-
if (entry.name.startsWith(".") || !entry.isDirectory())
|
|
49
|
+
if (entry.name.startsWith(".") || (!entry.isDirectory() && !entry.isSymbolicLink()))
|
|
49
50
|
continue;
|
|
50
51
|
const sourcePath = join(skillsRoot, entry.name);
|
|
51
|
-
const
|
|
52
|
+
const directory = await resolveProjectSkillDirectory(sourcePath);
|
|
53
|
+
if (directory === null) {
|
|
54
|
+
invalidSkillCount += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const skillPath = join(directory, "SKILL.md");
|
|
52
58
|
const skillStat = await stat(skillPath).catch(() => null);
|
|
53
59
|
if (!skillStat?.isFile() || skillStat.size > MAX_PROJECT_SKILL_FILE_BYTES) {
|
|
54
60
|
invalidSkillCount += 1;
|
|
@@ -70,6 +76,7 @@ export async function scanProject(project, now = () => new Date()) {
|
|
|
70
76
|
invalidSkillCount += 1;
|
|
71
77
|
continue;
|
|
72
78
|
}
|
|
79
|
+
// 保留入口:执行前重新解析,才能识别链接删除或改指。
|
|
73
80
|
candidates.push(Object.freeze({ name, description, sourcePath }));
|
|
74
81
|
}
|
|
75
82
|
const counts = new Map();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
/** 跟随用户放置的源链接;只在本次操作中使用真实目录,不把它缓存为扫描入口。 */
|
|
3
|
+
export async function resolveProjectSkillDirectory(sourcePath) {
|
|
4
|
+
try {
|
|
5
|
+
const directory = await realpath(sourcePath);
|
|
6
|
+
const info = await lstat(directory);
|
|
7
|
+
return info.isDirectory() && !info.isSymbolicLink() ? directory : null;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// 断链、循环或权限不足只使这个 Skill 不可用,不影响其它 Skill。
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const OBSERVABILITY_ENV = "NOWWORK_OBSERVABILITY_POLICY";
|
|
4
|
+
export const ObservabilityPolicySchema = z.object({
|
|
5
|
+
version: z.literal(1), captureId: z.string().min(1).max(128),
|
|
6
|
+
contentMode: z.enum(["metadata", "redacted"]),
|
|
7
|
+
}).strict();
|
|
8
|
+
const Id = z.string().min(1).max(200);
|
|
9
|
+
const Count = z.number().int().nonnegative().safe();
|
|
10
|
+
export const RuntimeObservationSchema = z.object({
|
|
11
|
+
kind: z.literal("agent_observation"), version: z.literal(1), captureId: z.string().min(1).max(128),
|
|
12
|
+
sourceEventIndex: z.number().int().positive().safe(),
|
|
13
|
+
runtime: z.enum(["codex", "claude", "kimi", "hermes", "deepseek-harness", "opencode", "pi"]),
|
|
14
|
+
observationId: Id, parentObservationId: Id.optional(), nativeSessionId: Id.optional(),
|
|
15
|
+
nativeTurnId: Id.optional(), nativeCallId: Id.optional(),
|
|
16
|
+
type: z.enum(["agent", "tool", "generation", "event", "span", "retriever"]),
|
|
17
|
+
phase: z.enum(["start", "end", "event"]), name: z.string().min(1).max(200),
|
|
18
|
+
observedAt: z.string().datetime({ offset: true }), status: z.enum(["ok", "error"]).optional(),
|
|
19
|
+
input: z.string().max(8192).optional(), output: z.string().max(8192).optional(), inputBytes: Count.optional(), outputBytes: Count.optional(), model: z.string().min(1).max(200).optional(),
|
|
20
|
+
usage: z.object({ inputTokens: Count.optional(), outputTokens: Count.optional(), cacheReadTokens: Count.optional(),
|
|
21
|
+
cacheCreationTokens: Count.optional(), reasoningTokens: Count.optional(), costUsd: z.number().finite().nonnegative().optional() }).strict().optional(),
|
|
22
|
+
usageScope: z.enum(["generation", "turn", "execution"]).optional(),
|
|
23
|
+
unavailable: z.array(z.string().min(1).max(120)).max(30).optional(), truncated: z.boolean().optional(),
|
|
24
|
+
expectedCount: z.number().int().nonnegative().safe().optional(), dropped: z.number().int().nonnegative().safe().optional(),
|
|
25
|
+
}).strict().superRefine((event, ctx) => {
|
|
26
|
+
if (event.name === "capture.summary") {
|
|
27
|
+
if (event.phase !== "event" || event.type !== "event" || event.expectedCount === undefined
|
|
28
|
+
|| event.sourceEventIndex !== event.expectedCount + 1)
|
|
29
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid capture summary" });
|
|
30
|
+
}
|
|
31
|
+
else if (event.expectedCount !== undefined || event.dropped !== undefined) {
|
|
32
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Only capture summary carries counts" });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
export function parseObservabilityPolicy(raw) {
|
|
36
|
+
const parsed = ObservabilityPolicySchema.safeParse(raw);
|
|
37
|
+
return parsed.success ? parsed.data : undefined;
|
|
38
|
+
}
|
|
39
|
+
export function observationPolicyFromEnvironment(env) {
|
|
40
|
+
try {
|
|
41
|
+
return parseObservabilityPolicy(JSON.parse(env[OBSERVABILITY_ENV] ?? "null"));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** 最后应用,清掉继承/用户配置中的同名变量,旧执行默认关闭。 */
|
|
48
|
+
export function observationEnvironment(env, policy) {
|
|
49
|
+
const clean = Object.fromEntries(Object.entries(env).filter(([key]) => key.toUpperCase() !== OBSERVABILITY_ENV));
|
|
50
|
+
const parsed = parseObservabilityPolicy(policy);
|
|
51
|
+
return parsed ? { ...clean, [OBSERVABILITY_ENV]: JSON.stringify(parsed) } : clean;
|
|
52
|
+
}
|
|
53
|
+
const REDACTED = "[REDACTED]";
|
|
54
|
+
// 与 Server 保持相同的边界规则;原 env/spec 不能先进入本地 journal 再由 Server 清洗。
|
|
55
|
+
const normalizeKey = (key) => key.toLowerCase().replace(/[^a-z0-9]/gu, "");
|
|
56
|
+
const forbiddenContainer = (key) => /^(?:env|envvars|runtimeenv|childenv|inheritedenv|environmentvariables|processenv|environmentplan|spec|executionspec|runtimeconfig|providerconfig)$/u.test(normalizeKey(key));
|
|
57
|
+
const sensitiveKey = (key) => /(?:apikey|accesskey(?:id)?|secretkey|secret|password|passwd|token|authorization|cookie|credential|privatekey|signature)$/u.test(normalizeKey(key))
|
|
58
|
+
|| /^(?:key|sig|session|auth|authkey|authstring)$/u.test(normalizeKey(key));
|
|
59
|
+
function clip(value, limit) {
|
|
60
|
+
const end = value.charCodeAt(limit - 1);
|
|
61
|
+
return value.slice(0, end >= 0xd800 && end <= 0xdbff ? limit - 1 : limit);
|
|
62
|
+
}
|
|
63
|
+
function cleanUrl(raw) {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(raw);
|
|
66
|
+
if (url.username || url.password) {
|
|
67
|
+
url.username = REDACTED;
|
|
68
|
+
url.password = "";
|
|
69
|
+
}
|
|
70
|
+
for (const name of [...url.searchParams.keys()]) {
|
|
71
|
+
if (sensitiveKey(name) || /^(?:code|ticket|sessionid|sas)$/iu.test(name) || /^(?:x-amz-|x-oss-|x-goog-)/iu.test(name))
|
|
72
|
+
url.searchParams.set(name, REDACTED);
|
|
73
|
+
}
|
|
74
|
+
if (url.hash && /(?:token|secret|password|credential|key|signature)/iu.test(url.hash))
|
|
75
|
+
url.hash = REDACTED;
|
|
76
|
+
return url.toString();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return REDACTED;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function redactObservationText(value) {
|
|
83
|
+
const urls = [];
|
|
84
|
+
const text = value.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`]+/giu, raw => {
|
|
85
|
+
const index = urls.push(cleanUrl(raw)) - 1;
|
|
86
|
+
return `__NOWWORK_TRACE_URL_${index}__`;
|
|
87
|
+
});
|
|
88
|
+
return text
|
|
89
|
+
.replace(/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/gu, REDACTED)
|
|
90
|
+
.replace(/\b(?:sk|pk)[-_]lf[-_][A-Za-z0-9_-]+\b/gu, REDACTED)
|
|
91
|
+
.replace(/\b(?:sk[-_][A-Za-z0-9_-]{6,}|gh[pousr]_[A-Za-z0-9_]{12,}|github_pat_[A-Za-z0-9_]{12,}|npm_[A-Za-z0-9]{12,})\b/gu, REDACTED)
|
|
92
|
+
.replace(/\b(authorization\s*[:=]\s*)(?:(?:bearer|basic)\s+)?[^\r\n,;]+/giu, `$1${REDACTED}`)
|
|
93
|
+
.replace(/((?:[A-Za-z0-9_]*(?:api[_-]?key|access[_-]?key|secret|password|passwd|token|cookie|credential|signature)|\b(?:key|session))\s*["']?\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)/giu, `$1${REDACTED}`)
|
|
94
|
+
.replace(/(\/\/[^\s"'`]+\/:_auth(?:token)?\s*=)\s*[^\s"'`]+/giu, `$1${REDACTED}`)
|
|
95
|
+
.replace(/__NOWWORK_TRACE_URL_(\d+)__/gu, (_match, index) => urls[Number(index)] ?? REDACTED);
|
|
96
|
+
}
|
|
97
|
+
function safeIdentity(value) {
|
|
98
|
+
// 含敏感值的 ID 用稳定别名,不能把不同调用都改成同一个 [REDACTED]。
|
|
99
|
+
return redactObservationText(value) === value ? value : `redacted-id:${createHash("sha256").update(value).digest("hex")}`;
|
|
100
|
+
}
|
|
101
|
+
function content(value) {
|
|
102
|
+
let truncated = false;
|
|
103
|
+
let remaining = 2000;
|
|
104
|
+
const visit = (item, depth) => {
|
|
105
|
+
if (--remaining < 0 || depth > 12) {
|
|
106
|
+
truncated = true;
|
|
107
|
+
return "[TRUNCATED]";
|
|
108
|
+
}
|
|
109
|
+
if (typeof item === "string") {
|
|
110
|
+
const text = redactObservationText(item);
|
|
111
|
+
if (text.length > 8192)
|
|
112
|
+
truncated = true;
|
|
113
|
+
return clip(text, 8192);
|
|
114
|
+
}
|
|
115
|
+
if (item === null || typeof item === "number" || typeof item === "boolean")
|
|
116
|
+
return item;
|
|
117
|
+
if (Array.isArray(item)) {
|
|
118
|
+
if (item.length > 100)
|
|
119
|
+
truncated = true;
|
|
120
|
+
return item.slice(0, 100).map(child => visit(child, depth + 1));
|
|
121
|
+
}
|
|
122
|
+
if (typeof item === "object") {
|
|
123
|
+
const entries = Object.entries(item);
|
|
124
|
+
if (entries.length > 100)
|
|
125
|
+
truncated = true;
|
|
126
|
+
return Object.fromEntries(entries.slice(0, 100).map(([key, child]) => [redactObservationText(key), forbiddenContainer(key) || sensitiveKey(key)
|
|
127
|
+
|| (normalizeKey(key) === "environment" && typeof child === "object") ? REDACTED : visit(child, depth + 1)]));
|
|
128
|
+
}
|
|
129
|
+
return String(item);
|
|
130
|
+
};
|
|
131
|
+
let structured = value;
|
|
132
|
+
if (typeof value === "string" && /^[\s]*[\[{]/u.test(value)) {
|
|
133
|
+
try {
|
|
134
|
+
structured = JSON.parse(value);
|
|
135
|
+
}
|
|
136
|
+
catch { /* 不是完整JSON则走文本清洗 */ }
|
|
137
|
+
}
|
|
138
|
+
const safe = visit(structured, 0);
|
|
139
|
+
const text = typeof safe === "string" ? safe : JSON.stringify(safe);
|
|
140
|
+
return { text: clip(text, 8192), truncated: truncated || text.length > 8192 };
|
|
141
|
+
}
|
|
142
|
+
/** 校验与本地内容策略在进入现有 telemetry journal 前再执行一次。 */
|
|
143
|
+
export function safeRuntimeObservation(raw, policy) {
|
|
144
|
+
if (!policy)
|
|
145
|
+
return undefined;
|
|
146
|
+
const result = RuntimeObservationSchema.safeParse(raw);
|
|
147
|
+
if (!result.success || result.data.captureId !== policy.captureId)
|
|
148
|
+
return undefined;
|
|
149
|
+
const { input, output, ...metadata } = result.data;
|
|
150
|
+
const cleanMetadata = { ...metadata, name: redactObservationText(metadata.name),
|
|
151
|
+
...(metadata.model ? { model: redactObservationText(metadata.model) } : {}),
|
|
152
|
+
...(metadata.unavailable ? { unavailable: metadata.unavailable.map(value => clip(redactObservationText(value), 120)) } : {}),
|
|
153
|
+
...Object.fromEntries(["observationId", "parentObservationId", "nativeSessionId", "nativeTurnId", "nativeCallId"]
|
|
154
|
+
.flatMap(key => metadata[key] === undefined ? [] : [[key, safeIdentity(metadata[key])]])),
|
|
155
|
+
};
|
|
156
|
+
const inputContent = policy.contentMode === "redacted" && input !== undefined ? content(input) : undefined;
|
|
157
|
+
const outputContent = policy.contentMode === "redacted" && output !== undefined ? content(output) : undefined;
|
|
158
|
+
const safe = policy.contentMode === "metadata" ? cleanMetadata : {
|
|
159
|
+
...cleanMetadata,
|
|
160
|
+
...(inputContent ? { input: inputContent.text } : {}),
|
|
161
|
+
...(outputContent ? { output: outputContent.text } : {}),
|
|
162
|
+
...(inputContent?.truncated || outputContent?.truncated ? { truncated: true } : {}),
|
|
163
|
+
};
|
|
164
|
+
const checked = RuntimeObservationSchema.safeParse(safe);
|
|
165
|
+
return checked.success && Buffer.byteLength(JSON.stringify(checked.data)) <= 24 * 1024 ? checked.data : undefined;
|
|
166
|
+
}
|
|
167
|
+
/** 仅短命 runner 使用;不持久化、不联网、不影响业务事件流。 */
|
|
168
|
+
export function createRuntimeObservationCapture(runtime, env = process.env, emit = event => { process.stdout.write(`${JSON.stringify(event)}\n`); }) {
|
|
169
|
+
const policy = observationPolicyFromEnvironment(env);
|
|
170
|
+
let index = 0;
|
|
171
|
+
let dropped = 0;
|
|
172
|
+
let finished = false;
|
|
173
|
+
const send = (fields, summary = false) => {
|
|
174
|
+
if (!policy || finished)
|
|
175
|
+
return;
|
|
176
|
+
if (!summary && index >= 20_000) {
|
|
177
|
+
dropped += 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const { input, output, ...metadata } = fields;
|
|
182
|
+
const bytes = (value) => Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value));
|
|
183
|
+
const inputText = policy.contentMode === "redacted" && input !== undefined ? content(input) : undefined;
|
|
184
|
+
const outputText = policy.contentMode === "redacted" && output !== undefined ? content(output) : undefined;
|
|
185
|
+
let candidate = {
|
|
186
|
+
...metadata, kind: "agent_observation", version: 1, captureId: policy.captureId,
|
|
187
|
+
runtime, sourceEventIndex: index + 1, observedAt: fields.observedAt ?? new Date().toISOString(),
|
|
188
|
+
...(input === undefined ? {} : { inputBytes: bytes(input) }), ...(output === undefined ? {} : { outputBytes: bytes(output) }),
|
|
189
|
+
...(inputText ? { input: inputText.text } : {}), ...(outputText ? { output: outputText.text } : {}),
|
|
190
|
+
...(inputText?.truncated || outputText?.truncated ? { truncated: true } : {}),
|
|
191
|
+
};
|
|
192
|
+
if (Buffer.byteLength(JSON.stringify(candidate)) > 24 * 1024) {
|
|
193
|
+
candidate = { ...candidate, ...(inputText ? { input: clip(inputText.text, 1500) } : {}),
|
|
194
|
+
...(outputText ? { output: clip(outputText.text, 1500) } : {}), truncated: true };
|
|
195
|
+
}
|
|
196
|
+
const safe = safeRuntimeObservation(candidate, policy);
|
|
197
|
+
if (!safe) {
|
|
198
|
+
dropped += 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
index += 1;
|
|
202
|
+
emit({ type: "nowwork.observation", observation: safe });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
dropped += 1; /* 诊断永远不改变 runtime 结果 */
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
enabled: policy !== undefined,
|
|
210
|
+
observe: fields => send(fields),
|
|
211
|
+
finish() {
|
|
212
|
+
if (finished)
|
|
213
|
+
return;
|
|
214
|
+
send({ observationId: "capture.summary", type: "event", phase: "event", name: "capture.summary",
|
|
215
|
+
expectedCount: index, dropped, ...(index === 0 ? { unavailable: ["runtime_events"] } : {}) }, true);
|
|
216
|
+
finished = true;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
|
|
7
|
+
export function runtimeToolEnvironment(env) {
|
|
8
|
+
// Keep only machine identity and lookup settings. Never pass runtime/control-plane credentials or Node injection flags.
|
|
9
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|LANG|LC_[A-Z_]+|XDG_CONFIG_HOME|XDG_CACHE_HOME)$/u.test(key)));
|
|
10
|
+
}
|
|
11
|
+
async function readReport(dir) {
|
|
12
|
+
const file = join(dir, "report.json");
|
|
13
|
+
if ((await stat(file)).size > 256_000)
|
|
14
|
+
throw new Error("invalid_report");
|
|
15
|
+
return RuntimeToolResponseSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
|
16
|
+
}
|
|
17
|
+
async function stopGroup(pid) {
|
|
18
|
+
if (!pid)
|
|
19
|
+
return true;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-pid, "SIGKILL");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code === "ESRCH";
|
|
25
|
+
}
|
|
26
|
+
const deadline = Date.now() + 3000;
|
|
27
|
+
while (Date.now() < deadline) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-pid, 0);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return error.code === "ESRCH";
|
|
33
|
+
}
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function createRuntimeToolProgressPoller(read, progress, intervalMs = 250) {
|
|
39
|
+
let active = true;
|
|
40
|
+
let pending = null;
|
|
41
|
+
const interval = setInterval(() => {
|
|
42
|
+
if (!active || pending || !progress)
|
|
43
|
+
return;
|
|
44
|
+
pending = read().then(async (data) => { if (active)
|
|
45
|
+
await progress(data); })
|
|
46
|
+
.catch(() => { }).finally(() => { pending = null; });
|
|
47
|
+
}, intervalMs);
|
|
48
|
+
return { async stop() { active = false; clearInterval(interval); await pending; } };
|
|
49
|
+
}
|
|
50
|
+
async function supervise(dir, env, start, progress, protectProcessGroup) {
|
|
51
|
+
const child = spawn(process.execPath, [join(dir, "runner.mjs"), "--managed-worker"], { cwd: dir, env, detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
|
|
52
|
+
const closed = new Promise(resolve => { child.once("error", () => resolve(null)); child.once("close", resolve); });
|
|
53
|
+
const polling = createRuntimeToolProgressPoller(() => readReport(dir), progress);
|
|
54
|
+
let expired = false;
|
|
55
|
+
const timer = setTimeout(() => { expired = true; void stopGroup(child.pid); }, start ? 12 * 60_000 : 120_000);
|
|
56
|
+
let launchFailed = false;
|
|
57
|
+
try {
|
|
58
|
+
if (start) {
|
|
59
|
+
if (!protectProcessGroup || !child.pid)
|
|
60
|
+
throw new Error("lease_protection_unavailable");
|
|
61
|
+
await protectProcessGroup(child.pid);
|
|
62
|
+
}
|
|
63
|
+
await new Promise((resolve, reject) => child.send({ type: "runtime-tools:go" }, error => error ? reject(error) : resolve()));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
launchFailed = true;
|
|
67
|
+
await stopGroup(child.pid);
|
|
68
|
+
}
|
|
69
|
+
const code = await closed;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
await polling.stop();
|
|
72
|
+
// A successful parent exit does not prove its package-manager descendants stopped.
|
|
73
|
+
const stopped = await stopGroup(child.pid);
|
|
74
|
+
return { stopped, success: stopped && !expired && !launchFailed && code === 0 };
|
|
75
|
+
}
|
|
76
|
+
export async function runRuntimeToolBridge(config, request, acquireEnv, progress, deps = {}) {
|
|
77
|
+
let dir;
|
|
78
|
+
let started = false;
|
|
79
|
+
let processStopped = true;
|
|
80
|
+
const failure = (errorCode) => ({ processStopped, data: request.action === "start" ? { operation: {
|
|
81
|
+
operationId: request.operationId, state: started ? "unknown" : "failed", currentIndex: 0, targets: request.targets,
|
|
82
|
+
items: request.targets.map(t => ({ runtime: t.installation.runtime, state: started ? "unknown" : "failed", errorCode })),
|
|
83
|
+
updatedAt: new Date().toISOString(), errorCode,
|
|
84
|
+
} } : {} });
|
|
85
|
+
try {
|
|
86
|
+
const response = await (deps.fetch ?? fetch)(`${config.serverUrl.replace(/\/+$/, "")}/machine/runtime-tool-updates/runner/${request.runner.releaseId}`, {
|
|
87
|
+
headers: { authorization: `Bearer ${config.machineToken}` }, redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok || !response.body)
|
|
90
|
+
return failure("runner_unavailable");
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
for await (const chunk of response.body) {
|
|
94
|
+
bytes += chunk.length;
|
|
95
|
+
if (bytes > 512_000)
|
|
96
|
+
return failure("runner_unavailable");
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
}
|
|
99
|
+
const content = Buffer.concat(chunks);
|
|
100
|
+
if (createHash("sha256").update(content).digest("hex") !== request.runner.sha256)
|
|
101
|
+
return failure("runner_unavailable");
|
|
102
|
+
const env = runtimeToolEnvironment(await acquireEnv());
|
|
103
|
+
dir = await mkdtemp(join(tmpdir(), "nowwork-runtime-update-"));
|
|
104
|
+
await writeFile(join(dir, "runner.mjs"), content, { mode: 0o600 });
|
|
105
|
+
await writeFile(join(dir, "request.json"), JSON.stringify(request), { mode: 0o600 });
|
|
106
|
+
started = true;
|
|
107
|
+
processStopped = false;
|
|
108
|
+
const outcome = await supervise(dir, env, request.action === "start", progress, deps.protectProcessGroup);
|
|
109
|
+
processStopped = outcome.stopped;
|
|
110
|
+
if (!outcome.success)
|
|
111
|
+
return failure("operation_unknown");
|
|
112
|
+
const data = await readReport(dir);
|
|
113
|
+
if (request.action === "start" && data.operation?.operationId !== request.operationId)
|
|
114
|
+
return failure("operation_unknown");
|
|
115
|
+
if (request.action === "inspect" && !data.snapshot)
|
|
116
|
+
return failure("inspection_failed");
|
|
117
|
+
return { data, processStopped };
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return failure(started ? "operation_unknown" : "runner_unavailable");
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (dir && processStopped)
|
|
124
|
+
await rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|