@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);
@@ -22,6 +22,7 @@ const FRAME_TYPES = new Set([
22
22
  "turn.event",
23
23
  "turn.file.report",
24
24
  "sandbox.drained",
25
+ "sandbox.log",
25
26
  "pong",
26
27
  "protocol.error",
27
28
  ]);
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 line = formatLogLine(level, msg, safeFields);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {