@botlearn-course/daemon 0.0.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/agent-service-client.d.ts +18 -0
- package/dist/agent-service-client.js +108 -0
- package/dist/auth-store.d.ts +16 -0
- package/dist/auth-store.js +106 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +354 -0
- package/dist/course-client.d.ts +46 -0
- package/dist/course-client.js +143 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +85 -0
- package/dist/file-candidates.d.ts +34 -0
- package/dist/file-candidates.js +173 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.js +154 -0
- package/dist/path-env.d.ts +8 -0
- package/dist/path-env.js +42 -0
- package/dist/redaction.d.ts +24 -0
- package/dist/redaction.js +158 -0
- package/dist/run-dispatcher.d.ts +43 -0
- package/dist/run-dispatcher.js +294 -0
- package/dist/run-queue.d.ts +11 -0
- package/dist/run-queue.js +26 -0
- package/dist/runtime-capabilities.d.ts +3 -0
- package/dist/runtime-capabilities.js +42 -0
- package/dist/runtime-profile.d.ts +8 -0
- package/dist/runtime-profile.js +213 -0
- package/dist/runtimes/acp-stream.d.ts +96 -0
- package/dist/runtimes/acp-stream.js +488 -0
- package/dist/runtimes/claude-code.d.ts +41 -0
- package/dist/runtimes/claude-code.js +353 -0
- package/dist/runtimes/codex.d.ts +44 -0
- package/dist/runtimes/codex.js +332 -0
- package/dist/runtimes/deepseek-tui.d.ts +50 -0
- package/dist/runtimes/deepseek-tui.js +701 -0
- package/dist/runtimes/engine.d.ts +52 -0
- package/dist/runtimes/engine.js +127 -0
- package/dist/runtimes/fake.d.ts +13 -0
- package/dist/runtimes/fake.js +45 -0
- package/dist/runtimes/gemini.d.ts +39 -0
- package/dist/runtimes/gemini.js +251 -0
- package/dist/runtimes/hermes-agent.d.ts +61 -0
- package/dist/runtimes/hermes-agent.js +173 -0
- package/dist/runtimes/index.d.ts +15 -0
- package/dist/runtimes/index.js +74 -0
- package/dist/runtimes/kimi.d.ts +35 -0
- package/dist/runtimes/kimi.js +335 -0
- package/dist/runtimes/ndjson-stream.d.ts +51 -0
- package/dist/runtimes/ndjson-stream.js +207 -0
- package/dist/runtimes/openclaw-acp.d.ts +52 -0
- package/dist/runtimes/openclaw-acp.js +872 -0
- package/dist/runtimes/probe.d.ts +17 -0
- package/dist/runtimes/probe.js +54 -0
- package/dist/runtimes/runtime-errors.d.ts +20 -0
- package/dist/runtimes/runtime-errors.js +95 -0
- package/dist/runtimes/text-cap.d.ts +7 -0
- package/dist/runtimes/text-cap.js +25 -0
- package/dist/transcript.d.ts +13 -0
- package/dist/transcript.js +46 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.js +17 -0
- package/dist/workspace.d.ts +23 -0
- package/dist/workspace.js +54 -0
- package/package.json +40 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { open, readdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { redactSecretString } from "./redaction.js";
|
|
6
|
+
const DEFAULT_MAX_FILES = 50;
|
|
7
|
+
// 与后端 settings.daemon_max_file_bytes 默认一致。
|
|
8
|
+
const DEFAULT_MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
9
|
+
// 与后端 MAX_PREVIEW_CHARS 一致。
|
|
10
|
+
const DEFAULT_MAX_PREVIEW_CHARS = 4000;
|
|
11
|
+
const DEFAULT_MAX_DEPTH = 8;
|
|
12
|
+
// 文本启发式:前 4KiB 不含 NUL 字节即视为文本,可取 preview。
|
|
13
|
+
const TEXT_SNIFF_BYTES = 4096;
|
|
14
|
+
// 与后端 _is_safe_relative_path 同规:非空、非绝对、无反斜杠、无 .. 段、无空段。
|
|
15
|
+
function isSafeRelativePath(rel) {
|
|
16
|
+
if (!rel || rel.startsWith("/") || rel.includes("\\"))
|
|
17
|
+
return false;
|
|
18
|
+
const segments = rel.split("/");
|
|
19
|
+
return segments.every((seg) => seg !== "" && seg !== "..");
|
|
20
|
+
}
|
|
21
|
+
async function sha256File(absPath) {
|
|
22
|
+
const hash = createHash("sha256");
|
|
23
|
+
const stream = createReadStream(absPath);
|
|
24
|
+
for await (const chunk of stream)
|
|
25
|
+
hash.update(chunk);
|
|
26
|
+
return hash.digest("hex");
|
|
27
|
+
}
|
|
28
|
+
async function readPreview(absPath, maxPreviewChars) {
|
|
29
|
+
const handle = await open(absPath, "r");
|
|
30
|
+
try {
|
|
31
|
+
const sniff = Buffer.alloc(TEXT_SNIFF_BYTES);
|
|
32
|
+
const { bytesRead } = await handle.read(sniff, 0, TEXT_SNIFF_BYTES, 0);
|
|
33
|
+
if (sniff.subarray(0, bytesRead).includes(0))
|
|
34
|
+
return null;
|
|
35
|
+
// 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
|
|
36
|
+
const want = maxPreviewChars * 4;
|
|
37
|
+
let buf = sniff.subarray(0, bytesRead);
|
|
38
|
+
if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
|
|
39
|
+
const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
|
|
40
|
+
const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
|
|
41
|
+
buf = Buffer.concat([buf, more.subarray(0, extra.bytesRead)]);
|
|
42
|
+
}
|
|
43
|
+
const text = buf.toString("utf8").replace(/�+$/, "");
|
|
44
|
+
return redactSecretString(text.slice(0, maxPreviewChars));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await handle.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 递归扫描 workspace 产出文件候选。
|
|
52
|
+
* 跳过:符号链接、隐藏项(`.` 开头)、node_modules、超深、超大、相对路径不安全的项。
|
|
53
|
+
* 达到 maxFiles 上限后停止并标记 truncated。
|
|
54
|
+
*/
|
|
55
|
+
export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
|
|
56
|
+
const maxFiles = limits.maxFiles ?? DEFAULT_MAX_FILES;
|
|
57
|
+
const maxFileBytes = limits.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
58
|
+
const maxPreviewChars = limits.maxPreviewChars ?? DEFAULT_MAX_PREVIEW_CHARS;
|
|
59
|
+
const maxDepth = limits.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
60
|
+
const files = [];
|
|
61
|
+
let truncated = false;
|
|
62
|
+
let stop = false;
|
|
63
|
+
async function walk(dir, depth) {
|
|
64
|
+
if (stop)
|
|
65
|
+
return;
|
|
66
|
+
if (depth > maxDepth) {
|
|
67
|
+
truncated = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (stop)
|
|
80
|
+
return;
|
|
81
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
82
|
+
continue;
|
|
83
|
+
if (entry.isSymbolicLink())
|
|
84
|
+
continue;
|
|
85
|
+
const absPath = path.join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
await walk(absPath, depth + 1);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!entry.isFile())
|
|
91
|
+
continue;
|
|
92
|
+
const rel = path.relative(workspaceDir, absPath).split(path.sep).join("/");
|
|
93
|
+
if (!isSafeRelativePath(rel))
|
|
94
|
+
continue;
|
|
95
|
+
let sizeBytes;
|
|
96
|
+
try {
|
|
97
|
+
const handle = await open(absPath, "r");
|
|
98
|
+
try {
|
|
99
|
+
sizeBytes = (await handle.stat()).size;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await handle.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (sizeBytes > maxFileBytes) {
|
|
109
|
+
truncated = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (files.length >= maxFiles) {
|
|
113
|
+
truncated = true;
|
|
114
|
+
stop = true;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let sha256;
|
|
118
|
+
let previewText;
|
|
119
|
+
try {
|
|
120
|
+
sha256 = await sha256File(absPath);
|
|
121
|
+
previewText = await readPreview(absPath, maxPreviewChars);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
truncated = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
files.push({
|
|
128
|
+
absPath,
|
|
129
|
+
event: "created",
|
|
130
|
+
path: rel,
|
|
131
|
+
name: entry.name,
|
|
132
|
+
size_bytes: sizeBytes,
|
|
133
|
+
sha256,
|
|
134
|
+
...(previewText !== null && previewText !== "" ? { preview_text: previewText } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await walk(workspaceDir, 0);
|
|
139
|
+
return { files, truncated };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 扫描并逐个上报文件候选。单文件上报失败只 warn 不中断;返回成功上报数。
|
|
143
|
+
*/
|
|
144
|
+
export async function reportFileCandidates(client, agentRunId, workspaceDir, log, limits) {
|
|
145
|
+
const { files, truncated } = await scanWorkspaceFiles(workspaceDir, limits);
|
|
146
|
+
if (truncated) {
|
|
147
|
+
log.warn("workspace file scan truncated", { agentRunId, reported: files.length });
|
|
148
|
+
}
|
|
149
|
+
let reported = 0;
|
|
150
|
+
let uploaded = 0;
|
|
151
|
+
let failed = 0;
|
|
152
|
+
for (const { absPath, ...candidate } of files) {
|
|
153
|
+
try {
|
|
154
|
+
const record = await client.postFile(agentRunId, candidate);
|
|
155
|
+
reported += 1;
|
|
156
|
+
if (client.uploadFileContent) {
|
|
157
|
+
if (!record)
|
|
158
|
+
throw new Error("file candidate response is missing its id");
|
|
159
|
+
await client.uploadFileContent(agentRunId, record.id, absPath, candidate.mime_type);
|
|
160
|
+
uploaded += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
failed += 1;
|
|
165
|
+
log.warn("file candidate report failed", {
|
|
166
|
+
agentRunId,
|
|
167
|
+
path: candidate.path,
|
|
168
|
+
error: err instanceof Error ? err.message : String(err),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { reported, uploaded, failed, truncated };
|
|
173
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 库导出面(spec §4):类型 + 各模块公共 API。
|
|
3
|
+
* CLI 入口在 cli.ts(bin),此处仅供程序化使用。
|
|
4
|
+
*/
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
export * from "./auth-store.js";
|
|
7
|
+
export * from "./course-client.js";
|
|
8
|
+
export * from "./agent-service-client.js";
|
|
9
|
+
export * from "./run-dispatcher.js";
|
|
10
|
+
export * from "./run-queue.js";
|
|
11
|
+
export * from "./workspace.js";
|
|
12
|
+
export * from "./transcript.js";
|
|
13
|
+
export * from "./file-candidates.js";
|
|
14
|
+
export * from "./doctor.js";
|
|
15
|
+
export * from "./log.js";
|
|
16
|
+
export * from "./redaction.js";
|
|
17
|
+
export * from "./runtime-profile.js";
|
|
18
|
+
export * from "./runtimes/index.js";
|
|
19
|
+
export * from "./runtimes/engine.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 库导出面(spec §4):类型 + 各模块公共 API。
|
|
3
|
+
* CLI 入口在 cli.ts(bin),此处仅供程序化使用。
|
|
4
|
+
*/
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
export * from "./auth-store.js";
|
|
7
|
+
export * from "./course-client.js";
|
|
8
|
+
export * from "./agent-service-client.js";
|
|
9
|
+
export * from "./run-dispatcher.js";
|
|
10
|
+
export * from "./run-queue.js";
|
|
11
|
+
export * from "./workspace.js";
|
|
12
|
+
export * from "./transcript.js";
|
|
13
|
+
export * from "./file-candidates.js";
|
|
14
|
+
export * from "./doctor.js";
|
|
15
|
+
export * from "./log.js";
|
|
16
|
+
export * from "./redaction.js";
|
|
17
|
+
export * from "./runtime-profile.js";
|
|
18
|
+
export * from "./runtimes/index.js";
|
|
19
|
+
export * from "./runtimes/engine.js";
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type Level = "info" | "warn" | "error" | "debug";
|
|
2
|
+
export interface Logger {
|
|
3
|
+
info(msg: string, fields?: Record<string, unknown>): void;
|
|
4
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
5
|
+
error(msg: string, fields?: Record<string, unknown>): void;
|
|
6
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
7
|
+
}
|
|
8
|
+
export interface LogFileEntry {
|
|
9
|
+
path: string;
|
|
10
|
+
name: string;
|
|
11
|
+
sizeBytes: number;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
active: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function formatLogLine(level: Level, msg: string, fields: Record<string, unknown> | undefined, date?: Date): string;
|
|
16
|
+
export declare function listDaemonLogFiles(logFile?: string): LogFileEntry[];
|
|
17
|
+
export declare function rotateLogIfNeeded(logFile?: string, nextBytes?: number, maxBytes?: number, keep?: number): void;
|
|
18
|
+
export declare const log: Logger;
|
|
19
|
+
export declare const LOG_FILE_PATH: string;
|
|
20
|
+
export {};
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { daemonHome } from "./auth-store.js";
|
|
4
|
+
import { redactSecretsDeep } from "./redaction.js";
|
|
5
|
+
const LOG_DIR = path.join(daemonHome(), "logs");
|
|
6
|
+
const LOG_FILE = path.join(LOG_DIR, "daemon.log");
|
|
7
|
+
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const LOG_ROTATE_KEEP = 20;
|
|
9
|
+
let inited = false;
|
|
10
|
+
function ensureDir() {
|
|
11
|
+
if (inited)
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// best-effort
|
|
18
|
+
}
|
|
19
|
+
inited = true;
|
|
20
|
+
}
|
|
21
|
+
function formatValue(value) {
|
|
22
|
+
if (value instanceof Error)
|
|
23
|
+
return JSON.stringify(value.stack ?? value.message);
|
|
24
|
+
if (typeof value === "string")
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null)
|
|
27
|
+
return String(value);
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return "undefined";
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return JSON.stringify(String(value));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function formatLogLine(level, msg, fields, date = new Date()) {
|
|
38
|
+
const detail = Object.entries(fields ?? {})
|
|
39
|
+
.map(([key, value]) => `${key}=${formatValue(value)}`)
|
|
40
|
+
.join(" ");
|
|
41
|
+
const prefix = `[${level.toUpperCase()}] ${msg}`;
|
|
42
|
+
const suffix = `ts=${date.toISOString()}`;
|
|
43
|
+
return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
|
|
44
|
+
}
|
|
45
|
+
function rotatedName(file, date = new Date()) {
|
|
46
|
+
const stamp = date.toISOString().replace(/[:.]/g, "-");
|
|
47
|
+
return `${file}.${stamp}.${process.pid}`;
|
|
48
|
+
}
|
|
49
|
+
export function listDaemonLogFiles(logFile = LOG_FILE) {
|
|
50
|
+
const dir = path.dirname(logFile);
|
|
51
|
+
const base = path.basename(logFile);
|
|
52
|
+
const entries = [];
|
|
53
|
+
try {
|
|
54
|
+
const st = statSync(logFile);
|
|
55
|
+
if (st.isFile()) {
|
|
56
|
+
entries.push({
|
|
57
|
+
path: logFile,
|
|
58
|
+
name: base,
|
|
59
|
+
sizeBytes: st.size,
|
|
60
|
+
mtimeMs: st.mtimeMs,
|
|
61
|
+
active: true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// no active log
|
|
67
|
+
}
|
|
68
|
+
let names = [];
|
|
69
|
+
try {
|
|
70
|
+
names = readdirSync(dir);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
if (!name.startsWith(`${base}.`))
|
|
77
|
+
continue;
|
|
78
|
+
const file = path.join(dir, name);
|
|
79
|
+
try {
|
|
80
|
+
const st = statSync(file);
|
|
81
|
+
if (!st.isFile())
|
|
82
|
+
continue;
|
|
83
|
+
entries.push({
|
|
84
|
+
path: file,
|
|
85
|
+
name,
|
|
86
|
+
sizeBytes: st.size,
|
|
87
|
+
mtimeMs: st.mtimeMs,
|
|
88
|
+
active: false,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// ignore disappearing files
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return entries.sort((a, b) => {
|
|
96
|
+
if (a.active !== b.active)
|
|
97
|
+
return a.active ? -1 : 1;
|
|
98
|
+
return b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function rotateLogIfNeeded(logFile = LOG_FILE, nextBytes = 0, maxBytes = LOG_ROTATE_MAX_BYTES, keep = LOG_ROTATE_KEEP) {
|
|
102
|
+
let currentSize = 0;
|
|
103
|
+
try {
|
|
104
|
+
const st = statSync(logFile);
|
|
105
|
+
if (!st.isFile())
|
|
106
|
+
return;
|
|
107
|
+
currentSize = st.size;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (currentSize + nextBytes <= maxBytes)
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
renameSync(logFile, rotatedName(logFile));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const rotated = listDaemonLogFiles(logFile).filter((entry) => !entry.active);
|
|
121
|
+
for (const entry of rotated.slice(Math.max(0, keep))) {
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(entry.path);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// best-effort cleanup
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function write(level, msg, fields) {
|
|
131
|
+
ensureDir();
|
|
132
|
+
// 所有 fields 值序列化前先深度脱敏,token 类值绝不落盘/上屏。
|
|
133
|
+
const safeFields = fields === undefined ? undefined : redactSecretsDeep(fields);
|
|
134
|
+
const line = formatLogLine(level, msg, safeFields);
|
|
135
|
+
try {
|
|
136
|
+
rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
|
|
137
|
+
appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// ignore log write errors
|
|
141
|
+
}
|
|
142
|
+
// 同步镜像到 stderr,前台运行时可直接观察。
|
|
143
|
+
process.stderr.write(line + "\n");
|
|
144
|
+
}
|
|
145
|
+
export const log = {
|
|
146
|
+
info: (msg, fields) => write("info", msg, fields),
|
|
147
|
+
warn: (msg, fields) => write("warn", msg, fields),
|
|
148
|
+
error: (msg, fields) => write("error", msg, fields),
|
|
149
|
+
debug: (msg, fields) => {
|
|
150
|
+
if (process.env.BOTLEARN_DAEMON_DEBUG)
|
|
151
|
+
write("debug", msg, fields);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
export const LOG_FILE_PATH = LOG_FILE;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function commonDaemonPathEntries(home?: string | undefined): string[];
|
|
2
|
+
export declare function mergePathEntries(basePath: string | undefined, extras: string[]): string;
|
|
3
|
+
/**
|
|
4
|
+
* GUI-launched macOS apps inherit a sparse launchd PATH and do not read the
|
|
5
|
+
* user's shell profile. Add common per-user CLI install locations so runtime
|
|
6
|
+
* adapters can find tools installed by uv/pipx, cargo, bun, npm, etc.
|
|
7
|
+
*/
|
|
8
|
+
export declare function augmentProcessPath(): void;
|
package/dist/path-env.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const COMMON_USER_BIN_RELATIVE_PATHS = [
|
|
3
|
+
".local/bin",
|
|
4
|
+
".cargo/bin",
|
|
5
|
+
".bun/bin",
|
|
6
|
+
".deno/bin",
|
|
7
|
+
".npm-global/bin",
|
|
8
|
+
".yarn/bin",
|
|
9
|
+
".pnpm",
|
|
10
|
+
".pyenv/shims",
|
|
11
|
+
".rye/shims",
|
|
12
|
+
".pixi/bin",
|
|
13
|
+
];
|
|
14
|
+
const COMMON_SYSTEM_BIN_PATHS = process.platform === "darwin"
|
|
15
|
+
? ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"]
|
|
16
|
+
: ["/usr/local/bin", "/usr/local/sbin"];
|
|
17
|
+
export function commonDaemonPathEntries(home = process.env.HOME) {
|
|
18
|
+
const userEntries = home
|
|
19
|
+
? COMMON_USER_BIN_RELATIVE_PATHS.map((entry) => path.join(home, entry))
|
|
20
|
+
: [];
|
|
21
|
+
return [...COMMON_SYSTEM_BIN_PATHS, ...userEntries];
|
|
22
|
+
}
|
|
23
|
+
export function mergePathEntries(basePath, extras) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const raw of [...(basePath ?? "").split(path.delimiter), ...extras]) {
|
|
27
|
+
const entry = raw.trim();
|
|
28
|
+
if (!entry || seen.has(entry))
|
|
29
|
+
continue;
|
|
30
|
+
seen.add(entry);
|
|
31
|
+
out.push(entry);
|
|
32
|
+
}
|
|
33
|
+
return out.join(path.delimiter);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* GUI-launched macOS apps inherit a sparse launchd PATH and do not read the
|
|
37
|
+
* user's shell profile. Add common per-user CLI install locations so runtime
|
|
38
|
+
* adapters can find tools installed by uv/pipx, cargo, bun, npm, etc.
|
|
39
|
+
*/
|
|
40
|
+
export function augmentProcessPath() {
|
|
41
|
+
process.env.PATH = mergePathEntries(process.env.PATH, commonDaemonPathEntries(process.env.HOME));
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const REDACTED = "[REDACTED]";
|
|
2
|
+
export declare const RUNTIME_FAILURE_TAIL_LIMIT: number;
|
|
3
|
+
/** key 命中即整值脱敏(深度脱敏用)。 */
|
|
4
|
+
export declare const SECRET_KEY_RE: RegExp;
|
|
5
|
+
/** Replace exact injected credentials and common encodings before persistence. */
|
|
6
|
+
export declare function redactInjectedCredentials(text: string, additionalSecrets?: readonly string[]): string;
|
|
7
|
+
/** Refuse raw workspace bytes that contain an injected credential or common encoding. */
|
|
8
|
+
export declare function assertNoInjectedCredentials(data: Buffer, additionalSecrets?: readonly string[]): void;
|
|
9
|
+
export declare function redactSecretString(text: string, additionalSecrets?: readonly string[]): string;
|
|
10
|
+
/**
|
|
11
|
+
* 深度脱敏:对象 key 命中 SECRET_KEY_RE → 整值替换;字符串走 redactSecretString;
|
|
12
|
+
* 递归数组/对象。超过 depth 的容器整体替换为 REDACTED(宁可截断不可泄露)。
|
|
13
|
+
*/
|
|
14
|
+
export declare function redactSecretsDeep(value: unknown, depth?: number, additionalSecrets?: readonly string[]): unknown;
|
|
15
|
+
/** runtime 失败文本脱敏 + 尾部截断(stderr/stdout tail、错误信息)。 */
|
|
16
|
+
export declare function sanitizeRuntimeFailureText(value: string, limit?: number): string;
|
|
17
|
+
export declare function tailText(value: string, limit: number): string;
|
|
18
|
+
/** argv 脱敏:逐段 sanitize(512 上限),secret-value flag 的下一个参数整体替换。 */
|
|
19
|
+
export declare function safeCommand(argv: string[]): string[];
|
|
20
|
+
export declare function errorInfo(err: unknown): {
|
|
21
|
+
error_name?: string;
|
|
22
|
+
error_message: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function truncateText(value: string, maxChars: number): string;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export const REDACTED = "[REDACTED]";
|
|
2
|
+
export const RUNTIME_FAILURE_TAIL_LIMIT = 8 * 1024;
|
|
3
|
+
const INJECTED_CREDENTIAL_ENV_NAMES = [
|
|
4
|
+
"BOTLEARN_AGENT_SERVICE_RUN_TOKEN",
|
|
5
|
+
"OPENAI_API_KEY",
|
|
6
|
+
"ANTHROPIC_API_KEY",
|
|
7
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
8
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
9
|
+
"DEEPSEEK_API_KEY",
|
|
10
|
+
"BOTLEARN_DEEPSEEK_TUI_TOKEN",
|
|
11
|
+
"GEMINI_API_KEY",
|
|
12
|
+
];
|
|
13
|
+
const MIN_EXACT_SECRET_CHARS = 8;
|
|
14
|
+
/** key 命中即整值脱敏(深度脱敏用)。 */
|
|
15
|
+
export const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password|credential/i;
|
|
16
|
+
const SECRET_KEY_NAME_SOURCE = "(?:openai[_-]?api[_-]?key|anthropic[_-]?api[_-]?key|x-api-key|access[_-]?token|refresh[_-]?token|api[_-]?key|apikey|password|secret|token)";
|
|
17
|
+
const SECRET_FLAG_SOURCE = "--(?:api-key|api_key|apikey|token|access-token|access_token|refresh-token|refresh_token|password|secret)";
|
|
18
|
+
const QUOTED_JSON_SECRET_PATTERN = new RegExp(`(["'])(${SECRET_KEY_NAME_SOURCE})\\1(\\s*:\\s*)(["'])([^"'\\\\]*(?:\\\\.[^"'\\\\]*)*)\\4`, "gi");
|
|
19
|
+
const QUOTED_ARGV_SECRET_PATTERN = new RegExp(`(["'])(${SECRET_FLAG_SOURCE})\\1(\\s*,\\s*)(["'])([^"'\\\\]*(?:\\\\.[^"'\\\\]*)*)\\4`, "gi");
|
|
20
|
+
// blic_ 是课程 install code 前缀,与 runtime token 前缀一并脱敏。
|
|
21
|
+
const SECRET_PREFIX_PATTERN = /\b(blic_|drt_|dit_|gho_|ghp_|sk-)[A-Za-z0-9_-]+/g;
|
|
22
|
+
const SECRET_VALUE_PATTERNS = [
|
|
23
|
+
[/\b(Bearer\s+)[^\s"']+/gi, `$1${REDACTED}`],
|
|
24
|
+
[new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})=([^\\s"']+)`, "gi"), `$1$2=${REDACTED}`],
|
|
25
|
+
[new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})(\\s+)([^\\s"']+)`, "gi"), `$1$2$3${REDACTED}`],
|
|
26
|
+
[
|
|
27
|
+
/\b((?:openai|anthropic)[_-]?api[_-]?key|x-api-key|access[_-]?token|refresh[_-]?token|api[_-]?key|apikey|password|secret|token)(\s*[:=]\s*)[^\s"']+/gi,
|
|
28
|
+
`$1$2${REDACTED}`,
|
|
29
|
+
],
|
|
30
|
+
[SECRET_PREFIX_PATTERN, `$1${REDACTED}`],
|
|
31
|
+
];
|
|
32
|
+
const SECRET_VALUE_FLAGS = new Set([
|
|
33
|
+
"--api-key",
|
|
34
|
+
"--api_key",
|
|
35
|
+
"--apikey",
|
|
36
|
+
"--token",
|
|
37
|
+
"--access-token",
|
|
38
|
+
"--access_token",
|
|
39
|
+
"--refresh-token",
|
|
40
|
+
"--refresh_token",
|
|
41
|
+
"--password",
|
|
42
|
+
"--secret",
|
|
43
|
+
]);
|
|
44
|
+
/** 轻量字符串脱敏:Bearer / token= / --token 形态 / 已知 token 前缀。 */
|
|
45
|
+
function injectedCredentialVariants(additionalSecrets = [], env = process.env) {
|
|
46
|
+
const values = [
|
|
47
|
+
...INJECTED_CREDENTIAL_ENV_NAMES.map((name) => env[name]),
|
|
48
|
+
...additionalSecrets,
|
|
49
|
+
].filter((value) => typeof value === "string" && value.length >= MIN_EXACT_SECRET_CHARS);
|
|
50
|
+
const variants = new Set();
|
|
51
|
+
for (const value of values) {
|
|
52
|
+
variants.add(value);
|
|
53
|
+
variants.add(Buffer.from(value, "utf8").toString("base64"));
|
|
54
|
+
variants.add(Buffer.from(value, "utf8").toString("base64url"));
|
|
55
|
+
variants.add(Buffer.from(value, "utf8").toString("hex"));
|
|
56
|
+
const encoded = encodeURIComponent(value);
|
|
57
|
+
if (encoded !== value)
|
|
58
|
+
variants.add(encoded);
|
|
59
|
+
}
|
|
60
|
+
return [...variants].sort((left, right) => right.length - left.length);
|
|
61
|
+
}
|
|
62
|
+
/** Replace exact injected credentials and common encodings before persistence. */
|
|
63
|
+
export function redactInjectedCredentials(text, additionalSecrets = []) {
|
|
64
|
+
let output = text;
|
|
65
|
+
for (const value of injectedCredentialVariants(additionalSecrets)) {
|
|
66
|
+
output = output.split(value).join(REDACTED);
|
|
67
|
+
}
|
|
68
|
+
return output;
|
|
69
|
+
}
|
|
70
|
+
/** Refuse raw workspace bytes that contain an injected credential or common encoding. */
|
|
71
|
+
export function assertNoInjectedCredentials(data, additionalSecrets = []) {
|
|
72
|
+
for (const value of injectedCredentialVariants(additionalSecrets)) {
|
|
73
|
+
if (data.includes(Buffer.from(value, "utf8"))) {
|
|
74
|
+
throw new Error("workspace file contains an injected credential");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function redactSecretString(text, additionalSecrets = []) {
|
|
79
|
+
return redactInjectedCredentials(text, additionalSecrets)
|
|
80
|
+
.replace(/\b(Bearer\s+)[^\s"']+/gi, `$1${REDACTED}`)
|
|
81
|
+
.replace(new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})=([^\\s"']+)`, "gi"), `$1$2=${REDACTED}`)
|
|
82
|
+
.replace(new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})(\\s+)([^\\s"']+)`, "gi"), `$1$2$3${REDACTED}`)
|
|
83
|
+
.replace(/\b(token=)[^\s"']+/gi, `$1${REDACTED}`)
|
|
84
|
+
.replace(SECRET_PREFIX_PATTERN, `$1${REDACTED}`);
|
|
85
|
+
}
|
|
86
|
+
const DEFAULT_REDACT_DEPTH = 8;
|
|
87
|
+
/**
|
|
88
|
+
* 深度脱敏:对象 key 命中 SECRET_KEY_RE → 整值替换;字符串走 redactSecretString;
|
|
89
|
+
* 递归数组/对象。超过 depth 的容器整体替换为 REDACTED(宁可截断不可泄露)。
|
|
90
|
+
*/
|
|
91
|
+
export function redactSecretsDeep(value, depth = DEFAULT_REDACT_DEPTH, additionalSecrets = []) {
|
|
92
|
+
if (typeof value === "string")
|
|
93
|
+
return redactSecretString(value, additionalSecrets);
|
|
94
|
+
if (value === null || typeof value !== "object")
|
|
95
|
+
return value;
|
|
96
|
+
if (value instanceof Error) {
|
|
97
|
+
// 保持 Error 实例:log 的 formatValue 依赖 instanceof Error 分支序列化 stack。
|
|
98
|
+
const copy = new Error(redactSecretString(value.message, additionalSecrets));
|
|
99
|
+
copy.name = value.name;
|
|
100
|
+
if (value.stack)
|
|
101
|
+
copy.stack = redactSecretString(value.stack, additionalSecrets);
|
|
102
|
+
return copy;
|
|
103
|
+
}
|
|
104
|
+
if (value instanceof Date)
|
|
105
|
+
return value;
|
|
106
|
+
if (depth <= 0)
|
|
107
|
+
return REDACTED;
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
return value.map((item) => redactSecretsDeep(item, depth - 1, additionalSecrets));
|
|
110
|
+
}
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const [key, v] of Object.entries(value)) {
|
|
113
|
+
out[key] = SECRET_KEY_RE.test(key)
|
|
114
|
+
? REDACTED
|
|
115
|
+
: redactSecretsDeep(v, depth - 1, additionalSecrets);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** runtime 失败文本脱敏 + 尾部截断(stderr/stdout tail、错误信息)。 */
|
|
120
|
+
export function sanitizeRuntimeFailureText(value, limit = RUNTIME_FAILURE_TAIL_LIMIT) {
|
|
121
|
+
let out = redactInjectedCredentials(value);
|
|
122
|
+
out = out.replace(QUOTED_JSON_SECRET_PATTERN, (_match, keyQuote, key, colon, valueQuote) => `${keyQuote}${key}${keyQuote}${colon}${valueQuote}${REDACTED}${valueQuote}`);
|
|
123
|
+
out = out.replace(QUOTED_ARGV_SECRET_PATTERN, (_match, flagQuote, flag, comma, valueQuote) => `${flagQuote}${flag}${flagQuote}${comma}${valueQuote}${REDACTED}${valueQuote}`);
|
|
124
|
+
for (const [pattern, replacement] of SECRET_VALUE_PATTERNS) {
|
|
125
|
+
out = out.replace(pattern, replacement);
|
|
126
|
+
}
|
|
127
|
+
return tailText(out, limit);
|
|
128
|
+
}
|
|
129
|
+
export function tailText(value, limit) {
|
|
130
|
+
return value.length > limit ? value.slice(-limit) : value;
|
|
131
|
+
}
|
|
132
|
+
/** argv 脱敏:逐段 sanitize(512 上限),secret-value flag 的下一个参数整体替换。 */
|
|
133
|
+
export function safeCommand(argv) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let i = 0; i < argv.length; i++) {
|
|
136
|
+
const part = argv[i];
|
|
137
|
+
out.push(sanitizeRuntimeFailureText(part, 512));
|
|
138
|
+
if (SECRET_VALUE_FLAGS.has(part.toLowerCase()) && i + 1 < argv.length) {
|
|
139
|
+
out.push(REDACTED);
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
export function errorInfo(err) {
|
|
146
|
+
if (err instanceof Error) {
|
|
147
|
+
return {
|
|
148
|
+
error_name: err.name || "Error",
|
|
149
|
+
error_message: sanitizeRuntimeFailureText(err.message, 2048),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { error_message: sanitizeRuntimeFailureText(String(err), 2048) };
|
|
153
|
+
}
|
|
154
|
+
export function truncateText(value, maxChars) {
|
|
155
|
+
if (value.length <= maxChars)
|
|
156
|
+
return value;
|
|
157
|
+
return `${value.slice(0, maxChars)}…(truncated)`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ScanLimits } from "./file-candidates.js";
|
|
2
|
+
import { type Logger } from "./log.js";
|
|
3
|
+
import { type CourseRuntimeProfile, type CourseRuntime, type RunEvent, type RunFileRecord, type RunStartPayload } from "./types.js";
|
|
4
|
+
export interface RunDispatcherOptions {
|
|
5
|
+
defaultRuntimeId?: string;
|
|
6
|
+
log?: Logger;
|
|
7
|
+
scanLimits?: ScanLimits;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
}
|
|
10
|
+
export interface RunReportingClient {
|
|
11
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<void>;
|
|
12
|
+
postFile(agentRunId: string, file: import("./types.js").RunFileCandidate): Promise<RunFileRecord | void>;
|
|
13
|
+
uploadFileContent?(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
|
|
14
|
+
getRunRuntimeProfile?(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run dispatcher:把 Course Service 下发的 run.start 交给 runtime,
|
|
18
|
+
* 并把 runtime 输出归一化成 run.block / run.message / run.completed 回报 Course Service。
|
|
19
|
+
*
|
|
20
|
+
* - 串行:同一 agent_instance 经 RunQueue 排队。
|
|
21
|
+
* - 取消:AbortController;runtime 抛错归一化为 run.failed。
|
|
22
|
+
* - 服务端 409(run 已终态)→ abort 并静默停止后续上报。
|
|
23
|
+
* - daemon 不推进进度,只汇报事实。
|
|
24
|
+
*/
|
|
25
|
+
export declare class RunDispatcher {
|
|
26
|
+
private readonly client;
|
|
27
|
+
private readonly runtimes;
|
|
28
|
+
private readonly queue;
|
|
29
|
+
private readonly inflight;
|
|
30
|
+
private readonly defaultRuntimeId;
|
|
31
|
+
private readonly log;
|
|
32
|
+
private readonly scanLimits?;
|
|
33
|
+
private readonly now;
|
|
34
|
+
constructor(client: RunReportingClient, runtimes: Map<string, CourseRuntime>, opts?: RunDispatcherOptions);
|
|
35
|
+
/** 排队执行一个 run。返回的 Promise 不 reject(失败已归一化回报为 run.failed)。 */
|
|
36
|
+
dispatch(payload: RunStartPayload): Promise<void>;
|
|
37
|
+
cancel(agentRunId: string): boolean;
|
|
38
|
+
cancelAll(): void;
|
|
39
|
+
get activeCount(): number;
|
|
40
|
+
/** 等待所有 run(含排队中的)结束;超时返回 false。 */
|
|
41
|
+
drain(timeoutMs: number): Promise<boolean>;
|
|
42
|
+
private execute;
|
|
43
|
+
}
|