@botlearn-course/daemon 0.0.14 → 0.0.15
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.
|
@@ -56,6 +56,15 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
|
|
|
56
56
|
private stopped;
|
|
57
57
|
private permanentFailure;
|
|
58
58
|
private lifecycleChain;
|
|
59
|
+
private runtimeLogPolicy;
|
|
60
|
+
private runtimeLogHandshakeReady;
|
|
61
|
+
private runtimeLogWindowStartedAt;
|
|
62
|
+
private runtimeLogWindowEvents;
|
|
63
|
+
private runtimeLogWindowBytes;
|
|
64
|
+
private runtimeLogSuppressedEvents;
|
|
65
|
+
private runtimeLogSuppressedBytes;
|
|
66
|
+
private runtimeLogSendChain;
|
|
67
|
+
private readonly removeOperationalLogSink;
|
|
59
68
|
constructor(options: AgentServiceSandboxOptions);
|
|
60
69
|
prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
|
|
61
70
|
persistNativeSession(nativeSessionId: string): void;
|
|
@@ -69,6 +78,8 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
|
|
|
69
78
|
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
70
79
|
private connectOnce;
|
|
71
80
|
private handleHello;
|
|
81
|
+
private forwardOperationalLog;
|
|
82
|
+
private queueRuntimeLog;
|
|
72
83
|
private assertServerFrame;
|
|
73
84
|
private handleServerFrame;
|
|
74
85
|
/** sandbox.sync 只做对账:重放 spool、关闭待关 session、应用 drain/shutdown。 */
|
|
@@ -2,7 +2,7 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ensureDaemonHome } from "./auth-store.js";
|
|
4
4
|
import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, parseSandboxFrame, UnsupportedSandboxProtocolError, } from "./agent-service-ws-protocol.js";
|
|
5
|
-
import { log as defaultLog } from "./log.js";
|
|
5
|
+
import { log as defaultLog, setOperationalLogSink, } from "./log.js";
|
|
6
6
|
import { availableRunCapabilities, runtimeSupportsCourseSkills, } from "./runtime-capabilities.js";
|
|
7
7
|
import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
|
|
8
8
|
import { redactSecretString } from "./redaction.js";
|
|
@@ -17,6 +17,70 @@ const MAX_EVENT_ACK_WINDOW = 64;
|
|
|
17
17
|
const LEGACY_EVENT_ACK_WINDOW = 1;
|
|
18
18
|
/** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
|
|
19
19
|
const SEQ_EPOCH_BASE = 1_000_000_000;
|
|
20
|
+
const RUNTIME_LOG_WINDOW_MS = 60_000;
|
|
21
|
+
const DISABLED_RUNTIME_LOG_POLICY = {
|
|
22
|
+
enabled: false,
|
|
23
|
+
maxEventBytes: 4096,
|
|
24
|
+
maxEventsPerMinute: 1,
|
|
25
|
+
maxBytesPerMinute: 4096,
|
|
26
|
+
};
|
|
27
|
+
function boundedUtf8(value, maxBytes) {
|
|
28
|
+
const encoded = Buffer.from(value, "utf8");
|
|
29
|
+
if (encoded.length <= maxBytes)
|
|
30
|
+
return value;
|
|
31
|
+
const suffix = "…[truncated]";
|
|
32
|
+
const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
|
|
33
|
+
return encoded.subarray(0, budget).toString("utf8").replace(/�$/u, "") + suffix;
|
|
34
|
+
}
|
|
35
|
+
function boundedLogFields(fields, maxBytes) {
|
|
36
|
+
if (!fields || maxBytes < 2)
|
|
37
|
+
return { bytes: 0 };
|
|
38
|
+
const bounded = {};
|
|
39
|
+
let truncated = false;
|
|
40
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
41
|
+
bounded[key] = value;
|
|
42
|
+
let bytes;
|
|
43
|
+
try {
|
|
44
|
+
bytes = Buffer.byteLength(JSON.stringify(bounded), "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
bytes = maxBytes + 1;
|
|
48
|
+
}
|
|
49
|
+
if (bytes > maxBytes) {
|
|
50
|
+
delete bounded[key];
|
|
51
|
+
truncated = true;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (truncated) {
|
|
56
|
+
const marked = { ...bounded, runtime_log_fields_truncated: true };
|
|
57
|
+
if (Buffer.byteLength(JSON.stringify(marked), "utf8") <= maxBytes) {
|
|
58
|
+
Object.assign(bounded, { runtime_log_fields_truncated: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!Object.keys(bounded).length)
|
|
62
|
+
return { bytes: 0 };
|
|
63
|
+
const bytes = Buffer.byteLength(JSON.stringify(bounded), "utf8");
|
|
64
|
+
return { fields: bounded, bytes };
|
|
65
|
+
}
|
|
66
|
+
function runtimeLogPolicy(value) {
|
|
67
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
68
|
+
return DISABLED_RUNTIME_LOG_POLICY;
|
|
69
|
+
}
|
|
70
|
+
const raw = value;
|
|
71
|
+
const integer = (key, fallback, min, max) => {
|
|
72
|
+
const candidate = Number(raw[key]);
|
|
73
|
+
return Number.isSafeInteger(candidate) && candidate >= min && candidate <= max
|
|
74
|
+
? candidate
|
|
75
|
+
: fallback;
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
enabled: raw.enabled === true,
|
|
79
|
+
maxEventBytes: integer("max_event_bytes", 4096, 256, 16_384),
|
|
80
|
+
maxEventsPerMinute: integer("max_events_per_minute", 60, 1, 600),
|
|
81
|
+
maxBytesPerMinute: integer("max_bytes_per_minute", 131_072, 4096, 1_048_576),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
20
84
|
function inputAttachmentGrant(value) {
|
|
21
85
|
if (value === undefined || value === null)
|
|
22
86
|
return undefined;
|
|
@@ -277,6 +341,15 @@ export class AgentServiceSandboxClient {
|
|
|
277
341
|
stopped = false;
|
|
278
342
|
permanentFailure = false;
|
|
279
343
|
lifecycleChain = Promise.resolve();
|
|
344
|
+
runtimeLogPolicy = DISABLED_RUNTIME_LOG_POLICY;
|
|
345
|
+
runtimeLogHandshakeReady = false;
|
|
346
|
+
runtimeLogWindowStartedAt = 0;
|
|
347
|
+
runtimeLogWindowEvents = 0;
|
|
348
|
+
runtimeLogWindowBytes = 0;
|
|
349
|
+
runtimeLogSuppressedEvents = 0;
|
|
350
|
+
runtimeLogSuppressedBytes = 0;
|
|
351
|
+
runtimeLogSendChain = Promise.resolve();
|
|
352
|
+
removeOperationalLogSink;
|
|
280
353
|
constructor(options) {
|
|
281
354
|
this.options = options;
|
|
282
355
|
this.log = options.log ?? defaultLog;
|
|
@@ -288,6 +361,9 @@ export class AgentServiceSandboxClient {
|
|
|
288
361
|
this.dispatcher = new RunDispatcher(this, options.runtimes, {
|
|
289
362
|
persistentSession: this,
|
|
290
363
|
});
|
|
364
|
+
this.removeOperationalLogSink = setOperationalLogSink((entry) => {
|
|
365
|
+
this.forwardOperationalLog(entry);
|
|
366
|
+
});
|
|
291
367
|
}
|
|
292
368
|
prepareTurn(payload) {
|
|
293
369
|
const scope = this.turnScopes.get(payload.agent_run_id);
|
|
@@ -448,6 +524,7 @@ export class AgentServiceSandboxClient {
|
|
|
448
524
|
await this.sleep(delay);
|
|
449
525
|
}
|
|
450
526
|
}
|
|
527
|
+
this.removeOperationalLogSink();
|
|
451
528
|
if (this.permanentFailure) {
|
|
452
529
|
throw new Error("Agent Service sandbox stopped after a permanent protocol failure");
|
|
453
530
|
}
|
|
@@ -464,6 +541,8 @@ export class AgentServiceSandboxClient {
|
|
|
464
541
|
this.activeSessionId = null;
|
|
465
542
|
this.currentTurnSessionId = null;
|
|
466
543
|
this.persist();
|
|
544
|
+
this.runtimeLogHandshakeReady = false;
|
|
545
|
+
this.removeOperationalLogSink();
|
|
467
546
|
this.socket?.close(1000, "daemon_stopping");
|
|
468
547
|
}
|
|
469
548
|
async postEvent(agentRunId, event) {
|
|
@@ -639,6 +718,7 @@ export class AgentServiceSandboxClient {
|
|
|
639
718
|
this.rejectPendingFiles(new Error("Agent Service file transport disconnected"));
|
|
640
719
|
if (this.socket === socket)
|
|
641
720
|
this.socket = null;
|
|
721
|
+
this.runtimeLogHandshakeReady = false;
|
|
642
722
|
if (socket.readyState === WebSocketClient.OPEN)
|
|
643
723
|
socket.close(1000, "reconnecting");
|
|
644
724
|
}
|
|
@@ -683,6 +763,7 @@ export class AgentServiceSandboxClient {
|
|
|
683
763
|
? advertisedAckWindow
|
|
684
764
|
: LEGACY_EVENT_ACK_WINDOW;
|
|
685
765
|
this.maxUnackedEvents = Math.min(MAX_EVENT_ACK_WINDOW, ackWindow);
|
|
766
|
+
this.runtimeLogPolicy = runtimeLogPolicy(frame.payload.runtime_log_policy);
|
|
686
767
|
this.persist();
|
|
687
768
|
await this.sendControlFrame("sandbox.ready", {
|
|
688
769
|
protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
|
|
@@ -696,6 +777,73 @@ export class AgentServiceSandboxClient {
|
|
|
696
777
|
resumed_sessions: Object.keys(this.state.sessions),
|
|
697
778
|
spool_frames: this.spoolFrameCount(),
|
|
698
779
|
});
|
|
780
|
+
this.runtimeLogHandshakeReady = true;
|
|
781
|
+
}
|
|
782
|
+
forwardOperationalLog(entry) {
|
|
783
|
+
const policy = this.runtimeLogPolicy;
|
|
784
|
+
if (!policy.enabled || !this.runtimeLogHandshakeReady)
|
|
785
|
+
return;
|
|
786
|
+
const message = boundedUtf8(entry.message, policy.maxEventBytes);
|
|
787
|
+
const messageBytes = Buffer.byteLength(message, "utf8");
|
|
788
|
+
const boundedFields = boundedLogFields(entry.fields, Math.max(0, policy.maxEventBytes - messageBytes));
|
|
789
|
+
const contentBytes = messageBytes + boundedFields.bytes;
|
|
790
|
+
const now = Date.now();
|
|
791
|
+
if (now - this.runtimeLogWindowStartedAt >= RUNTIME_LOG_WINDOW_MS) {
|
|
792
|
+
if (this.runtimeLogSuppressedEvents > 0) {
|
|
793
|
+
this.queueRuntimeLog({
|
|
794
|
+
level: "warn",
|
|
795
|
+
message: "sandbox runtime logs suppressed at daemon source",
|
|
796
|
+
fields: {
|
|
797
|
+
suppressed_count: this.runtimeLogSuppressedEvents,
|
|
798
|
+
suppressed_bytes: this.runtimeLogSuppressedBytes,
|
|
799
|
+
},
|
|
800
|
+
timestamp: new Date(now).toISOString(),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
this.runtimeLogWindowStartedAt = now;
|
|
804
|
+
this.runtimeLogWindowEvents = 0;
|
|
805
|
+
this.runtimeLogWindowBytes = 0;
|
|
806
|
+
this.runtimeLogSuppressedEvents = 0;
|
|
807
|
+
this.runtimeLogSuppressedBytes = 0;
|
|
808
|
+
}
|
|
809
|
+
if (this.runtimeLogWindowEvents >= policy.maxEventsPerMinute ||
|
|
810
|
+
this.runtimeLogWindowBytes + contentBytes > policy.maxBytesPerMinute) {
|
|
811
|
+
this.runtimeLogSuppressedEvents += 1;
|
|
812
|
+
this.runtimeLogSuppressedBytes += contentBytes;
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
this.runtimeLogWindowEvents += 1;
|
|
816
|
+
this.runtimeLogWindowBytes += contentBytes;
|
|
817
|
+
this.queueRuntimeLog({
|
|
818
|
+
...entry,
|
|
819
|
+
message,
|
|
820
|
+
fields: boundedFields.fields,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
queueRuntimeLog(entry) {
|
|
824
|
+
this.runtimeLogSendChain = this.runtimeLogSendChain
|
|
825
|
+
.then(async () => {
|
|
826
|
+
if (!this.runtimeLogHandshakeReady || this.socket?.readyState !== WebSocketClient.OPEN) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
await this.sendFrame(createSandboxFrame({
|
|
830
|
+
type: "sandbox.log",
|
|
831
|
+
sandboxId: this.options.sandboxId,
|
|
832
|
+
sandboxGeneration: this.sandboxGeneration,
|
|
833
|
+
connectionEpoch: this.connectionEpoch,
|
|
834
|
+
seq: this.nextOutboundSeq(),
|
|
835
|
+
payload: {
|
|
836
|
+
level: entry.level,
|
|
837
|
+
stream: "stderr",
|
|
838
|
+
message: entry.message,
|
|
839
|
+
emitted_at: entry.timestamp,
|
|
840
|
+
...(entry.fields ? { fields: entry.fields } : {}),
|
|
841
|
+
},
|
|
842
|
+
}));
|
|
843
|
+
})
|
|
844
|
+
.catch(() => {
|
|
845
|
+
// Sentry forwarding is best-effort and must not enter the daemon logger.
|
|
846
|
+
});
|
|
699
847
|
}
|
|
700
848
|
assertServerFrame(frame) {
|
|
701
849
|
if (frame.sandbox_id !== this.options.sandboxId ||
|
|
@@ -1092,7 +1240,10 @@ export class AgentServiceSandboxClient {
|
|
|
1092
1240
|
await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
|
|
1093
1241
|
return;
|
|
1094
1242
|
}
|
|
1095
|
-
this.dispatcher.cancel(frame.agent_run_id)
|
|
1243
|
+
if (!this.dispatcher.cancel(frame.agent_run_id)) {
|
|
1244
|
+
await this.sendCommandAck(frame, "rejected", "turn_not_running");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1096
1247
|
await this.sendCommandAck(frame, "ok");
|
|
1097
1248
|
}
|
|
1098
1249
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.2";
|
|
2
2
|
export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v2";
|
|
3
|
-
export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.closed" | "turn.event" | "turn.file.report" | "sandbox.drained" | "pong" | "protocol.error";
|
|
3
|
+
export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.closed" | "turn.event" | "turn.file.report" | "sandbox.drained" | "sandbox.log" | "pong" | "protocol.error";
|
|
4
4
|
export declare class UnsupportedSandboxProtocolError extends Error {
|
|
5
5
|
readonly schemaVersion: unknown;
|
|
6
6
|
constructor(schemaVersion: unknown);
|
package/dist/log.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
type Level = "info" | "warn" | "error" | "debug";
|
|
2
|
+
export interface OperationalLogEntry {
|
|
3
|
+
level: Level;
|
|
4
|
+
message: string;
|
|
5
|
+
fields?: Record<string, unknown>;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
}
|
|
8
|
+
export type OperationalLogSink = (entry: OperationalLogEntry) => void;
|
|
9
|
+
/** One managed sandbox daemon runs per process, so it owns one ephemeral remote sink. */
|
|
10
|
+
export declare function setOperationalLogSink(sink: OperationalLogSink): () => void;
|
|
2
11
|
export interface Logger {
|
|
3
12
|
info(msg: string, fields?: Record<string, unknown>): void;
|
|
4
13
|
warn(msg: string, fields?: Record<string, unknown>): void;
|
package/dist/log.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { daemonHome } from "./auth-store.js";
|
|
4
|
-
import { redactSecretsDeep } from "./redaction.js";
|
|
4
|
+
import { redactSecretString, redactSecretsDeep } from "./redaction.js";
|
|
5
5
|
const LOG_DIR = path.join(daemonHome(), "logs");
|
|
6
6
|
const LOG_FILE = path.join(LOG_DIR, "daemon.log");
|
|
7
7
|
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -18,6 +18,15 @@ function ensureDir() {
|
|
|
18
18
|
}
|
|
19
19
|
inited = true;
|
|
20
20
|
}
|
|
21
|
+
let operationalLogSink = null;
|
|
22
|
+
/** One managed sandbox daemon runs per process, so it owns one ephemeral remote sink. */
|
|
23
|
+
export function setOperationalLogSink(sink) {
|
|
24
|
+
operationalLogSink = sink;
|
|
25
|
+
return () => {
|
|
26
|
+
if (operationalLogSink === sink)
|
|
27
|
+
operationalLogSink = null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
21
30
|
function formatValue(value) {
|
|
22
31
|
if (value instanceof Error)
|
|
23
32
|
return JSON.stringify(value.stack ?? value.message);
|
|
@@ -131,7 +140,9 @@ function write(level, msg, fields) {
|
|
|
131
140
|
ensureDir();
|
|
132
141
|
// 所有 fields 值序列化前先深度脱敏,token 类值绝不落盘/上屏。
|
|
133
142
|
const safeFields = fields === undefined ? undefined : redactSecretsDeep(fields);
|
|
134
|
-
const
|
|
143
|
+
const safeMessage = redactSecretString(msg);
|
|
144
|
+
const timestamp = new Date();
|
|
145
|
+
const line = formatLogLine(level, safeMessage, safeFields, timestamp);
|
|
135
146
|
try {
|
|
136
147
|
rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
|
|
137
148
|
appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
|
|
@@ -141,6 +152,17 @@ function write(level, msg, fields) {
|
|
|
141
152
|
}
|
|
142
153
|
// 同步镜像到 stderr,前台运行时可直接观察。
|
|
143
154
|
process.stderr.write(line + "\n");
|
|
155
|
+
try {
|
|
156
|
+
operationalLogSink?.({
|
|
157
|
+
level,
|
|
158
|
+
message: safeMessage,
|
|
159
|
+
fields: safeFields,
|
|
160
|
+
timestamp: timestamp.toISOString(),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Observability must never affect daemon execution or recurse into the logger.
|
|
165
|
+
}
|
|
144
166
|
}
|
|
145
167
|
export const log = {
|
|
146
168
|
info: (msg, fields) => write("info", msg, fields),
|
package/package.json
CHANGED