@botlearn-course/daemon 0.0.20-beta.1 → 0.0.20-beta.3
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/dist/agent-service-sandbox.d.ts +6 -1
- package/dist/agent-service-sandbox.js +398 -28
- package/dist/agent-service-ws-protocol.d.ts +3 -3
- package/dist/agent-service-ws-protocol.js +5 -2
- package/dist/cli.js +19 -1
- package/dist/file-candidates.js +46 -39
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/run-dispatcher.d.ts +1 -1
- package/dist/run-dispatcher.js +1 -1
- package/dist/runtime-env.js +4 -4
- package/dist/runtime-quiescence.d.ts +19 -0
- package/dist/runtime-quiescence.js +110 -0
- package/dist/tool-observation.d.ts +6 -5
- package/dist/tool-observation.js +6 -5
- package/dist/trace-projection.d.ts +1 -1
- package/dist/trace-projection.js +1 -1
- package/dist/workspace-quota.d.ts +4 -0
- package/dist/workspace-quota.js +42 -0
- package/dist/workspace-restore.d.ts +9 -1
- package/dist/workspace-restore.js +30 -16
- package/dist/workspace-snapshot-control.d.ts +29 -0
- package/dist/workspace-snapshot-control.js +169 -0
- package/dist/workspace-snapshot-policy.d.ts +24 -0
- package/dist/workspace-snapshot-policy.js +45 -0
- package/dist/workspace-snapshot-staging.d.ts +2 -9
- package/dist/workspace-snapshot-staging.js +59 -25
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.
|
|
3
|
-
export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.
|
|
2
|
+
export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.4";
|
|
3
|
+
export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v4";
|
|
4
4
|
export const WORKSPACE_FILE_READ_BINARY_CAPABILITY = "workspace_file_read_binary_v1";
|
|
5
5
|
export const WORKSPACE_FILE_CHUNK_BYTES = 64 * 1024;
|
|
6
6
|
export const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
|
|
@@ -18,6 +18,8 @@ const FRAME_TYPES = new Set([
|
|
|
18
18
|
"event.ack",
|
|
19
19
|
"auth.rotate",
|
|
20
20
|
"workspace.file.read",
|
|
21
|
+
"workspace.checkpoint",
|
|
22
|
+
"workspace.thaw",
|
|
21
23
|
"ping",
|
|
22
24
|
"sandbox.ready",
|
|
23
25
|
"sandbox.heartbeat",
|
|
@@ -42,6 +44,7 @@ const SESSION_TYPES = new Set([
|
|
|
42
44
|
"session.open_failed",
|
|
43
45
|
"session.closed",
|
|
44
46
|
"workspace.file.read",
|
|
47
|
+
"workspace.checkpoint",
|
|
45
48
|
"workspace.file.result",
|
|
46
49
|
]);
|
|
47
50
|
/** TURN 帧:必须带 runtime_session_id + agent_run_id + worker_attempt + activation_id 四元组。 */
|
package/dist/cli.js
CHANGED
|
@@ -372,7 +372,25 @@ async function cmdAgentServiceSession(args) {
|
|
|
372
372
|
deepseekTuiVersion: process.env.BOTLEARN_MANAGED_DEEPSEEK_TUI_VERSION?.trim() || undefined,
|
|
373
373
|
});
|
|
374
374
|
clearAgentServiceControlEnv();
|
|
375
|
-
|
|
375
|
+
let stopping = false;
|
|
376
|
+
const stop = () => {
|
|
377
|
+
if (stopping)
|
|
378
|
+
return;
|
|
379
|
+
stopping = true;
|
|
380
|
+
void client.stopGracefully().catch((error) => {
|
|
381
|
+
console.error(error instanceof Error ? error.message : "sandbox shutdown failed");
|
|
382
|
+
client.stop();
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
process.once("SIGTERM", stop);
|
|
386
|
+
process.once("SIGINT", stop);
|
|
387
|
+
try {
|
|
388
|
+
await client.run();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
process.removeListener("SIGTERM", stop);
|
|
392
|
+
process.removeListener("SIGINT", stop);
|
|
393
|
+
}
|
|
376
394
|
return 0;
|
|
377
395
|
}
|
|
378
396
|
// ---------------------------------------------------------------
|
package/dist/file-candidates.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
3
|
import { open, readdir } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
@@ -20,30 +20,50 @@ function isSafeRelativePath(rel) {
|
|
|
20
20
|
const segments = rel.split("/");
|
|
21
21
|
return segments.every((seg) => seg !== "" && seg !== "..");
|
|
22
22
|
}
|
|
23
|
-
async function sha256File(
|
|
23
|
+
async function sha256File(handle) {
|
|
24
24
|
const hash = createHash("sha256");
|
|
25
|
-
const stream = createReadStream(
|
|
25
|
+
const stream = handle.createReadStream({ autoClose: false, start: 0 });
|
|
26
26
|
for await (const chunk of stream)
|
|
27
27
|
hash.update(chunk);
|
|
28
28
|
return hash.digest("hex");
|
|
29
29
|
}
|
|
30
|
-
async function readPreview(
|
|
31
|
-
const
|
|
30
|
+
async function readPreview(handle, maxPreviewChars) {
|
|
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
|
+
function sameFileVersion(left, right) {
|
|
47
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
|
|
48
|
+
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
49
|
+
}
|
|
50
|
+
async function scanStableFile(absPath, maxPreviewChars, maxFileBytes) {
|
|
51
|
+
const handle = await open(absPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
32
52
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return null;
|
|
37
|
-
// 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
|
|
38
|
-
const want = maxPreviewChars * 4;
|
|
39
|
-
let buf = sniff.subarray(0, bytesRead);
|
|
40
|
-
if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
|
|
41
|
-
const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
|
|
42
|
-
const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
|
|
43
|
-
buf = Buffer.concat([buf, more.subarray(0, extra.bytesRead)]);
|
|
53
|
+
const before = await handle.stat({ bigint: true });
|
|
54
|
+
if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
55
|
+
throw new Error("workspace_candidate_invalid_file");
|
|
44
56
|
}
|
|
45
|
-
const
|
|
46
|
-
|
|
57
|
+
const sizeBytes = Number(before.size);
|
|
58
|
+
if (sizeBytes > maxFileBytes)
|
|
59
|
+
return { sizeBytes, tooLarge: true };
|
|
60
|
+
const sha256 = await sha256File(handle);
|
|
61
|
+
const previewText = await readPreview(handle, maxPreviewChars);
|
|
62
|
+
const after = await handle.stat({ bigint: true });
|
|
63
|
+
if (!sameFileVersion(before, after)) {
|
|
64
|
+
throw new Error("workspace_candidate_changed_during_scan");
|
|
65
|
+
}
|
|
66
|
+
return { sizeBytes, tooLarge: false, sha256, previewText };
|
|
47
67
|
}
|
|
48
68
|
finally {
|
|
49
69
|
await handle.close();
|
|
@@ -92,23 +112,18 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
|
|
|
92
112
|
const rel = path.relative(workspaceDir, absPath).split(path.sep).join("/");
|
|
93
113
|
if (!isSafeRelativePath(rel))
|
|
94
114
|
continue;
|
|
95
|
-
let
|
|
115
|
+
let scanned;
|
|
96
116
|
try {
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
sizeBytes = (await handle.stat()).size;
|
|
100
|
-
}
|
|
101
|
-
finally {
|
|
102
|
-
await handle.close();
|
|
103
|
-
}
|
|
117
|
+
scanned = await scanStableFile(absPath, maxPreviewChars, maxFileBytes);
|
|
104
118
|
}
|
|
105
119
|
catch {
|
|
106
120
|
truncated = true;
|
|
107
121
|
continue;
|
|
108
122
|
}
|
|
123
|
+
const { sizeBytes } = scanned;
|
|
109
124
|
observedFileCount += 1;
|
|
110
125
|
observedTotalBytes += sizeBytes;
|
|
111
|
-
if (
|
|
126
|
+
if (scanned.tooLarge) {
|
|
112
127
|
truncated = true;
|
|
113
128
|
continue;
|
|
114
129
|
}
|
|
@@ -116,24 +131,16 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
|
|
|
116
131
|
truncated = true;
|
|
117
132
|
continue;
|
|
118
133
|
}
|
|
119
|
-
let sha256;
|
|
120
|
-
let previewText;
|
|
121
|
-
try {
|
|
122
|
-
sha256 = await sha256File(absPath);
|
|
123
|
-
previewText = await readPreview(absPath, maxPreviewChars);
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
truncated = true;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
134
|
files.push({
|
|
130
135
|
absPath,
|
|
131
136
|
event: "created",
|
|
132
137
|
path: rel,
|
|
133
138
|
name: entry.name,
|
|
134
139
|
size_bytes: sizeBytes,
|
|
135
|
-
sha256,
|
|
136
|
-
...(previewText !== null && previewText !== ""
|
|
140
|
+
sha256: scanned.sha256,
|
|
141
|
+
...(scanned.previewText !== null && scanned.previewText !== ""
|
|
142
|
+
? { preview_text: scanned.previewText }
|
|
143
|
+
: {}),
|
|
137
144
|
});
|
|
138
145
|
}
|
|
139
146
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export * from "./workspace.js";
|
|
|
14
14
|
export * from "./workspace-entry-set.js";
|
|
15
15
|
export * from "./workspace-materialization.js";
|
|
16
16
|
export * from "./workspace-snapshot-staging.js";
|
|
17
|
+
export * from "./workspace-snapshot-policy.js";
|
|
18
|
+
export * from "./workspace-snapshot-control.js";
|
|
17
19
|
export * from "./workspace-restore.js";
|
|
18
20
|
export * from "./transcript.js";
|
|
19
21
|
export * from "./file-candidates.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export * from "./workspace.js";
|
|
|
14
14
|
export * from "./workspace-entry-set.js";
|
|
15
15
|
export * from "./workspace-materialization.js";
|
|
16
16
|
export * from "./workspace-snapshot-staging.js";
|
|
17
|
+
export * from "./workspace-snapshot-policy.js";
|
|
18
|
+
export * from "./workspace-snapshot-control.js";
|
|
17
19
|
export * from "./workspace-restore.js";
|
|
18
20
|
export * from "./transcript.js";
|
|
19
21
|
export * from "./file-candidates.js";
|
package/dist/run-dispatcher.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface PreparedPersistentTurn {
|
|
|
23
23
|
export interface PersistentSessionExecution {
|
|
24
24
|
prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
|
|
25
25
|
persistNativeSession(sessionId: string): void;
|
|
26
|
-
finishTurn(payload: RunStartPayload): void
|
|
26
|
+
finishTurn(payload: RunStartPayload): Promise<void>;
|
|
27
27
|
}
|
|
28
28
|
export interface RunReportingClient {
|
|
29
29
|
postEvent(agentRunId: string, event: RunEvent): Promise<void | RunEventReceipt>;
|
package/dist/run-dispatcher.js
CHANGED
package/dist/runtime-env.js
CHANGED
|
@@ -42,11 +42,15 @@ export function clearAgentServiceControlEnv(env = process.env) {
|
|
|
42
42
|
export function runtimeChildEnv(env = process.env) {
|
|
43
43
|
const childEnv = { ...env };
|
|
44
44
|
const runtimeHome = childEnv.BOTLEARN_RUNTIME_HOME;
|
|
45
|
+
const activationId = childEnv.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID;
|
|
45
46
|
clearAgentServiceControlEnv(childEnv);
|
|
46
47
|
for (const key of AGENT_SERVICE_SUPERVISOR_ENV_KEYS)
|
|
47
48
|
delete childEnv[key];
|
|
48
49
|
if (runtimeHome)
|
|
49
50
|
childEnv.HOME = runtimeHome;
|
|
51
|
+
if (activationId && /^[A-Za-z0-9_-]{1,120}$/u.test(activationId)) {
|
|
52
|
+
childEnv.BOTLEARN_RUNTIME_ACTIVATION_SCOPE = activationId;
|
|
53
|
+
}
|
|
50
54
|
return childEnv;
|
|
51
55
|
}
|
|
52
56
|
/**
|
|
@@ -146,10 +150,6 @@ export function runtimeChildLaunch(binary, args, env = process.env, options = {}
|
|
|
146
150
|
"-n",
|
|
147
151
|
"-H",
|
|
148
152
|
"-E",
|
|
149
|
-
"-u",
|
|
150
|
-
runtimeUser,
|
|
151
|
-
"-g",
|
|
152
|
-
runtimeGroup,
|
|
153
153
|
"--",
|
|
154
154
|
MANAGED_RUNTIME_LAUNCHER,
|
|
155
155
|
binary,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface WorkspaceWriterFreezeProof {
|
|
2
|
+
readonly proofVersion: "workspace-writer-freeze-proof/1";
|
|
3
|
+
readonly sandboxId: string;
|
|
4
|
+
readonly sandboxGeneration: number;
|
|
5
|
+
readonly runtimeSessionId: string;
|
|
6
|
+
readonly checkpointId: string;
|
|
7
|
+
readonly allWritersFrozen: true;
|
|
8
|
+
}
|
|
9
|
+
export declare function freezeRuntimeWriters(): Promise<void>;
|
|
10
|
+
export declare function thawRuntimeWriters(): Promise<void>;
|
|
11
|
+
export declare function assertWorkspaceWriterFreezeSupported(): void;
|
|
12
|
+
export declare function quiesceRuntimeWriters(activationId: string | null): Promise<void>;
|
|
13
|
+
export declare function issueWorkspaceWriterFreezeProof(scope: {
|
|
14
|
+
sandboxId: string;
|
|
15
|
+
sandboxGeneration: number;
|
|
16
|
+
runtimeSessionId: string;
|
|
17
|
+
checkpointId: string;
|
|
18
|
+
}): WorkspaceWriterFreezeProof;
|
|
19
|
+
export declare function isWorkspaceWriterFreezeProof(value: object): value is WorkspaceWriterFreezeProof;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { accessSync, constants as fsConstants } from "node:fs";
|
|
3
|
+
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
|
|
8
|
+
const RUNTIME_CGROUP_ROOT = "/sys/fs/cgroup/botlearn-runtime";
|
|
9
|
+
const proofs = new WeakSet();
|
|
10
|
+
const sleep = (milliseconds) => new Promise((resolve) => {
|
|
11
|
+
setTimeout(resolve, milliseconds);
|
|
12
|
+
});
|
|
13
|
+
async function setDirectCgroupsFrozen(frozen) {
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = await readdir(RUNTIME_CGROUP_ROOT, { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (!entry.isDirectory())
|
|
25
|
+
continue;
|
|
26
|
+
const directory = path.join(RUNTIME_CGROUP_ROOT, entry.name);
|
|
27
|
+
await writeFile(path.join(directory, "cgroup.freeze"), frozen ? "1" : "0");
|
|
28
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
29
|
+
const events = await readFile(path.join(directory, "cgroup.events"), "utf8");
|
|
30
|
+
if (events.includes(`frozen ${frozen ? 1 : 0}`))
|
|
31
|
+
break;
|
|
32
|
+
if (attempt === 99)
|
|
33
|
+
throw new Error("workspace_runtime_freeze_timeout");
|
|
34
|
+
await sleep(50);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function setRuntimeWritersFrozen(frozen) {
|
|
39
|
+
const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
|
|
40
|
+
if (mode === "direct-uid") {
|
|
41
|
+
await setDirectCgroupsFrozen(frozen);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (mode === "sudo") {
|
|
45
|
+
await execFileAsync("/usr/bin/sudo", ["-n", "--", RUNTIME_LAUNCHER, frozen ? "--freeze-all" : "--thaw-all"], { timeout: 10_000 });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
throw new Error("workspace_runtime_cgroup_unavailable");
|
|
52
|
+
}
|
|
53
|
+
export async function freezeRuntimeWriters() {
|
|
54
|
+
await setRuntimeWritersFrozen(true);
|
|
55
|
+
}
|
|
56
|
+
export async function thawRuntimeWriters() {
|
|
57
|
+
await setRuntimeWritersFrozen(false);
|
|
58
|
+
}
|
|
59
|
+
export function assertWorkspaceWriterFreezeSupported() {
|
|
60
|
+
const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
|
|
61
|
+
if (mode === "direct-uid") {
|
|
62
|
+
accessSync(RUNTIME_CGROUP_ROOT, fsConstants.R_OK | fsConstants.W_OK);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (mode === "sudo") {
|
|
66
|
+
accessSync(RUNTIME_LAUNCHER, fsConstants.X_OK);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw new Error("workspace_runtime_cgroup_unavailable");
|
|
73
|
+
}
|
|
74
|
+
function managedQuiescenceCommand(activationId) {
|
|
75
|
+
const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
|
|
76
|
+
if (mode === "direct-uid") {
|
|
77
|
+
return [RUNTIME_LAUNCHER, [activationId ? "--quiesce" : "--quiesce-all", ...(activationId ? [activationId] : [])]];
|
|
78
|
+
}
|
|
79
|
+
if (mode === "sudo") {
|
|
80
|
+
return [
|
|
81
|
+
"/usr/bin/sudo",
|
|
82
|
+
["-n", "--", RUNTIME_LAUNCHER, activationId ? "--quiesce" : "--quiesce-all", ...(activationId ? [activationId] : [])],
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
export async function quiesceRuntimeWriters(activationId) {
|
|
88
|
+
const command = managedQuiescenceCommand(activationId);
|
|
89
|
+
if (command === null) {
|
|
90
|
+
// Local/BYOA runtimes execute as the daemon user and are not eligible for the managed
|
|
91
|
+
// durable capability. Unit/e2e fake runtimes have no descendant writer to reap.
|
|
92
|
+
if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw new Error("workspace_runtime_cgroup_unavailable");
|
|
96
|
+
}
|
|
97
|
+
await execFileAsync(command[0], command[1], { timeout: 10_000 });
|
|
98
|
+
}
|
|
99
|
+
export function issueWorkspaceWriterFreezeProof(scope) {
|
|
100
|
+
const proof = Object.freeze({
|
|
101
|
+
proofVersion: "workspace-writer-freeze-proof/1",
|
|
102
|
+
...scope,
|
|
103
|
+
allWritersFrozen: true,
|
|
104
|
+
});
|
|
105
|
+
proofs.add(proof);
|
|
106
|
+
return proof;
|
|
107
|
+
}
|
|
108
|
+
export function isWorkspaceWriterFreezeProof(value) {
|
|
109
|
+
return proofs.has(value);
|
|
110
|
+
}
|
|
@@ -13,12 +13,13 @@ export interface ToolObservationPayload extends Record<string, unknown> {
|
|
|
13
13
|
redacted: boolean;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Project a provider tool envelope into durable
|
|
16
|
+
* Project a provider tool envelope into durable learner-preview and teaching-audit evidence.
|
|
17
17
|
*
|
|
18
18
|
* Only allowlisted operational argument/result fields survive, credentials are removed,
|
|
19
|
-
* and
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* and paths inside the current workspace become workspace-relative. Other semantic content,
|
|
20
|
+
* including execution-host paths outside that workspace, stays intact. `detail_preview` is
|
|
21
|
+
* a bounded feed-sized excerpt; `detail_full` carries the same allowlisted projection at
|
|
22
|
+
* audit fidelity (deeper walk, larger arrays, 64k chars). Message text, request IDs, and
|
|
23
|
+
* arbitrary envelope metadata never cross this boundary.
|
|
23
24
|
*/
|
|
24
25
|
export declare function buildToolObservation(block: RuntimeBlock, runtime: string, workspaceDir: string): ToolObservationPayload | null;
|
package/dist/tool-observation.js
CHANGED
|
@@ -39,13 +39,14 @@ const RESULT_FIELDS = new Set([
|
|
|
39
39
|
"bytes",
|
|
40
40
|
]);
|
|
41
41
|
/**
|
|
42
|
-
* Project a provider tool envelope into durable
|
|
42
|
+
* Project a provider tool envelope into durable learner-preview and teaching-audit evidence.
|
|
43
43
|
*
|
|
44
44
|
* Only allowlisted operational argument/result fields survive, credentials are removed,
|
|
45
|
-
* and
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* and paths inside the current workspace become workspace-relative. Other semantic content,
|
|
46
|
+
* including execution-host paths outside that workspace, stays intact. `detail_preview` is
|
|
47
|
+
* a bounded feed-sized excerpt; `detail_full` carries the same allowlisted projection at
|
|
48
|
+
* audit fidelity (deeper walk, larger arrays, 64k chars). Message text, request IDs, and
|
|
49
|
+
* arbitrary envelope metadata never cross this boundary.
|
|
49
50
|
*/
|
|
50
51
|
export function buildToolObservation(block, runtime, workspaceDir) {
|
|
51
52
|
if (block.kind !== "tool_call" && block.kind !== "tool_result")
|
|
@@ -17,5 +17,5 @@ export interface ReasoningTracePayload extends Record<string, unknown> {
|
|
|
17
17
|
* envelope stays local to the sandbox transcript.
|
|
18
18
|
*/
|
|
19
19
|
export declare function extractReasoningText(raw: unknown): string;
|
|
20
|
-
/** Project
|
|
20
|
+
/** Project reasoning into one bounded payload that filters credentials and preserves semantics. */
|
|
21
21
|
export declare function buildReasoningTracePayload(text: string, runtime: string, alreadyTruncated?: boolean): ReasoningTracePayload;
|
package/dist/trace-projection.js
CHANGED
|
@@ -40,7 +40,7 @@ function collectReasoningStrings(value, depth, out) {
|
|
|
40
40
|
collectReasoningStrings(item, depth + 1, out);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
/** Project
|
|
43
|
+
/** Project reasoning into one bounded payload that filters credentials and preserves semantics. */
|
|
44
44
|
export function buildReasoningTracePayload(text, runtime, alreadyTruncated = false) {
|
|
45
45
|
const redactedText = redactSecretString(text);
|
|
46
46
|
const truncated = alreadyTruncated || redactedText.length > REASONING_TRACE_MAX_CHARS;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const RUNTIME_WORKSPACE_QUOTA_BYTES: number;
|
|
2
|
+
export declare const RUNTIME_WORKSPACE_INODE_QUOTA = 2000;
|
|
3
|
+
export declare function assertRuntimeWorkspaceQuota(workspaceRoot: string): void;
|
|
4
|
+
export declare function applyRuntimeWorkspaceQuota(runtimeSessionId: string, sandboxGeneration: number): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, statfsSync, statSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
export const RUNTIME_WORKSPACE_QUOTA_BYTES = 512 * 1024 * 1024;
|
|
5
|
+
export const RUNTIME_WORKSPACE_INODE_QUOTA = 2_000;
|
|
6
|
+
const WORKSPACE_BACKING_BYTES = 3 * 1024 * 1024 * 1024;
|
|
7
|
+
const QUOTA_MARKER = "/var/lib/botlearn-runtime/workspace-quota.ready";
|
|
8
|
+
const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
export function assertRuntimeWorkspaceQuota(workspaceRoot) {
|
|
11
|
+
if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (process.env.BOTLEARN_RUNTIME_WORKSPACE_QUOTA_BYTES !== String(RUNTIME_WORKSPACE_QUOTA_BYTES) ||
|
|
15
|
+
process.env.BOTLEARN_RUNTIME_WORKSPACE_INODE_QUOTA !== String(RUNTIME_WORKSPACE_INODE_QUOTA)) {
|
|
16
|
+
throw new Error("workspace_runtime_quota_unverified");
|
|
17
|
+
}
|
|
18
|
+
const filesystem = statfsSync(workspaceRoot, { bigint: true });
|
|
19
|
+
const marker = statSync(QUOTA_MARKER);
|
|
20
|
+
if (filesystem.blocks * filesystem.bsize < BigInt(WORKSPACE_BACKING_BYTES) ||
|
|
21
|
+
marker.uid !== 0 || (marker.mode & 0o777) !== 0o600 ||
|
|
22
|
+
readFileSync(QUOTA_MARKER, "utf8") !== "workspace-filesystem-quota/1\n") {
|
|
23
|
+
throw new Error("workspace_runtime_quota_probe_failed");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function applyRuntimeWorkspaceQuota(runtimeSessionId, sandboxGeneration) {
|
|
27
|
+
if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const args = ["--workspace-quota-apply", runtimeSessionId, String(sandboxGeneration)];
|
|
31
|
+
if (process.env.BOTLEARN_RUNTIME_LAUNCH_MODE === "sudo") {
|
|
32
|
+
await execFileAsync("/usr/bin/sudo", ["-n", "--", RUNTIME_LAUNCHER, ...args], {
|
|
33
|
+
timeout: 10_000,
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (process.env.BOTLEARN_RUNTIME_LAUNCH_MODE === "direct-uid") {
|
|
38
|
+
await execFileAsync(RUNTIME_LAUNCHER, args, { timeout: 10_000 });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new Error("workspace_runtime_quota_helper_unavailable");
|
|
42
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type WorkspaceEntry, type WorkspaceEntrySetLimits } from "./workspace-entry-set.js";
|
|
2
2
|
export declare class WorkspaceRestoreError extends Error {
|
|
3
3
|
readonly code: string;
|
|
4
|
-
|
|
4
|
+
readonly retryable: boolean;
|
|
5
|
+
constructor(code: string, retryable?: boolean);
|
|
5
6
|
}
|
|
6
7
|
export interface WorkspaceRestoreDescriptor {
|
|
7
8
|
sandboxId: string;
|
|
@@ -25,6 +26,12 @@ export interface WorkspaceRestoreOptions {
|
|
|
25
26
|
requiredFreeBytes: number;
|
|
26
27
|
downloadFile(entry: WorkspaceEntry, partPath: string): Promise<void>;
|
|
27
28
|
}
|
|
29
|
+
export interface WorkspaceRestoreCapacityOptions {
|
|
30
|
+
workspaceDirectory: string;
|
|
31
|
+
descriptor: WorkspaceRestoreDescriptor;
|
|
32
|
+
requiredFreeBytes: number;
|
|
33
|
+
entryCopies?: 1 | 2;
|
|
34
|
+
}
|
|
28
35
|
export declare function workspaceRestoreJournalPath(controlDirectory: string, runtimeSessionId: string): string;
|
|
29
36
|
export declare function recoverWorkspaceRestoreSwap(options: {
|
|
30
37
|
workspaceDirectory: string;
|
|
@@ -32,3 +39,4 @@ export declare function recoverWorkspaceRestoreSwap(options: {
|
|
|
32
39
|
descriptor: WorkspaceRestoreDescriptor;
|
|
33
40
|
}): boolean;
|
|
34
41
|
export declare function restoreWorkspaceSnapshot(options: WorkspaceRestoreOptions): Promise<void>;
|
|
42
|
+
export declare function assertWorkspaceRestoreCapacity(options: WorkspaceRestoreCapacityOptions): void;
|
|
@@ -9,14 +9,18 @@ const RESTORE_JOURNAL_SCHEMA = "agent-workspace-restore-journal/1";
|
|
|
9
9
|
const COPY_CHUNK_BYTES = 64 * 1024;
|
|
10
10
|
export class WorkspaceRestoreError extends Error {
|
|
11
11
|
code;
|
|
12
|
-
|
|
12
|
+
retryable;
|
|
13
|
+
constructor(code, retryable = false) {
|
|
13
14
|
super(code);
|
|
14
15
|
this.code = code;
|
|
16
|
+
this.retryable = retryable;
|
|
15
17
|
this.name = "WorkspaceRestoreError";
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
function fail(code) {
|
|
19
|
-
throw new WorkspaceRestoreError(code
|
|
21
|
+
throw new WorkspaceRestoreError(code, code === "workspace_restore_unavailable" ||
|
|
22
|
+
code === "workspace_restore_disk_pressure" ||
|
|
23
|
+
code === "workspace_restore_inode_pressure");
|
|
20
24
|
}
|
|
21
25
|
function assertUuid(value, label) {
|
|
22
26
|
if (!UUID.test(value))
|
|
@@ -229,9 +233,7 @@ export async function restoreWorkspaceSnapshot(options) {
|
|
|
229
233
|
const descriptor = options.descriptor;
|
|
230
234
|
assertDescriptor(descriptor);
|
|
231
235
|
assertControlPath(options.workspaceDirectory, options.controlDirectory);
|
|
232
|
-
|
|
233
|
-
options.requiredFreeBytes < descriptor.expectedTotalBytes)
|
|
234
|
-
fail("workspace_restore_reservation_invalid");
|
|
236
|
+
assertWorkspaceRestoreCapacity(options);
|
|
235
237
|
recoverWorkspaceRestoreSwap({
|
|
236
238
|
workspaceDirectory: options.workspaceDirectory,
|
|
237
239
|
controlDirectory: options.controlDirectory,
|
|
@@ -244,17 +246,6 @@ export async function restoreWorkspaceSnapshot(options) {
|
|
|
244
246
|
(descriptor.revision > 0 && canonical.contentSha256 !== descriptor.contentSha256))
|
|
245
247
|
fail("workspace_restore_integrity_failed");
|
|
246
248
|
const parent = path.dirname(options.workspaceDirectory);
|
|
247
|
-
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
248
|
-
if (existsSync(options.workspaceDirectory)) {
|
|
249
|
-
const current = lstatSync(options.workspaceDirectory, { bigint: true });
|
|
250
|
-
if (!current.isDirectory() || current.isSymbolicLink()) {
|
|
251
|
-
fail("workspace_restore_existing_workspace_invalid");
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
const filesystem = statfsSync(parent, { bigint: true });
|
|
255
|
-
if (filesystem.bavail * filesystem.bsize < BigInt(options.requiredFreeBytes)) {
|
|
256
|
-
fail("workspace_restore_disk_pressure");
|
|
257
|
-
}
|
|
258
249
|
const nonce = randomUUID();
|
|
259
250
|
const stagingName = `.botlearn-restore-${descriptor.runtimeSessionId}-${nonce}`;
|
|
260
251
|
const backupName = `.botlearn-backup-${descriptor.runtimeSessionId}-${nonce}`;
|
|
@@ -331,3 +322,26 @@ export async function restoreWorkspaceSnapshot(options) {
|
|
|
331
322
|
throw error;
|
|
332
323
|
}
|
|
333
324
|
}
|
|
325
|
+
export function assertWorkspaceRestoreCapacity(options) {
|
|
326
|
+
const { descriptor } = options;
|
|
327
|
+
assertDescriptor(descriptor);
|
|
328
|
+
if (!Number.isSafeInteger(options.requiredFreeBytes) ||
|
|
329
|
+
options.requiredFreeBytes < descriptor.expectedTotalBytes)
|
|
330
|
+
fail("workspace_restore_reservation_invalid");
|
|
331
|
+
const parent = path.dirname(options.workspaceDirectory);
|
|
332
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
333
|
+
if (existsSync(options.workspaceDirectory)) {
|
|
334
|
+
const current = lstatSync(options.workspaceDirectory, { bigint: true });
|
|
335
|
+
if (!current.isDirectory() || current.isSymbolicLink()) {
|
|
336
|
+
fail("workspace_restore_existing_workspace_invalid");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const filesystem = statfsSync(parent, { bigint: true });
|
|
340
|
+
if (filesystem.bavail * filesystem.bsize < BigInt(options.requiredFreeBytes)) {
|
|
341
|
+
fail("workspace_restore_disk_pressure");
|
|
342
|
+
}
|
|
343
|
+
const entryCopies = options.entryCopies ?? 1;
|
|
344
|
+
if (filesystem.ffree < BigInt(descriptor.expectedEntryCount * entryCopies + 16)) {
|
|
345
|
+
fail("workspace_restore_inode_pressure");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WorkspaceWriterFreezeProof } from "./runtime-quiescence.js";
|
|
2
|
+
interface WorkspaceCheckpointScope {
|
|
3
|
+
checkpointId: string;
|
|
4
|
+
baseRevision: number;
|
|
5
|
+
sandboxId: string;
|
|
6
|
+
sandboxGeneration: number;
|
|
7
|
+
connectionEpoch: number;
|
|
8
|
+
runtimeSessionId: string;
|
|
9
|
+
agentRunId?: string;
|
|
10
|
+
workerAttempt?: number;
|
|
11
|
+
activationId?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class WorkspaceSnapshotControlError extends Error {
|
|
14
|
+
readonly code: string;
|
|
15
|
+
readonly retryable: boolean;
|
|
16
|
+
constructor(code: string, retryable: boolean);
|
|
17
|
+
}
|
|
18
|
+
export declare function checkpointWorkspace(options: {
|
|
19
|
+
wsUrl: string;
|
|
20
|
+
reconnectToken: string;
|
|
21
|
+
workspaceDirectory: string;
|
|
22
|
+
scope: WorkspaceCheckpointScope;
|
|
23
|
+
freezeProof: WorkspaceWriterFreezeProof;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
revision: number;
|
|
26
|
+
snapshotId: string | null;
|
|
27
|
+
contentSha256: string;
|
|
28
|
+
}>;
|
|
29
|
+
export {};
|