@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OURACA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @botlearn-course/daemon
|
|
2
|
+
|
|
3
|
+
BotLearn Course 的轻量本机 daemon:把课程任务(run)分发到你自己机器上的 agent runtime 执行
|
|
4
|
+
(BYOA,Bring Your Own Agent)。daemon 用 install code 绑定你的 BotLearn 账号,然后轮询
|
|
5
|
+
Course API 领取分配给本机的 run,在隔离工作区内调用本机已登录的 CLI runtime(Codex、
|
|
6
|
+
Claude Code、Gemini 等)完成任务,并把过程事件与产出文件候选回传给课程服务。
|
|
7
|
+
|
|
8
|
+
- 零 production 依赖,只用 Node 内建模块(Node >= 20)。
|
|
9
|
+
- 凭据只存本机,不经过第三方;runtime 复用你本机已有的登录状态。
|
|
10
|
+
|
|
11
|
+
## 安装与使用
|
|
12
|
+
|
|
13
|
+
无需安装,直接用 `npx`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 1. 环境自检:探测各 runtime 的安装 / 版本 / 登录状态
|
|
17
|
+
npx @botlearn-course/daemon@latest course doctor
|
|
18
|
+
|
|
19
|
+
# 2. 登录:用前端「我的 Daemon」页面生成的 install code 绑定本机
|
|
20
|
+
npx @botlearn-course/daemon@latest course login --api-url <course-api-url> --code <blic_xxx> [--label <名称>]
|
|
21
|
+
|
|
22
|
+
# 3. 启动:前台轮询并执行分配给本机的 run(Ctrl+C 优雅退出)
|
|
23
|
+
npx @botlearn-course/daemon@latest course start [--once] [--poll-interval-ms <ms>]
|
|
24
|
+
|
|
25
|
+
# 4. 登出:删除本机凭据文件
|
|
26
|
+
npx @botlearn-course/daemon@latest course logout
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
命令说明:
|
|
30
|
+
|
|
31
|
+
| 命令 | 说明 |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `course login` | 用 install code 换取 daemon 凭据并写入本机 `auth.json`。`--api-url` 缺省时读 `BOTLEARN_COURSE_API_URL`;`--label` 缺省为本机 hostname。成功后打印 daemon id。 |
|
|
34
|
+
| `course start` | 读取 `auth.json`,轮询 Course API 领取 run 并执行。`--once` 只领取一次(有 run 则等它执行完再退出);`--poll-interval-ms` 调整空轮询间隔。凭据过期/被吊销时提示重新 login 并退出。 |
|
|
35
|
+
| `course logout` | 删除本机 `auth.json`。服务端吊销请在前端 daemon 列表操作。 |
|
|
36
|
+
| `course doctor` | 打印 Auth 状态(不含 token)与 runtime 探测表(安装路径 / 版本 / 登录状态 / 安装提示)。 |
|
|
37
|
+
| `--help` / `--version` | 帮助与版本。 |
|
|
38
|
+
|
|
39
|
+
## 支持的 runtime
|
|
40
|
+
|
|
41
|
+
run 的 `runtime.id` 决定用哪个 adapter 执行;未指定时默认 `codex`。runtime 不可用时 run 会
|
|
42
|
+
直接失败(不做静默回退),请先用 `course doctor` 确认目标 runtime 可用。
|
|
43
|
+
|
|
44
|
+
| runtime id | 名称 | 依赖的本机命令 | 二进制覆盖变量 | 说明 |
|
|
45
|
+
| --- | --- | --- | --- | --- |
|
|
46
|
+
| `codex` | Codex CLI | `codex` | `BOTLEARN_CODEX_BIN` | 默认 runtime |
|
|
47
|
+
| `claude-code` | Claude Code | `claude` | `BOTLEARN_CLAUDE_BIN` | doctor 会额外探测登录状态 |
|
|
48
|
+
| `gemini` | Gemini CLI | `gemini` | `BOTLEARN_GEMINI_BIN` | |
|
|
49
|
+
| `kimi-cli` | Kimi CLI | `kimi` | `BOTLEARN_KIMI_CLI_BIN` | |
|
|
50
|
+
| `deepseek-tui` | DeepSeek TUI | `deepseek` | `BOTLEARN_DEEPSEEK_TUI_BIN` | |
|
|
51
|
+
| `hermes-agent` | Hermes Agent | `hermes-acp` | `BOTLEARN_HERMES_AGENT_BIN` | ACP stdio 协议 |
|
|
52
|
+
| `openclaw-acp` | OpenClaw (ACP) | `openclaw` | `BOTLEARN_OPENCLAW_BIN` | 还需配置 `BOTLEARN_OPENCLAW_URL`(可选 `BOTLEARN_OPENCLAW_TOKEN` / `BOTLEARN_OPENCLAW_AGENT`),二进制或 URL 缺失时 doctor 标记不可用 |
|
|
53
|
+
| `fake` | 假 runtime | 无 | — | 仅测试/演示用:产出确定性文本与一个 `draft.md`。默认隐藏,只有 `BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME=1` 时才注册并上报 |
|
|
54
|
+
|
|
55
|
+
各 runtime 的登录(API key / OAuth)沿用你本机已有的配置 —— daemon 不保存、不代管任何
|
|
56
|
+
runtime 凭据。
|
|
57
|
+
|
|
58
|
+
## 环境变量
|
|
59
|
+
|
|
60
|
+
| 变量 | 说明 |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `BOTLEARN_COURSE_API_URL` | `course login --api-url` 缺省值 |
|
|
63
|
+
| `BOTLEARN_DAEMON_HOME` | 覆盖本地状态根目录(默认 `~/.botlearn-course/daemon`;测试/多实例用) |
|
|
64
|
+
| `BOTLEARN_DAEMON_DEBUG` | 非空时输出 debug 级日志 |
|
|
65
|
+
| `BOTLEARN_<X>_BIN` | 覆盖某个 runtime 的二进制路径,各 runtime 的变量名见上表(如 `BOTLEARN_CLAUDE_BIN`),以 `course doctor` 输出为准 |
|
|
66
|
+
| `BOTLEARN_OPENCLAW_URL` | OpenClaw gateway 地址(`openclaw-acp` 必需) |
|
|
67
|
+
| `BOTLEARN_OPENCLAW_TOKEN` | OpenClaw gateway token(可选) |
|
|
68
|
+
| `BOTLEARN_OPENCLAW_AGENT` | OpenClaw agent 名称(可选) |
|
|
69
|
+
| `BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME` | 设为 `1` 时启用测试用 `fake` runtime |
|
|
70
|
+
|
|
71
|
+
## 本地文件布局与权限
|
|
72
|
+
|
|
73
|
+
所有状态位于 `~/.botlearn-course/daemon`(可用 `BOTLEARN_DAEMON_HOME` 覆盖):
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
~/.botlearn-course/daemon/ # 0700
|
|
77
|
+
auth.json # 0600,daemon 凭据(access/refresh token、courseApiUrl)
|
|
78
|
+
machine-id # 0600,首次运行生成的机器指纹
|
|
79
|
+
logs/ # 0700
|
|
80
|
+
daemon.log # 0600,按 10MiB 轮转,保留 20 个历史文件
|
|
81
|
+
workspaces/<agent_run_id>/ # 每个 run 一个隔离目录,0700
|
|
82
|
+
work/ # runtime 的工作目录(cwd),产出文件候选从这里扫描
|
|
83
|
+
transcript.jsonl # 本地执行记录(脱敏后写入)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 安全说明
|
|
87
|
+
|
|
88
|
+
- `auth.json` 是敏感凭据文件(0600),token 只存本机、只发给你 login 时指定的 Course API;
|
|
89
|
+
日志、transcript、doctor 输出、错误信息在写出前都会经过脱敏(token / api key / 密码等
|
|
90
|
+
字段与常见 token 形态一律替换为 `[REDACTED]`)。
|
|
91
|
+
- 文件扫描只访问 workspace 内路径;包含 `..`、绝对路径或反斜杠的路径会被拒绝。本机
|
|
92
|
+
BYOA daemon 上报相对路径、大小、sha256 和脱敏 preview;Agent Service sandbox 还会在
|
|
93
|
+
终态前把完整文件上传到 Course Service 的受保护存储。
|
|
94
|
+
- 每个 run 在独立 workspace 内执行并有超时上限;runtime 进程在取消/超时后会被终止
|
|
95
|
+
(SIGTERM → SIGKILL)。
|
|
96
|
+
- `course logout` 只删除本机凭据;要让服务端立即吊销该 daemon,请在前端 daemon 列表执行
|
|
97
|
+
revoke。
|
|
98
|
+
- 本包 production 依赖为零,发布 tarball 只包含 `dist/`、`README.md`、`package.json`、
|
|
99
|
+
`LICENSE`(CI 发布前强制校验)。
|
|
100
|
+
|
|
101
|
+
## 发布
|
|
102
|
+
|
|
103
|
+
在 GitHub Actions 中手动运行 `Publish Course Daemon`。`version` 留空时,CI 读取 npm 的
|
|
104
|
+
`latest` 版本并自动增加 patch;包尚不存在时首次发布为 `0.0.1`。只有需要指定
|
|
105
|
+
major、minor 或 prerelease 时才填写精确 SemVer(例如 `1.0.0` 或 `1.1.0-rc.1`)。
|
|
106
|
+
|
|
107
|
+
所有正式版发布到 npm dist-tag `latest`,prerelease 发布到 `next`。CI 会串行化发布并在
|
|
108
|
+
上传前确认目标版本尚不存在,避免并发任务重复发布同一个版本。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CourseRuntimeProfile, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
2
|
+
/** Run-scoped client used only inside an Agent Service sandbox. */
|
|
3
|
+
export declare class AgentServiceRunClient {
|
|
4
|
+
private readonly baseUrl;
|
|
5
|
+
private readonly agentRunId;
|
|
6
|
+
private readonly runToken;
|
|
7
|
+
private readonly workerId;
|
|
8
|
+
constructor(baseUrl: string, agentRunId: string, runToken: string, workerId: string);
|
|
9
|
+
private url;
|
|
10
|
+
private request;
|
|
11
|
+
getRun(): Promise<RunStartPayload>;
|
|
12
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<void>;
|
|
13
|
+
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
|
|
14
|
+
uploadFileContent(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
|
|
15
|
+
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
16
|
+
heartbeat(): Promise<void>;
|
|
17
|
+
private assertRunId;
|
|
18
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CourseClientError } from "./course-client.js";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { assertNoInjectedCredentials, redactSecretString, redactSecretsDeep, } from "./redaction.js";
|
|
4
|
+
const ERROR_BODY_MAX_CHARS = 500;
|
|
5
|
+
/** Run-scoped client used only inside an Agent Service sandbox. */
|
|
6
|
+
export class AgentServiceRunClient {
|
|
7
|
+
baseUrl;
|
|
8
|
+
agentRunId;
|
|
9
|
+
runToken;
|
|
10
|
+
workerId;
|
|
11
|
+
constructor(baseUrl, agentRunId, runToken, workerId) {
|
|
12
|
+
this.baseUrl = baseUrl;
|
|
13
|
+
this.agentRunId = agentRunId;
|
|
14
|
+
this.runToken = runToken;
|
|
15
|
+
this.workerId = workerId;
|
|
16
|
+
}
|
|
17
|
+
url(path) {
|
|
18
|
+
const base = this.baseUrl.replace(/\/+$/, "");
|
|
19
|
+
if (base.endsWith("/api/v1") && path.startsWith("/api/v1/")) {
|
|
20
|
+
return `${base}${path.slice("/api/v1".length)}`;
|
|
21
|
+
}
|
|
22
|
+
return `${base}${path}`;
|
|
23
|
+
}
|
|
24
|
+
async request(method, path, body) {
|
|
25
|
+
const response = await fetch(this.url(path), {
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
authorization: `Bearer ${this.runToken}`,
|
|
29
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
30
|
+
},
|
|
31
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const text = await response.text().catch(() => "");
|
|
35
|
+
throw new CourseClientError(response.status, `${method} ${path} -> ${response.status} ${redactSecretString(text.slice(0, ERROR_BODY_MAX_CHARS))}`);
|
|
36
|
+
}
|
|
37
|
+
const raw = await response.text();
|
|
38
|
+
return (raw ? JSON.parse(raw) : null);
|
|
39
|
+
}
|
|
40
|
+
async getRun() {
|
|
41
|
+
return this.request("GET", `/api/v1/agent-service/runs/${this.agentRunId}`);
|
|
42
|
+
}
|
|
43
|
+
async postEvent(agentRunId, event) {
|
|
44
|
+
this.assertRunId(agentRunId);
|
|
45
|
+
const sanitized = redactSecretsDeep(event, 8, [this.runToken]);
|
|
46
|
+
await this.request("POST", `/api/v1/agent-service/runs/${agentRunId}/events`, sanitized);
|
|
47
|
+
}
|
|
48
|
+
async postFile(agentRunId, file) {
|
|
49
|
+
this.assertRunId(agentRunId);
|
|
50
|
+
const sanitized = redactSecretsDeep(file, 8, [this.runToken]);
|
|
51
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
52
|
+
try {
|
|
53
|
+
return await this.request("POST", `/api/v1/agent-service/runs/${agentRunId}/files`, sanitized);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const retryable = !(err instanceof CourseClientError) || err.status === 429 || err.status >= 500;
|
|
57
|
+
if (!retryable || attempt === 2)
|
|
58
|
+
throw err;
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw new Error("unreachable");
|
|
63
|
+
}
|
|
64
|
+
async uploadFileContent(agentRunId, fileId, absPath, mimeType) {
|
|
65
|
+
this.assertRunId(agentRunId);
|
|
66
|
+
const data = await readFile(absPath);
|
|
67
|
+
assertNoInjectedCredentials(data, [this.runToken]);
|
|
68
|
+
const path = `/api/v1/agent-service/runs/${agentRunId}/files/${fileId}/content`;
|
|
69
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(this.url(path), {
|
|
73
|
+
method: "PUT",
|
|
74
|
+
headers: {
|
|
75
|
+
authorization: `Bearer ${this.runToken}`,
|
|
76
|
+
"content-type": mimeType ?? "application/octet-stream",
|
|
77
|
+
},
|
|
78
|
+
body: data,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (attempt === 2)
|
|
83
|
+
throw err;
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (response.ok)
|
|
88
|
+
return (await response.json());
|
|
89
|
+
const text = await response.text().catch(() => "");
|
|
90
|
+
const error = new CourseClientError(response.status, `PUT ${path} -> ${response.status} ${redactSecretString(text.slice(0, ERROR_BODY_MAX_CHARS))}`);
|
|
91
|
+
if (attempt === 2 || (response.status !== 429 && response.status < 500))
|
|
92
|
+
throw error;
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
94
|
+
}
|
|
95
|
+
throw new Error("unreachable");
|
|
96
|
+
}
|
|
97
|
+
async getRunRuntimeProfile(agentRunId) {
|
|
98
|
+
this.assertRunId(agentRunId);
|
|
99
|
+
return this.request("GET", `/api/v1/agent-service/runs/${agentRunId}/runtime-profile`);
|
|
100
|
+
}
|
|
101
|
+
async heartbeat() {
|
|
102
|
+
await this.request("POST", `/api/v1/agent-service/runs/${this.agentRunId}/heartbeat`, { worker_id: this.workerId });
|
|
103
|
+
}
|
|
104
|
+
assertRunId(value) {
|
|
105
|
+
if (value !== this.agentRunId)
|
|
106
|
+
throw new Error("Agent Service run client cannot cross runs");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DaemonAuth } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 本地身份与状态目录(spec §5)。
|
|
4
|
+
* 可用 BOTLEARN_DAEMON_HOME 覆盖根目录(测试 / 多实例)。
|
|
5
|
+
*/
|
|
6
|
+
export declare function daemonHome(): string;
|
|
7
|
+
export declare function ensureDaemonHome(): string;
|
|
8
|
+
export declare function authFilePath(): string;
|
|
9
|
+
export declare function writeAuth(auth: DaemonAuth): void;
|
|
10
|
+
export declare function readAuth(): DaemonAuth | null;
|
|
11
|
+
export declare function clearAuth(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* 稳定的本机指纹:首次生成 randomUUID 并持久化到 <home>/machine-id(0600),
|
|
14
|
+
* 之后跨进程 / 跨重启保持不变,供 login 上报。
|
|
15
|
+
*/
|
|
16
|
+
export declare function machineFingerprint(): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* 本地身份与状态目录(spec §5)。
|
|
7
|
+
* 可用 BOTLEARN_DAEMON_HOME 覆盖根目录(测试 / 多实例)。
|
|
8
|
+
*/
|
|
9
|
+
export function daemonHome() {
|
|
10
|
+
const override = process.env.BOTLEARN_DAEMON_HOME;
|
|
11
|
+
if (override && override.trim())
|
|
12
|
+
return override;
|
|
13
|
+
return path.join(homedir(), ".botlearn-course", "daemon");
|
|
14
|
+
}
|
|
15
|
+
export function ensureDaemonHome() {
|
|
16
|
+
const home = daemonHome();
|
|
17
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
18
|
+
// recursive mkdir 只对新建目录生效 mode,已存在目录需 best-effort 收紧。
|
|
19
|
+
try {
|
|
20
|
+
chmodSync(home, 0o700);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Windows 等不支持 chmod 时忽略。
|
|
24
|
+
}
|
|
25
|
+
return home;
|
|
26
|
+
}
|
|
27
|
+
export function authFilePath() {
|
|
28
|
+
return path.join(daemonHome(), "auth.json");
|
|
29
|
+
}
|
|
30
|
+
// 原子写:先写同目录 tmp 文件(0600)再 rename,读方永远看不到半写状态。
|
|
31
|
+
function writeFileAtomic(file, content) {
|
|
32
|
+
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
35
|
+
renameSync(tmp, file);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(tmp);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// tmp 可能尚未创建。
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
// writeFileSync 的 mode 受 umask 影响之外还可能被目标文件旧权限覆盖(rename 保留 tmp 权限,
|
|
47
|
+
// 但 Windows 无 chmod),这里再收紧一次。
|
|
48
|
+
try {
|
|
49
|
+
chmodSync(file, 0o600);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Windows 等不支持 chmod 时忽略。
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function writeAuth(auth) {
|
|
56
|
+
ensureDaemonHome();
|
|
57
|
+
writeFileAtomic(authFilePath(), JSON.stringify(auth, null, 2));
|
|
58
|
+
}
|
|
59
|
+
export function readAuth() {
|
|
60
|
+
const file = authFilePath();
|
|
61
|
+
if (!existsSync(file))
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
65
|
+
if (!parsed || typeof parsed !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// 损坏的 auth.json 视同未登录,由 CLI 引导重新 login。
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function clearAuth() {
|
|
75
|
+
const file = authFilePath();
|
|
76
|
+
if (!existsSync(file))
|
|
77
|
+
return false;
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(file);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 稳定的本机指纹:首次生成 randomUUID 并持久化到 <home>/machine-id(0600),
|
|
88
|
+
* 之后跨进程 / 跨重启保持不变,供 login 上报。
|
|
89
|
+
*/
|
|
90
|
+
export function machineFingerprint() {
|
|
91
|
+
ensureDaemonHome();
|
|
92
|
+
const file = path.join(daemonHome(), "machine-id");
|
|
93
|
+
if (existsSync(file)) {
|
|
94
|
+
try {
|
|
95
|
+
const value = readFileSync(file, "utf8").trim();
|
|
96
|
+
if (value)
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// 读失败则重新生成。
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const id = randomUUID();
|
|
104
|
+
writeFileAtomic(file, `${id}\n`);
|
|
105
|
+
return id;
|
|
106
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type Logger } from "./log.js";
|
|
3
|
+
import type { RunStartPayload } from "./types.js";
|
|
4
|
+
/** Keep the run-scoped Course credential out of runtime subprocess environments. */
|
|
5
|
+
export declare function clearAgentServiceControlEnv(env?: NodeJS.ProcessEnv): void;
|
|
6
|
+
export interface CliArgs {
|
|
7
|
+
positional: string[];
|
|
8
|
+
flags: Record<string, string | boolean>;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseCliArgs(argv: string[]): CliArgs;
|
|
11
|
+
export type PollLoopExit = "stopped" | "once" | "auth_failure";
|
|
12
|
+
export interface PollLoopDeps {
|
|
13
|
+
claimNextRun(): Promise<RunStartPayload | null>;
|
|
14
|
+
/** dispatch 的 Promise 由调用方保证不 reject(RunDispatcher 语义)。 */
|
|
15
|
+
dispatch(payload: RunStartPayload): Promise<void>;
|
|
16
|
+
isStopping(): boolean;
|
|
17
|
+
pollIntervalMs: number;
|
|
18
|
+
once?: boolean;
|
|
19
|
+
maxBackoffMs?: number;
|
|
20
|
+
sleep?: (ms: number) => Promise<void>;
|
|
21
|
+
log?: Logger;
|
|
22
|
+
}
|
|
23
|
+
export declare function runPollLoop(deps: PollLoopDeps): Promise<PollLoopExit>;
|
|
24
|
+
export declare function runCli(argv: string[]): Promise<number>;
|