@botlearn-course/daemon 0.0.20-beta.9 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/agent-service-sandbox.d.ts +7 -0
- package/dist/agent-service-sandbox.js +31 -226
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/redaction.js +2 -0
- package/dist/runtime-env.d.ts +1 -1
- package/dist/runtime-env.js +1 -1
- package/dist/runtimes/deepseek-tui.js +11 -0
- package/dist/sandbox-supervisor.js +1 -1
- package/dist/tool-observation.js +2 -0
- package/dist/workspace-filesystem.d.ts +0 -1
- package/dist/workspace-filesystem.js +0 -10
- package/dist/workspace-snapshot-control.js +0 -25
- package/dist/workspace-snapshot-policy.d.ts +1 -1
- package/dist/workspace-snapshot-policy.js +4 -2
- package/dist/workspace.d.ts +3 -3
- package/dist/workspace.js +10 -22
- package/package.json +1 -1
- package/dist/workspace-materialization.d.ts +0 -16
- package/dist/workspace-materialization.js +0 -136
- package/dist/workspace-quota.d.ts +0 -4
- package/dist/workspace-quota.js +0 -42
- package/dist/workspace-restore.d.ts +0 -42
- package/dist/workspace-restore.js +0 -347
package/README.md
CHANGED
|
@@ -36,22 +36,22 @@ npx --yes --package @botlearn-course/daemon@latest botlearn-course-daemon course
|
|
|
36
36
|
| `course doctor` | 打印 Auth 状态(不含 token)与 runtime 探测表(安装路径 / 版本 / 登录状态 / 安装提示)。 |
|
|
37
37
|
| `--help` / `--version` | 帮助与版本。 |
|
|
38
38
|
|
|
39
|
-
包内还包含供 BotLearn 托管
|
|
39
|
+
包内还包含供 BotLearn 托管 sandbox 使用的内部命令
|
|
40
40
|
`botlearn-sandbox-supervisor agent-service session`。它不是 BYOA 用户入口:生产环境只允许由
|
|
41
|
-
Agent Service 以固定 argv 启动,通过 stdin 接收一次性 bootstrap。
|
|
41
|
+
Agent Service 以固定 argv 启动,通过 stdin 接收一次性 bootstrap。sandbox 中 daemon/runtime 分别
|
|
42
42
|
降权到 `botlearn-control`/`user` UID;继承 `NoNewPrivs=1`、无法使用 sudo 的平台保留 root control
|
|
43
43
|
daemon,由 root-owned 固定 launcher 清空附加组后直接降权为 `user`。两条路径都为 DeepSeek
|
|
44
44
|
设置 `no_new_privs`,runtime 用户本身没有 sudo 权限。托管启动链只执行
|
|
45
|
-
`/opt` 下的固定 Node、supervisor、daemon、launcher 与 DeepSeek 文件,不信任
|
|
45
|
+
`/opt` 下的固定 Node、supervisor、daemon、launcher 与 DeepSeek 文件,不信任 provider 会开放给 runtime
|
|
46
46
|
写入的 `/usr/local/bin`。
|
|
47
47
|
`agent-service session --bootstrap-stdin` 同样属于受 supervisor 保护的内部协议,不应直接暴露给
|
|
48
48
|
课程任务或 workspace shell。
|
|
49
49
|
|
|
50
50
|
veFaaS Origin 的托管 workspace 使用用户级 NAS backing image。root bootstrap 完成 NFS、marker、
|
|
51
|
-
ext4
|
|
51
|
+
ext4、`fuse2fs` mount 和固定 3 GiB 校验后,daemon 才发送 `sandbox.ready`。RuntimeSession 使用稳定路径
|
|
52
52
|
`/workspace/sessions/{runtime_session_id}/workspace`,Sandbox generation 只参与写入隔离。磁盘写满
|
|
53
53
|
统一上报 `workspace_user_quota_exceeded`。pause 和 cleanup 进入 drain 后,daemon 先停止 runtime
|
|
54
|
-
writer,再通过 root launcher 执行 ext4 sync
|
|
54
|
+
writer,再通过 root launcher 执行 ext4 sync、`fuse2fs` unmount、backing image 和 NFS parent fsync;完成后
|
|
55
55
|
才发送 `sandbox.drained` 并退出。
|
|
56
56
|
|
|
57
57
|
## 支持的 runtime
|
|
@@ -3,6 +3,13 @@ import type { WorkspaceScanMeasurement } from "./file-candidates.js";
|
|
|
3
3
|
import { type RuntimeSkillProviderFactory } from "./runtime-skills.js";
|
|
4
4
|
import { type PersistentSessionExecution, type PreparedPersistentTurn, type RunReportingClient } from "./run-dispatcher.js";
|
|
5
5
|
import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunEventReceipt, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
6
|
+
export declare function workspaceDurabilityCapabilities(): string[];
|
|
7
|
+
/**
|
|
8
|
+
* NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
|
|
9
|
+
* 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
|
|
10
|
+
* 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
|
|
11
|
+
*/
|
|
12
|
+
export declare function assertWorkspaceRestoreAllowed(requestedRestore: unknown): void;
|
|
6
13
|
export interface AgentServiceSandboxOptions {
|
|
7
14
|
wsUrl: string;
|
|
8
15
|
sandboxId: string;
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { Readable } from "node:stream";
|
|
4
|
-
import { pipeline } from "node:stream/promises";
|
|
1
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
5
2
|
import path from "node:path";
|
|
6
3
|
import { ensureDaemonHome } from "./auth-store.js";
|
|
7
4
|
import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, createWorkspaceFileChunk, parseSandboxFrame, UnsupportedSandboxProtocolError, WORKSPACE_FILE_READ_BINARY_CAPABILITY, } from "./agent-service-ws-protocol.js";
|
|
@@ -16,12 +13,9 @@ import { readWorkspaceFile, WorkspaceFileReadError } from "./workspace-file-read
|
|
|
16
13
|
import { checkpointWorkspace, WorkspaceSnapshotControlError, } from "./workspace-snapshot-control.js";
|
|
17
14
|
import { assertWorkspaceWriterFreezeSupported, freezeRuntimeWriters, issueWorkspaceWriterFreezeProof, quiesceRuntimeWriters, thawRuntimeWriters, } from "./runtime-quiescence.js";
|
|
18
15
|
import { WorkspaceSnapshotStagingError } from "./workspace-snapshot-staging.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { readWorkspaceMaterializationMarker } from "./workspace-materialization.js";
|
|
23
|
-
import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, nasWorkspaceEnabled, } from "./workspace-filesystem.js";
|
|
24
|
-
import { applyRuntimeWorkspaceQuota } from "./workspace-quota.js";
|
|
16
|
+
import { WorkspaceEntrySetError, } from "./workspace-entry-set.js";
|
|
17
|
+
import { workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
|
|
18
|
+
import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, } from "./workspace-filesystem.js";
|
|
25
19
|
import { WebSocketClient, } from "./websocket-client.js";
|
|
26
20
|
const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
|
27
21
|
const MAX_SPOOL_FRAMES = 1024;
|
|
@@ -41,6 +35,23 @@ function agentServiceHttpBase(wsUrl) {
|
|
|
41
35
|
}
|
|
42
36
|
const RUNTIME_LOG_WINDOW_MS = 60_000;
|
|
43
37
|
const WORKSPACE_FILE_TRANSFER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
38
|
+
export function workspaceDurabilityCapabilities() {
|
|
39
|
+
return [
|
|
40
|
+
"workspace_writer_freeze_v1",
|
|
41
|
+
"workspace_snapshot_v2",
|
|
42
|
+
"workspace_nas_v1",
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
|
|
47
|
+
* 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
|
|
48
|
+
* 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
|
|
49
|
+
*/
|
|
50
|
+
export function assertWorkspaceRestoreAllowed(requestedRestore) {
|
|
51
|
+
if (requestedRestore !== null && requestedRestore !== undefined) {
|
|
52
|
+
throw new Error("workspace_restore_forbidden_for_nas");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
const DISABLED_RUNTIME_LOG_POLICY = {
|
|
45
56
|
enabled: false,
|
|
46
57
|
maxEventBytes: 4096,
|
|
@@ -836,7 +847,8 @@ export class AgentServiceSandboxClient {
|
|
|
836
847
|
this.rejectPending(new Error("Agent Service sandbox generation changed"));
|
|
837
848
|
for (const sessionId of Object.keys(this.state.sessions)) {
|
|
838
849
|
revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
|
|
839
|
-
|
|
850
|
+
// NAS image 跨 generation 保留正文,只清掉本地 daemon 侧的 session 状态。
|
|
851
|
+
removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, true);
|
|
840
852
|
}
|
|
841
853
|
this.state.sessions = {};
|
|
842
854
|
this.state.sandboxGeneration = frame.sandbox_generation;
|
|
@@ -872,10 +884,7 @@ export class AgentServiceSandboxClient {
|
|
|
872
884
|
protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
|
|
873
885
|
capabilities: [
|
|
874
886
|
WORKSPACE_FILE_READ_BINARY_CAPABILITY,
|
|
875
|
-
|
|
876
|
-
"workspace_snapshot_v2",
|
|
877
|
-
"workspace_restore_v1",
|
|
878
|
-
...(nasWorkspaceEnabled() ? ["workspace_nas_v1"] : []),
|
|
887
|
+
...workspaceDurabilityCapabilities(),
|
|
879
888
|
...(workspaceCopyV1Supported() ? ["workspace_copy_v1"] : []),
|
|
880
889
|
],
|
|
881
890
|
workspace_snapshot_policy: workspaceSnapshotPolicyCapability(),
|
|
@@ -1058,7 +1067,7 @@ export class AgentServiceSandboxClient {
|
|
|
1058
1067
|
const courseRunId = frame.payload.course_run_id;
|
|
1059
1068
|
const requestedWorkspaceRef = frame.payload.workspace_ref;
|
|
1060
1069
|
const requestedCopy = frame.payload.workspace_copy;
|
|
1061
|
-
|
|
1070
|
+
assertWorkspaceRestoreAllowed(frame.payload.workspace_restore);
|
|
1062
1071
|
const requestedCopyRecord = requestedCopy && typeof requestedCopy === "object"
|
|
1063
1072
|
? requestedCopy
|
|
1064
1073
|
: undefined;
|
|
@@ -1079,11 +1088,7 @@ export class AgentServiceSandboxClient {
|
|
|
1079
1088
|
typeof copyId !== "string" ||
|
|
1080
1089
|
copyId.length < 1 ||
|
|
1081
1090
|
typeof requestedCopySource !== "string" ||
|
|
1082
|
-
requestedCopySource.length < 1))
|
|
1083
|
-
|| (requestedRestore !== null && requestedRestore !== undefined &&
|
|
1084
|
-
(typeof requestedRestore !== "object" || Array.isArray(requestedRestore)))
|
|
1085
|
-
|| (requestedCopy !== null && requestedCopy !== undefined &&
|
|
1086
|
-
requestedRestore !== null && requestedRestore !== undefined)) {
|
|
1091
|
+
requestedCopySource.length < 1))) {
|
|
1087
1092
|
throw new Error("invalid session.open payload");
|
|
1088
1093
|
}
|
|
1089
1094
|
if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
|
|
@@ -1097,165 +1102,6 @@ export class AgentServiceSandboxClient {
|
|
|
1097
1102
|
const isNewSession = session.courseRunId === null;
|
|
1098
1103
|
let markerPath = null;
|
|
1099
1104
|
try {
|
|
1100
|
-
if (requestedRestore && typeof requestedRestore === "object") {
|
|
1101
|
-
const raw = requestedRestore;
|
|
1102
|
-
const descriptorRaw = raw.descriptor;
|
|
1103
|
-
const controlFence = raw.control_fence;
|
|
1104
|
-
let restoreControlToken = raw.control_token;
|
|
1105
|
-
if (!controlFence || typeof controlFence !== "object" || Array.isArray(controlFence)) {
|
|
1106
|
-
throw new Error("invalid workspace restore control fence");
|
|
1107
|
-
}
|
|
1108
|
-
if (typeof restoreControlToken !== "string" || restoreControlToken.length < 32) {
|
|
1109
|
-
throw new Error("invalid workspace restore control token");
|
|
1110
|
-
}
|
|
1111
|
-
if (!descriptorRaw || !Array.isArray(raw.entries)) {
|
|
1112
|
-
throw new Error("invalid workspace restore payload");
|
|
1113
|
-
}
|
|
1114
|
-
const descriptor = {
|
|
1115
|
-
sandboxId: this.options.sandboxId,
|
|
1116
|
-
sandboxGeneration: this.sandboxGeneration,
|
|
1117
|
-
runtimeSessionId: sessionId,
|
|
1118
|
-
continuityState: descriptorRaw.continuity_state,
|
|
1119
|
-
continuityErrorCode: descriptorRaw.continuity_error_code,
|
|
1120
|
-
snapshotId: descriptorRaw.snapshot_id,
|
|
1121
|
-
revision: Number(descriptorRaw.revision),
|
|
1122
|
-
contentSha256: descriptorRaw.content_sha256,
|
|
1123
|
-
expectedFileCount: Number(descriptorRaw.expected_file_count),
|
|
1124
|
-
expectedEntryCount: Number(descriptorRaw.expected_entry_count),
|
|
1125
|
-
expectedTotalBytes: Number(descriptorRaw.expected_total_bytes),
|
|
1126
|
-
};
|
|
1127
|
-
const workspaceDirectory = runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration);
|
|
1128
|
-
const requiredFreeBytes = Math.max(WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes, descriptor.expectedTotalBytes + WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes);
|
|
1129
|
-
// Fail before issuing or consuming any object grant. The download staging is a
|
|
1130
|
-
// sibling on this same bounded backing filesystem and is renamed, not copied,
|
|
1131
|
-
// into the atomic restore staging tree.
|
|
1132
|
-
assertWorkspaceRestoreCapacity({
|
|
1133
|
-
workspaceDirectory,
|
|
1134
|
-
descriptor,
|
|
1135
|
-
requiredFreeBytes,
|
|
1136
|
-
entryCopies: 2,
|
|
1137
|
-
});
|
|
1138
|
-
const entries = [];
|
|
1139
|
-
const predownloadRoot = path.join(path.dirname(workspaceDirectory), `.botlearn-predownload-${sessionId}-${randomUUID()}`);
|
|
1140
|
-
mkdirSync(predownloadRoot, { recursive: true, mode: 0o700 });
|
|
1141
|
-
try {
|
|
1142
|
-
let nextCursor = null;
|
|
1143
|
-
do {
|
|
1144
|
-
if (nextCursor !== null && nextCursor.length > 2048) {
|
|
1145
|
-
throw new Error("invalid workspace restore cursor");
|
|
1146
|
-
}
|
|
1147
|
-
const pageSignal = AbortSignal.timeout(60_000);
|
|
1148
|
-
const response = await fetch(`${agentServiceHttpBase(this.options.wsUrl)}/runtime-sessions/${sessionId}/restore-grants`, {
|
|
1149
|
-
method: "POST",
|
|
1150
|
-
headers: {
|
|
1151
|
-
Authorization: `Bearer ${restoreControlToken}`,
|
|
1152
|
-
"Content-Type": "application/json",
|
|
1153
|
-
},
|
|
1154
|
-
body: JSON.stringify({
|
|
1155
|
-
policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
|
|
1156
|
-
fence: controlFence,
|
|
1157
|
-
cursor: nextCursor,
|
|
1158
|
-
// Fetch one canonical entry at a time so a grant is consumed as soon as
|
|
1159
|
-
// it is signed, including deployments using the minimum 60-second TTL.
|
|
1160
|
-
max_entries: 1,
|
|
1161
|
-
}),
|
|
1162
|
-
signal: pageSignal,
|
|
1163
|
-
});
|
|
1164
|
-
if (!response.ok) {
|
|
1165
|
-
let code = "workspace_restore_unavailable";
|
|
1166
|
-
let retryable = response.status >= 500 || response.status === 408 ||
|
|
1167
|
-
response.status === 429;
|
|
1168
|
-
try {
|
|
1169
|
-
const rejected = await response.json();
|
|
1170
|
-
const detail = rejected.detail;
|
|
1171
|
-
if (detail && typeof detail === "object" && !Array.isArray(detail)) {
|
|
1172
|
-
const stableCode = detail.code;
|
|
1173
|
-
if (typeof stableCode === "string" && stableCode.length <= 120) {
|
|
1174
|
-
code = stableCode;
|
|
1175
|
-
}
|
|
1176
|
-
if (typeof detail.retryable === "boolean") {
|
|
1177
|
-
retryable = detail.retryable;
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
catch {
|
|
1182
|
-
// Content-free fallback retains the stable local failure code.
|
|
1183
|
-
}
|
|
1184
|
-
throw new WorkspaceRestoreError(code, retryable);
|
|
1185
|
-
}
|
|
1186
|
-
const page = await response.json();
|
|
1187
|
-
if (!Array.isArray(page.entries))
|
|
1188
|
-
throw new Error("invalid workspace restore page");
|
|
1189
|
-
if (typeof page.control_token !== "string" || page.control_token.length < 32) {
|
|
1190
|
-
throw new Error("invalid refreshed workspace restore control token");
|
|
1191
|
-
}
|
|
1192
|
-
restoreControlToken = page.control_token;
|
|
1193
|
-
const pageFiles = [];
|
|
1194
|
-
for (const value of page.entries) {
|
|
1195
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1196
|
-
throw new Error("invalid workspace restore entry");
|
|
1197
|
-
}
|
|
1198
|
-
const item = value;
|
|
1199
|
-
if (item.entry_type === "directory") {
|
|
1200
|
-
entries.push({ type: "directory", path: String(item.path), mode: "0700" });
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
if (item.entry_type !== "file" || !item.grant || typeof item.grant !== "object") {
|
|
1204
|
-
throw new Error("invalid workspace restore file grant");
|
|
1205
|
-
}
|
|
1206
|
-
const entry = {
|
|
1207
|
-
type: "file",
|
|
1208
|
-
path: String(item.path),
|
|
1209
|
-
mode: item.mode,
|
|
1210
|
-
sizeBytes: Number(item.size_bytes),
|
|
1211
|
-
sha256: String(item.sha256),
|
|
1212
|
-
};
|
|
1213
|
-
entries.push(entry);
|
|
1214
|
-
pageFiles.push({ entry, grant: item.grant });
|
|
1215
|
-
}
|
|
1216
|
-
// Validate all paths and duplicates before using any page path locally.
|
|
1217
|
-
validateWorkspaceEntrySet({ schemaVersion: "agent-workspace-entry-set/1", entries }, WORKSPACE_SNAPSHOT_POLICY_V1.limits);
|
|
1218
|
-
for (const { entry, grant } of pageFiles) {
|
|
1219
|
-
if (typeof grant.url !== "string")
|
|
1220
|
-
throw new Error("workspace restore grant missing");
|
|
1221
|
-
const destination = path.join(predownloadRoot, ...entry.path.split("/"));
|
|
1222
|
-
mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
1223
|
-
const part = `${destination}.part`;
|
|
1224
|
-
const downloadSignal = AbortSignal.timeout(60_000);
|
|
1225
|
-
const download = await fetch(grant.url, {
|
|
1226
|
-
method: "GET",
|
|
1227
|
-
headers: grant.headers,
|
|
1228
|
-
signal: downloadSignal,
|
|
1229
|
-
});
|
|
1230
|
-
if (!download.ok)
|
|
1231
|
-
throw new Error(`workspace_restore_download_${download.status}`);
|
|
1232
|
-
if (!download.body)
|
|
1233
|
-
throw new Error("workspace_restore_download_body_missing");
|
|
1234
|
-
await pipeline(Readable.fromWeb(download.body), createWriteStream(part, { mode: 0o600 }), { signal: downloadSignal });
|
|
1235
|
-
renameSync(part, destination);
|
|
1236
|
-
}
|
|
1237
|
-
const rawNext = page.next_cursor;
|
|
1238
|
-
if (rawNext !== null && rawNext !== undefined && typeof rawNext !== "string") {
|
|
1239
|
-
throw new Error("invalid workspace restore cursor");
|
|
1240
|
-
}
|
|
1241
|
-
nextCursor = rawNext ?? null;
|
|
1242
|
-
} while (nextCursor !== null);
|
|
1243
|
-
await restoreWorkspaceSnapshot({
|
|
1244
|
-
workspaceDirectory,
|
|
1245
|
-
controlDirectory: path.join(ensureDaemonHome(), "agent-service-sandboxes", this.options.sandboxId, "workspace-control"),
|
|
1246
|
-
descriptor,
|
|
1247
|
-
entrySet: { schemaVersion: "agent-workspace-entry-set/1", entries },
|
|
1248
|
-
limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
|
|
1249
|
-
requiredFreeBytes,
|
|
1250
|
-
downloadFile: async (entry, partPath) => {
|
|
1251
|
-
renameSync(path.join(predownloadRoot, ...entry.path.split("/")), partPath);
|
|
1252
|
-
},
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
|
-
finally {
|
|
1256
|
-
rmSync(predownloadRoot, { recursive: true, force: true });
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
1105
|
if (typeof requestedCopySource === "string" && typeof copyId === "string") {
|
|
1260
1106
|
if (session.workspaceCopyReceipt !== null) {
|
|
1261
1107
|
const receipt = session.workspaceCopyReceipt;
|
|
@@ -1299,14 +1145,6 @@ export class AgentServiceSandboxClient {
|
|
|
1299
1145
|
});
|
|
1300
1146
|
return;
|
|
1301
1147
|
}
|
|
1302
|
-
if (requestedRestore) {
|
|
1303
|
-
const restoreError = error instanceof WorkspaceRestoreError ? error : null;
|
|
1304
|
-
await this.sendSessionFrame("session.open_failed", sessionId, {
|
|
1305
|
-
error_code: restoreError?.code ?? "workspace_restore_unavailable",
|
|
1306
|
-
retryable: restoreError?.retryable ?? true,
|
|
1307
|
-
});
|
|
1308
|
-
return;
|
|
1309
|
-
}
|
|
1310
1148
|
throw error;
|
|
1311
1149
|
}
|
|
1312
1150
|
session.courseRunId = courseRunId;
|
|
@@ -1322,41 +1160,14 @@ export class AgentServiceSandboxClient {
|
|
|
1322
1160
|
if (markerPath !== null) {
|
|
1323
1161
|
await finalizeRuntimeSessionWorkspaceCopy(markerPath);
|
|
1324
1162
|
}
|
|
1325
|
-
try {
|
|
1326
|
-
if (!nasWorkspaceEnabled()) {
|
|
1327
|
-
await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
catch (error) {
|
|
1331
|
-
if (requestedRestore) {
|
|
1332
|
-
await this.sendSessionFrame("session.open_failed", sessionId, {
|
|
1333
|
-
error_code: "workspace_restore_unavailable",
|
|
1334
|
-
});
|
|
1335
|
-
return;
|
|
1336
|
-
}
|
|
1337
|
-
if (requestedCopy) {
|
|
1338
|
-
await this.sendSessionFrame("session.open_failed", sessionId, {
|
|
1339
|
-
error_code: "workspace_migration_io_failed",
|
|
1340
|
-
});
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
throw error;
|
|
1344
|
-
}
|
|
1345
1163
|
this.state.sessions[sessionId] = session;
|
|
1346
1164
|
this.persist();
|
|
1347
|
-
|
|
1165
|
+
// NAS 是唯一的 workspace 权威来源,所以 session.opened 不携带 snapshot 物化标记:
|
|
1166
|
+
// 挂载证明只说明当前 generation 已挂上 NAS,不要求运行目录等于某个 committed revision。
|
|
1348
1167
|
await this.sendSessionFrame("session.opened", sessionId, {
|
|
1349
1168
|
workspace_ref: session.workspaceRef,
|
|
1350
1169
|
native_session_id: session.nativeSessionId,
|
|
1351
|
-
|
|
1352
|
-
? {
|
|
1353
|
-
workspace_marker_version: 1,
|
|
1354
|
-
workspace_continuity_state: materialization.workspaceContinuityState,
|
|
1355
|
-
workspace_snapshot_id: materialization.snapshotId,
|
|
1356
|
-
workspace_revision: materialization.revision,
|
|
1357
|
-
workspace_content_sha256: materialization.contentSha256,
|
|
1358
|
-
}
|
|
1359
|
-
: {}),
|
|
1170
|
+
workspace_authority: "nas",
|
|
1360
1171
|
...(session.workspaceCopyReceipt !== null
|
|
1361
1172
|
? { workspace_copy_receipt: session.workspaceCopyReceipt }
|
|
1362
1173
|
: {}),
|
|
@@ -1420,17 +1231,11 @@ export class AgentServiceSandboxClient {
|
|
|
1420
1231
|
freezeProof,
|
|
1421
1232
|
});
|
|
1422
1233
|
checkpointCommitted = true;
|
|
1423
|
-
|
|
1424
|
-
if (!marker)
|
|
1425
|
-
throw new Error("workspace checkpoint marker missing");
|
|
1234
|
+
// committed revision 只是派生投影,不改变运行目录,所以这里不上报物化标记。
|
|
1426
1235
|
await this.sendSessionFrame("session.opened", sessionId, {
|
|
1427
1236
|
workspace_ref: session.workspaceRef,
|
|
1428
1237
|
native_session_id: session.nativeSessionId,
|
|
1429
|
-
|
|
1430
|
-
workspace_continuity_state: marker.workspaceContinuityState,
|
|
1431
|
-
workspace_snapshot_id: marker.snapshotId,
|
|
1432
|
-
workspace_revision: marker.revision,
|
|
1433
|
-
workspace_content_sha256: marker.contentSha256,
|
|
1238
|
+
workspace_authority: "nas",
|
|
1434
1239
|
});
|
|
1435
1240
|
await this.sendCommandAck(frame, "ok");
|
|
1436
1241
|
if (!lifecycleCheckpoint)
|
package/dist/index.d.ts
CHANGED
|
@@ -12,11 +12,9 @@ export * from "./run-dispatcher.js";
|
|
|
12
12
|
export * from "./run-queue.js";
|
|
13
13
|
export * from "./workspace.js";
|
|
14
14
|
export * from "./workspace-entry-set.js";
|
|
15
|
-
export * from "./workspace-materialization.js";
|
|
16
15
|
export * from "./workspace-snapshot-staging.js";
|
|
17
16
|
export * from "./workspace-snapshot-policy.js";
|
|
18
17
|
export * from "./workspace-snapshot-control.js";
|
|
19
|
-
export * from "./workspace-restore.js";
|
|
20
18
|
export * from "./transcript.js";
|
|
21
19
|
export * from "./file-candidates.js";
|
|
22
20
|
export * from "./doctor.js";
|
package/dist/index.js
CHANGED
|
@@ -12,11 +12,9 @@ export * from "./run-dispatcher.js";
|
|
|
12
12
|
export * from "./run-queue.js";
|
|
13
13
|
export * from "./workspace.js";
|
|
14
14
|
export * from "./workspace-entry-set.js";
|
|
15
|
-
export * from "./workspace-materialization.js";
|
|
16
15
|
export * from "./workspace-snapshot-staging.js";
|
|
17
16
|
export * from "./workspace-snapshot-policy.js";
|
|
18
17
|
export * from "./workspace-snapshot-control.js";
|
|
19
|
-
export * from "./workspace-restore.js";
|
|
20
18
|
export * from "./transcript.js";
|
|
21
19
|
export * from "./file-candidates.js";
|
|
22
20
|
export * from "./doctor.js";
|
package/dist/redaction.js
CHANGED
|
@@ -25,6 +25,7 @@ const QUOTED_JSON_SECRET_PATTERN = new RegExp(`(["'])(${SECRET_KEY_NAME_SOURCE})
|
|
|
25
25
|
const QUOTED_ARGV_SECRET_PATTERN = new RegExp(`(["'])(${SECRET_FLAG_SOURCE})\\1(\\s*,\\s*)(["'])([^"'\\\\]*(?:\\\\.[^"'\\\\]*)*)\\4`, "gi");
|
|
26
26
|
// blic_ 是课程 install code 前缀,与 runtime token 前缀一并脱敏。
|
|
27
27
|
const SECRET_PREFIX_PATTERN = /\b(blic_|drt_|dit_|gho_|ghp_|sk-)[A-Za-z0-9_-]+/g;
|
|
28
|
+
const SIGNED_OBJECT_URL_PATTERN = /https?:\/\/[^\s"'<>]*[?&](?:X-Amz-Algorithm|X-Amz-Credential|X-Amz-Signature|X-Amz-Security-Token)=[^\s"'<>]*/gi;
|
|
28
29
|
const SECRET_VALUE_PATTERNS = [
|
|
29
30
|
[/\b(Bearer\s+)[^\s"']+/gi, `$1${REDACTED}`],
|
|
30
31
|
[new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})=([^\\s"']+)`, "gi"), `$1$2=${REDACTED}`],
|
|
@@ -83,6 +84,7 @@ export function assertNoInjectedCredentials(data, additionalSecrets = []) {
|
|
|
83
84
|
}
|
|
84
85
|
export function redactSecretString(text, additionalSecrets = []) {
|
|
85
86
|
return redactInjectedCredentials(text, additionalSecrets)
|
|
87
|
+
.replace(SIGNED_OBJECT_URL_PATTERN, REDACTED)
|
|
86
88
|
.replace(/\b(Bearer\s+)[^\s"']+/gi, `$1${REDACTED}`)
|
|
87
89
|
.replace(new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})=([^\\s"']+)`, "gi"), `$1$2=${REDACTED}`)
|
|
88
90
|
.replace(new RegExp(`(^|[\\s])(${SECRET_FLAG_SOURCE})(\\s+)([^\\s"']+)`, "gi"), `$1$2$3${REDACTED}`)
|
package/dist/runtime-env.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare function runtimeChildIdentity(env?: NodeJS.ProcessEnv): {
|
|
|
16
16
|
/**
|
|
17
17
|
* Build the fixed control-to-runtime privilege boundary used by managed sandboxes.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* The legacy image boundary may grant botlearn-control one fixed sudoers command.
|
|
20
20
|
* Platforms that inherit no_new_privs cannot use sudo, so their root supervisor daemon
|
|
21
21
|
* invokes the same immutable launcher directly; the launcher clears supplementary groups
|
|
22
22
|
* and performs the one-way uid/gid drop. BYOA retains direct-spawn behavior.
|
package/dist/runtime-env.js
CHANGED
|
@@ -96,7 +96,7 @@ export function runtimeChildIdentity(env = process.env) {
|
|
|
96
96
|
/**
|
|
97
97
|
* Build the fixed control-to-runtime privilege boundary used by managed sandboxes.
|
|
98
98
|
*
|
|
99
|
-
*
|
|
99
|
+
* The legacy image boundary may grant botlearn-control one fixed sudoers command.
|
|
100
100
|
* Platforms that inherit no_new_privs cannot use sudo, so their root supervisor daemon
|
|
101
101
|
* invokes the same immutable launcher directly; the launcher clears supplementary groups
|
|
102
102
|
* and performs the one-way uid/gid drop. BYOA retains direct-spawn behavior.
|
|
@@ -1000,6 +1000,17 @@ function normalizeDeepseekEvent(eventName, payload, seq) {
|
|
|
1000
1000
|
status: deepseekToolFailed(payload) ? "error" : "completed",
|
|
1001
1001
|
};
|
|
1002
1002
|
}
|
|
1003
|
+
if (eventName === "item.delta" && isDeepseekReasoningEvent(eventName, payload)) {
|
|
1004
|
+
const text = extractDeepseekDelta(payload);
|
|
1005
|
+
return text
|
|
1006
|
+
? {
|
|
1007
|
+
raw: { event: eventName, payload },
|
|
1008
|
+
kind: "thinking",
|
|
1009
|
+
seq,
|
|
1010
|
+
text,
|
|
1011
|
+
}
|
|
1012
|
+
: null;
|
|
1013
|
+
}
|
|
1003
1014
|
if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
1004
1015
|
return {
|
|
1005
1016
|
raw: { event: eventName, payload },
|
|
@@ -19,7 +19,7 @@ const MANAGED_PATH = [
|
|
|
19
19
|
"/opt/node/22.23.1/bin",
|
|
20
20
|
"/usr/bin",
|
|
21
21
|
"/bin",
|
|
22
|
-
//
|
|
22
|
+
// The managed image exposes this directory as runtime-writable. Keep it last and never
|
|
23
23
|
// use it for a managed control-plane executable.
|
|
24
24
|
"/usr/local/bin",
|
|
25
25
|
].join(":");
|
package/dist/tool-observation.js
CHANGED
|
@@ -12,6 +12,7 @@ const CALL_FIELDS = new Set([
|
|
|
12
12
|
"arguments",
|
|
13
13
|
"args",
|
|
14
14
|
"input",
|
|
15
|
+
"detail",
|
|
15
16
|
"command",
|
|
16
17
|
"cmd",
|
|
17
18
|
"cwd",
|
|
@@ -27,6 +28,7 @@ const CALL_FIELDS = new Set([
|
|
|
27
28
|
const RESULT_FIELDS = new Set([
|
|
28
29
|
"result",
|
|
29
30
|
"output",
|
|
31
|
+
"detail",
|
|
30
32
|
"stdout",
|
|
31
33
|
"stderr",
|
|
32
34
|
"exit_code",
|
|
@@ -12,7 +12,6 @@ export interface WorkspaceCapacityProbes {
|
|
|
12
12
|
backingFileBytes(imagePath: string): bigint;
|
|
13
13
|
}
|
|
14
14
|
export declare function parseWorkspaceMountInfo(text: string): MountInfoEntry[];
|
|
15
|
-
export declare function nasWorkspaceEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
16
15
|
export declare function workspaceFilesystemFlushCommand(env?: NodeJS.ProcessEnv): [string, string[]] | null;
|
|
17
16
|
export declare function flushWorkspaceFilesystem(env?: NodeJS.ProcessEnv): Promise<void>;
|
|
18
17
|
export declare function isWorkspaceQuotaExceededError(error: unknown): boolean;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { readFileSync, statfsSync, statSync } from "node:fs";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
import { assertRuntimeWorkspaceQuota } from "./workspace-quota.js";
|
|
5
4
|
export const WORKSPACE_IMAGE_FORMAT = "botlearn-workspace-image/1";
|
|
6
5
|
export const WORKSPACE_IMAGE_LOGICAL_BYTES = 3 * 1024 * 1024 * 1024;
|
|
7
6
|
const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
|
|
@@ -35,12 +34,7 @@ export function parseWorkspaceMountInfo(text) {
|
|
|
35
34
|
};
|
|
36
35
|
});
|
|
37
36
|
}
|
|
38
|
-
export function nasWorkspaceEnabled(env = process.env) {
|
|
39
|
-
return env.BOTLEARN_WORKSPACE_IMAGE_FORMAT !== undefined;
|
|
40
|
-
}
|
|
41
37
|
export function workspaceFilesystemFlushCommand(env = process.env) {
|
|
42
|
-
if (!nasWorkspaceEnabled(env))
|
|
43
|
-
return null;
|
|
44
38
|
if (env.BOTLEARN_RUNTIME_LAUNCH_MODE === "direct-uid") {
|
|
45
39
|
return [RUNTIME_LAUNCHER, ["--workspace-filesystem-flush"]];
|
|
46
40
|
}
|
|
@@ -82,10 +76,6 @@ export function isWorkspaceQuotaExceededError(error) {
|
|
|
82
76
|
|| (candidate.cause !== undefined && isWorkspaceQuotaExceededError(candidate.cause));
|
|
83
77
|
}
|
|
84
78
|
export function assertWorkspaceFilesystem(workspaceRoot, env = process.env, mountInfo, capacityProbes) {
|
|
85
|
-
if (!nasWorkspaceEnabled(env)) {
|
|
86
|
-
assertRuntimeWorkspaceQuota(workspaceRoot);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
79
|
if (env.BOTLEARN_WORKSPACE_IMAGE_FORMAT !== WORKSPACE_IMAGE_FORMAT
|
|
90
80
|
|| env.BOTLEARN_WORKSPACE_LOGICAL_BYTES !== String(WORKSPACE_IMAGE_LOGICAL_BYTES)
|
|
91
81
|
|| !/^[0-9a-f]{64}$/u.test(env.BOTLEARN_WORKSPACE_SCOPE_DIGEST ?? "")) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createReadStream, rmSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ensureDaemonHome } from "./auth-store.js";
|
|
4
|
-
import { writeWorkspaceMaterializationMarker } from "./workspace-materialization.js";
|
|
5
4
|
import { WORKSPACE_SNAPSHOT_POLICY_V1 } from "./workspace-snapshot-policy.js";
|
|
6
5
|
import { stageWorkspaceSnapshot } from "./workspace-snapshot-staging.js";
|
|
7
6
|
export class WorkspaceSnapshotControlError extends Error {
|
|
@@ -95,19 +94,6 @@ export async function checkpointWorkspace(options) {
|
|
|
95
94
|
entry_set: entrySet,
|
|
96
95
|
}, signal);
|
|
97
96
|
if (proposal.unchanged) {
|
|
98
|
-
if (proposal.revision === 0 && proposal.snapshot_id === null) {
|
|
99
|
-
writeWorkspaceMaterializationMarker({
|
|
100
|
-
schemaVersion: "agent-workspace-materialization/1",
|
|
101
|
-
sandboxId: scope.sandboxId,
|
|
102
|
-
sandboxGeneration: scope.sandboxGeneration,
|
|
103
|
-
runtimeSessionId: scope.runtimeSessionId,
|
|
104
|
-
workspaceContinuityState: "healthy",
|
|
105
|
-
snapshotId: null,
|
|
106
|
-
revision: 0,
|
|
107
|
-
contentSha256: null,
|
|
108
|
-
writtenAt: new Date().toISOString(),
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
97
|
return {
|
|
112
98
|
revision: proposal.revision,
|
|
113
99
|
snapshotId: proposal.snapshot_id,
|
|
@@ -146,17 +132,6 @@ export async function checkpointWorkspace(options) {
|
|
|
146
132
|
committed.content_sha256 === null) {
|
|
147
133
|
throw new WorkspaceSnapshotControlError(committed.error_code || "workspace_snapshot_verification_failed", false);
|
|
148
134
|
}
|
|
149
|
-
writeWorkspaceMaterializationMarker({
|
|
150
|
-
schemaVersion: "agent-workspace-materialization/1",
|
|
151
|
-
sandboxId: scope.sandboxId,
|
|
152
|
-
sandboxGeneration: scope.sandboxGeneration,
|
|
153
|
-
runtimeSessionId: scope.runtimeSessionId,
|
|
154
|
-
workspaceContinuityState: "healthy",
|
|
155
|
-
snapshotId: committed.snapshot_id,
|
|
156
|
-
revision: committed.revision,
|
|
157
|
-
contentSha256: committed.content_sha256,
|
|
158
|
-
writtenAt: new Date().toISOString(),
|
|
159
|
-
});
|
|
160
135
|
return {
|
|
161
136
|
revision: committed.revision,
|
|
162
137
|
snapshotId: committed.snapshot_id,
|
|
@@ -20,5 +20,5 @@ export declare const WORKSPACE_SNAPSHOT_POLICY_V1: {
|
|
|
20
20
|
readonly checkpointReservationBytes: number;
|
|
21
21
|
readonly restorePageEntries: 25;
|
|
22
22
|
};
|
|
23
|
-
/** Wire representation validated exactly by Agent Service during the
|
|
23
|
+
/** Wire representation validated exactly by Agent Service during the handshake. */
|
|
24
24
|
export declare function workspaceSnapshotPolicyCapability(): Record<string, unknown>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
/** runtime 目录配额;与 Course Service 的 WorkspaceSnapshotPolicyV1 逐字段一致。 */
|
|
2
|
+
const RUNTIME_WORKSPACE_QUOTA_BYTES = 512 * 1024 * 1024;
|
|
3
|
+
const RUNTIME_WORKSPACE_INODE_QUOTA = 2_000;
|
|
2
4
|
/**
|
|
3
5
|
* Protocol limits frozen with Course Service's WorkspaceSnapshotPolicyV1.
|
|
4
6
|
* Deployment may enable the capability, but must never override these values.
|
|
@@ -21,7 +23,7 @@ export const WORKSPACE_SNAPSHOT_POLICY_V1 = {
|
|
|
21
23
|
checkpointReservationBytes: 512 * 1024 * 1024,
|
|
22
24
|
restorePageEntries: 25,
|
|
23
25
|
};
|
|
24
|
-
/** Wire representation validated exactly by Agent Service during the
|
|
26
|
+
/** Wire representation validated exactly by Agent Service during the handshake. */
|
|
25
27
|
export function workspaceSnapshotPolicyCapability() {
|
|
26
28
|
return {
|
|
27
29
|
policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
|
package/dist/workspace.d.ts
CHANGED
|
@@ -20,9 +20,9 @@ export declare function runtimeProfileDir(agentRunId: string): string;
|
|
|
20
20
|
export declare function runtimeProfileRunRootDir(agentRunId: string): string;
|
|
21
21
|
export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxGeneration: number): string;
|
|
22
22
|
/**
|
|
23
|
-
* per-session workspace(ADR-015 §7):
|
|
24
|
-
* `<root>/sessions/<runtime_session_id>/workspace
|
|
25
|
-
*
|
|
23
|
+
* per-session workspace(ADR-015 §7):managed sandbox 使用稳定路径
|
|
24
|
+
* `<root>/sessions/<runtime_session_id>/workspace`。该路径不含 generation,
|
|
25
|
+
* 因为 NAS image 跨 generation 重新挂载后必须解析到同一个目录。
|
|
26
26
|
*/
|
|
27
27
|
export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sandboxGeneration: number): string;
|
|
28
28
|
export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): string;
|