@lobu/worker 7.0.0 → 7.1.0

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.
Files changed (73) hide show
  1. package/dist/core/error-handler.d.ts +0 -4
  2. package/dist/core/error-handler.d.ts.map +1 -1
  3. package/dist/core/error-handler.js +4 -15
  4. package/dist/core/error-handler.js.map +1 -1
  5. package/dist/core/types.d.ts +1 -19
  6. package/dist/core/types.d.ts.map +1 -1
  7. package/dist/core/types.js +0 -4
  8. package/dist/core/types.js.map +1 -1
  9. package/dist/core/workspace.d.ts +2 -11
  10. package/dist/core/workspace.d.ts.map +1 -1
  11. package/dist/core/workspace.js +14 -36
  12. package/dist/core/workspace.js.map +1 -1
  13. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  14. package/dist/embedded/just-bash-bootstrap.js +34 -4
  15. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  16. package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
  17. package/dist/embedded/mcp-cli-commands.js +3 -38
  18. package/dist/embedded/mcp-cli-commands.js.map +1 -1
  19. package/dist/gateway/sse-client.d.ts.map +1 -1
  20. package/dist/gateway/sse-client.js +44 -8
  21. package/dist/gateway/sse-client.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +7 -24
  24. package/dist/index.js.map +1 -1
  25. package/dist/instructions/builder.d.ts.map +1 -1
  26. package/dist/instructions/builder.js +2 -1
  27. package/dist/instructions/builder.js.map +1 -1
  28. package/dist/openclaw/plugin-loader.d.ts.map +1 -1
  29. package/dist/openclaw/plugin-loader.js +8 -19
  30. package/dist/openclaw/plugin-loader.js.map +1 -1
  31. package/dist/openclaw/processor.d.ts.map +1 -1
  32. package/dist/openclaw/processor.js +2 -0
  33. package/dist/openclaw/processor.js.map +1 -1
  34. package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
  35. package/dist/openclaw/sandbox-leak.js +1 -6
  36. package/dist/openclaw/sandbox-leak.js.map +1 -1
  37. package/dist/openclaw/session-context.d.ts.map +1 -1
  38. package/dist/openclaw/session-context.js +3 -0
  39. package/dist/openclaw/session-context.js.map +1 -1
  40. package/dist/openclaw/tool-policy.d.ts.map +1 -1
  41. package/dist/openclaw/tool-policy.js +5 -11
  42. package/dist/openclaw/tool-policy.js.map +1 -1
  43. package/dist/openclaw/worker.d.ts.map +1 -1
  44. package/dist/openclaw/worker.js +1 -10
  45. package/dist/openclaw/worker.js.map +1 -1
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +3 -40
  48. package/dist/server.js.map +1 -1
  49. package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
  50. package/dist/shared/audio-provider-suggestions.js +4 -6
  51. package/dist/shared/audio-provider-suggestions.js.map +1 -1
  52. package/dist/shared/tool-implementations.d.ts.map +1 -1
  53. package/dist/shared/tool-implementations.js +62 -24
  54. package/dist/shared/tool-implementations.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/__tests__/processor-harden.test.ts +6 -16
  57. package/src/core/error-handler.ts +5 -20
  58. package/src/core/types.ts +1 -35
  59. package/src/core/workspace.ts +22 -45
  60. package/src/embedded/just-bash-bootstrap.ts +36 -4
  61. package/src/embedded/mcp-cli-commands.ts +9 -6
  62. package/src/gateway/sse-client.ts +45 -8
  63. package/src/index.ts +8 -26
  64. package/src/instructions/builder.ts +2 -3
  65. package/src/openclaw/plugin-loader.ts +15 -19
  66. package/src/openclaw/processor.ts +1 -0
  67. package/src/openclaw/sandbox-leak.ts +1 -6
  68. package/src/openclaw/session-context.ts +3 -0
  69. package/src/openclaw/tool-policy.ts +5 -12
  70. package/src/openclaw/worker.ts +2 -13
  71. package/src/server.ts +1 -5
  72. package/src/shared/audio-provider-suggestions.ts +4 -6
  73. package/src/shared/tool-implementations.ts +57 -16
@@ -9,9 +9,11 @@ import {
9
9
  extractTraceId,
10
10
  flushTracing,
11
11
  SpanStatusCode,
12
+ stripEnv,
12
13
  } from "@lobu/core";
13
14
  import { z } from "zod";
14
15
  import type { WorkerConfig, WorkerExecutor } from "../core/types";
16
+ import { SENSITIVE_WORKER_ENV_KEYS } from "../shared/worker-env-keys";
15
17
  import { HttpWorkerTransport } from "./gateway-integration";
16
18
  import { MessageBatcher } from "./message-batcher";
17
19
  import type { MessagePayload, QueuedMessage } from "./types";
@@ -574,16 +576,27 @@ export class GatewayClient {
574
576
  });
575
577
 
576
578
  let completed = false;
579
+ let sigkillTimer: NodeJS.Timeout | null = null;
577
580
 
578
581
  try {
579
- // Spawn the command
582
+ // Strip the worker's own gateway credentials before handing the shell
583
+ // its env. An `exec` command is an arbitrary string from the gateway
584
+ // that ends up under `sh -c`; leaking WORKER_TOKEN / DISPATCHER_URL
585
+ // into that environment would let a malicious or buggy exec impersonate
586
+ // the worker against its own gateway. The bash-tool and just-bash
587
+ // spawners already apply the same filter (see openclaw/tools.ts and
588
+ // embedded/just-bash-bootstrap.ts) — keep parity here.
589
+ const baseEnv = stripEnv(process.env, SENSITIVE_WORKER_ENV_KEYS);
580
590
  const proc = spawn("sh", ["-c", execCommand], {
581
591
  cwd: workingDir,
582
- env: { ...process.env, ...execEnv },
592
+ env: { ...baseEnv, ...execEnv },
583
593
  stdio: ["ignore", "pipe", "pipe"],
584
594
  });
585
595
 
586
- // Setup timeout
596
+ // Setup timeout. The SIGKILL escalation timer is tracked so the `close`
597
+ // handler can clear it when the child exits between SIGTERM and SIGKILL;
598
+ // otherwise the timer pins the event loop for an extra 5s after every
599
+ // timed-out exec and (worse) leaks if `close`/`error` never fires.
587
600
  const timeoutId = setTimeout(() => {
588
601
  if (!completed) {
589
602
  logger.warn(
@@ -591,7 +604,8 @@ export class GatewayClient {
591
604
  "Exec timeout reached, killing process"
592
605
  );
593
606
  proc.kill("SIGTERM");
594
- setTimeout(() => {
607
+ sigkillTimer = setTimeout(() => {
608
+ sigkillTimer = null;
595
609
  if (!completed) {
596
610
  proc.kill("SIGKILL");
597
611
  }
@@ -600,7 +614,7 @@ export class GatewayClient {
600
614
  }, timeout);
601
615
 
602
616
  // Stream stdout
603
- proc.stdout?.on("data", (chunk: Buffer) => {
617
+ const onStdout = (chunk: Buffer) => {
604
618
  const content = chunk.toString();
605
619
  transport.sendExecOutput(execId, "stdout", content).catch((err) => {
606
620
  logger.error(
@@ -608,10 +622,11 @@ export class GatewayClient {
608
622
  "Failed to send stdout"
609
623
  );
610
624
  });
611
- });
625
+ };
626
+ proc.stdout?.on("data", onStdout);
612
627
 
613
628
  // Stream stderr
614
- proc.stderr?.on("data", (chunk: Buffer) => {
629
+ const onStderr = (chunk: Buffer) => {
615
630
  const content = chunk.toString();
616
631
  transport.sendExecOutput(execId, "stderr", content).catch((err) => {
617
632
  logger.error(
@@ -619,19 +634,34 @@ export class GatewayClient {
619
634
  "Failed to send stderr"
620
635
  );
621
636
  });
622
- });
637
+ };
638
+ proc.stderr?.on("data", onStderr);
623
639
 
624
640
  // Wait for process to complete
625
641
  const exitCode = await new Promise<number>((resolve, reject) => {
626
642
  proc.on("close", (code) => {
627
643
  completed = true;
628
644
  clearTimeout(timeoutId);
645
+ if (sigkillTimer) {
646
+ clearTimeout(sigkillTimer);
647
+ sigkillTimer = null;
648
+ }
649
+ // Stop accepting late `data` events so a chunk buffered after exit
650
+ // can't fire `sendExecOutput` AFTER we've signalled completion.
651
+ proc.stdout?.removeListener("data", onStdout);
652
+ proc.stderr?.removeListener("data", onStderr);
629
653
  resolve(code ?? 0);
630
654
  });
631
655
 
632
656
  proc.on("error", (error) => {
633
657
  completed = true;
634
658
  clearTimeout(timeoutId);
659
+ if (sigkillTimer) {
660
+ clearTimeout(sigkillTimer);
661
+ sigkillTimer = null;
662
+ }
663
+ proc.stdout?.removeListener("data", onStdout);
664
+ proc.stderr?.removeListener("data", onStderr);
635
665
  reject(error);
636
666
  });
637
667
  });
@@ -663,6 +693,13 @@ export class GatewayClient {
663
693
 
664
694
  logger.error({ traceId, execId, error: errorMessage }, "Exec failed");
665
695
  } finally {
696
+ // Defensive: if we threw before `close`/`error` fired (e.g. transport
697
+ // throwing during sendExecOutput on a long-running child), make sure
698
+ // the SIGKILL escalation timer doesn't outlive this exec.
699
+ if (sigkillTimer) {
700
+ clearTimeout(sigkillTimer);
701
+ sigkillTimer = null;
702
+ }
666
703
  this.currentJobId = undefined;
667
704
  }
668
705
  }
package/src/index.ts CHANGED
@@ -16,7 +16,6 @@ import { startWorkerHttpServer, stopWorkerHttpServer } from "./server";
16
16
  * Main entry point for gateway-based persistent worker
17
17
  */
18
18
  async function main() {
19
- // Register global rejection/exception handlers early
20
19
  process.on("unhandledRejection", (reason) => {
21
20
  logger.error("Unhandled rejection:", reason);
22
21
  process.exit(1);
@@ -29,10 +28,8 @@ async function main() {
29
28
 
30
29
  logger.info("Starting worker...");
31
30
 
32
- // Initialize Sentry for error tracking
33
31
  await initSentry();
34
32
 
35
- // Initialize OpenTelemetry tracing for distributed tracing
36
33
  const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
37
34
  if (otlpEndpoint) {
38
35
  initTracing({
@@ -42,16 +39,12 @@ async function main() {
42
39
  logger.info(`Tracing initialized: lobu-worker -> ${otlpEndpoint}`);
43
40
  }
44
41
 
45
- // Discover and register available modules
46
42
  await moduleRegistry.registerAvailableModules();
47
-
48
- // Initialize all registered modules
49
43
  await moduleRegistry.initAll();
50
44
  logger.info("✅ Modules initialized");
51
45
 
52
46
  logger.info("🔄 Starting in gateway mode (SSE/HTTP-based persistent worker)");
53
47
 
54
- // Get user ID from environment
55
48
  const userId = process.env.USER_ID;
56
49
 
57
50
  if (!userId) {
@@ -62,7 +55,6 @@ async function main() {
62
55
  }
63
56
 
64
57
  try {
65
- // Get required environment variables
66
58
  const deploymentName = process.env.DEPLOYMENT_NAME;
67
59
  const dispatcherUrl = process.env.DISPATCHER_URL;
68
60
  const workerToken = process.env.WORKER_TOKEN;
@@ -80,11 +72,9 @@ async function main() {
80
72
  process.exit(1);
81
73
  }
82
74
 
83
- // Start HTTP server before connecting to gateway
84
75
  const httpPort = await startWorkerHttpServer();
85
76
  logger.info(`Worker HTTP server started on port ${httpPort}`);
86
77
 
87
- // Initialize gateway client directly
88
78
  logger.info(`🚀 Starting Gateway-based Persistent Worker`);
89
79
  logger.info(`- User ID: ${userId}`);
90
80
  logger.info(`- Deployment: ${deploymentName}`);
@@ -100,33 +90,25 @@ async function main() {
100
90
 
101
91
  // Register signal handlers before async operations
102
92
  let isShuttingDown = false;
103
-
104
- process.on("SIGTERM", async () => {
93
+ const shutdown = async (signal: NodeJS.Signals) => {
105
94
  if (isShuttingDown) return;
106
95
  isShuttingDown = true;
107
- logger.info("Received SIGTERM, shutting down gateway worker...");
96
+ logger.info(`Received ${signal}, shutting down gateway worker...`);
108
97
  await gatewayClient.stop();
109
98
  await stopWorkerHttpServer();
110
99
  process.exit(0);
111
- });
100
+ };
112
101
 
113
- process.on("SIGINT", async () => {
114
- if (isShuttingDown) return;
115
- isShuttingDown = true;
116
- logger.info("Received SIGINT, shutting down gateway worker...");
117
- await gatewayClient.stop();
118
- await stopWorkerHttpServer();
119
- process.exit(0);
120
- });
102
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
103
+ process.on("SIGINT", () => shutdown("SIGINT"));
121
104
 
122
105
  logger.info("🔌 Connecting to dispatcher...");
123
106
  await gatewayClient.start();
124
107
  logger.info("✅ Gateway worker started successfully");
125
108
 
126
- // Keep process alive
127
- await new Promise(() => {
128
- // Keep process running indefinitely so we can listen messages from the queue
129
- }); // Wait forever
109
+ await new Promise<never>(() => {
110
+ // Intentionally never resolves — block process until signal.
111
+ });
130
112
  } catch (error) {
131
113
  logger.error("❌ Gateway worker failed:", error);
132
114
  process.exit(1);
@@ -20,10 +20,9 @@ export async function generateCustomInstructions(
20
20
  context: InstructionContext
21
21
  ): Promise<string> {
22
22
  try {
23
+ const sorted = [...providers].sort((a, b) => a.priority - b.priority);
23
24
  const sections: string[] = [];
24
- for (const provider of [...providers].sort(
25
- (a, b) => a.priority - b.priority
26
- )) {
25
+ for (const provider of sorted) {
27
26
  const instructions = await provider.getInstructions(context);
28
27
  if (instructions?.trim()) {
29
28
  sections.push(instructions.trim());
@@ -99,16 +99,15 @@ async function loadSinglePlugin(
99
99
  ): Promise<LoadedPlugin | null> {
100
100
  const { source, slot, config: pluginConfig } = config;
101
101
 
102
- let mod: Record<string, unknown>;
103
- try {
104
- mod = (await import(source)) as Record<string, unknown>;
105
- } catch (err) {
102
+ const mod = await import(source).catch((err) => {
106
103
  throw new Error(
107
104
  `Cannot import "${source}": ${err instanceof Error ? err.message : String(err)}`
108
105
  );
109
- }
106
+ });
110
107
 
111
- const pluginEntrypoint = resolvePluginEntrypoint(mod);
108
+ const pluginEntrypoint = resolvePluginEntrypoint(
109
+ mod as Record<string, unknown>
110
+ );
112
111
  if (!pluginEntrypoint) {
113
112
  logger.warn(`Plugin "${source}" has no registerable entrypoint - skipping`);
114
113
  return null;
@@ -224,22 +223,19 @@ function createShimApi(params: {
224
223
  cwd,
225
224
  } = params;
226
225
  const noop = () => {
227
- /* intentional no-op */
226
+ // No-op stub for shim plugin APIs that this loader does not implement.
228
227
  };
229
228
 
229
+ const prefix = `[plugin:${extractPluginName(source)}]`;
230
230
  const shimLogger = {
231
- info(message: string, ...args: unknown[]) {
232
- logger.info(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
233
- },
234
- warn(message: string, ...args: unknown[]) {
235
- logger.warn(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
236
- },
237
- error(message: string, ...args: unknown[]) {
238
- logger.error(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
239
- },
240
- debug(message: string, ...args: unknown[]) {
241
- logger.debug(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
242
- },
231
+ info: (message: string, ...args: unknown[]) =>
232
+ logger.info(`${prefix} ${message}`, ...args),
233
+ warn: (message: string, ...args: unknown[]) =>
234
+ logger.warn(`${prefix} ${message}`, ...args),
235
+ error: (message: string, ...args: unknown[]) =>
236
+ logger.error(`${prefix} ${message}`, ...args),
237
+ debug: (message: string, ...args: unknown[]) =>
238
+ logger.debug(`${prefix} ${message}`, ...args),
243
239
  };
244
240
 
245
241
  return {
@@ -33,6 +33,7 @@ export class OpenClawProgressProcessor {
33
33
  return false;
34
34
  }
35
35
  const assistantEvent = event.assistantMessageEvent;
36
+ if (!assistantEvent) return false;
36
37
 
37
38
  if (assistantEvent.type === "text_delta") {
38
39
  this.hasStreamedText = true;
@@ -89,12 +89,7 @@ export function checkSandboxLeak(
89
89
  );
90
90
  redacted = redacted.replace(
91
91
  DELIVERY_PHRASE_RE,
92
- (_match, _path: string, _offset: number, _full: string) => {
93
- // Reconstruct the phrase prefix (everything before the path) by
94
- // re-matching on the original substring. Simpler: replace the whole
95
- // match with a generic note.
96
- return "[file was created but not uploaded — use `UploadUserFile` to deliver it]";
97
- }
92
+ "[file was created but not uploaded use `UploadUserFile` to deliver it]"
98
93
  );
99
94
 
100
95
  const note =
@@ -231,6 +231,9 @@ export async function getOpenClawSessionContext(
231
231
  headers: {
232
232
  Authorization: `Bearer ${workerToken}`,
233
233
  },
234
+ // Session context is fetched once per turn; a stalled gateway here would
235
+ // otherwise hang the worker before the agent ever sees the prompt.
236
+ signal: AbortSignal.timeout(30_000),
234
237
  });
235
238
 
236
239
  if (!response.ok) {
@@ -86,10 +86,6 @@ export function isDirectPackageInstallCommand(command: string): boolean {
86
86
  );
87
87
  }
88
88
 
89
- function normalizePattern(pattern: string): string {
90
- return pattern.trim();
91
- }
92
-
93
89
  function normalizeToolName(name: string): string {
94
90
  return name.trim().toLowerCase();
95
91
  }
@@ -108,16 +104,13 @@ export function normalizeToolList(value?: string | string[]): string[] {
108
104
 
109
105
  function parseBashFilter(pattern: string): string | null {
110
106
  const match = pattern.match(/^Bash\(([^:]+):\*\)$/i);
111
- if (!match) {
112
- return null;
113
- }
114
- const prefix = match[1]?.trim();
115
- return prefix ? prefix : null;
107
+ const prefix = match?.[1]?.trim();
108
+ return prefix || null;
116
109
  }
117
110
 
118
111
  function matchesToolPattern(toolName: string, pattern: string): boolean {
119
112
  const normalizedTool = normalizeToolName(toolName);
120
- const normalizedPattern = normalizePattern(pattern);
113
+ const normalizedPattern = pattern.trim();
121
114
  const normalizedPatternLower = normalizedPattern.toLowerCase();
122
115
 
123
116
  if (normalizedPattern === "*") {
@@ -145,11 +138,11 @@ export function buildToolPolicy(params: {
145
138
  const mergedAllowed = [
146
139
  ...(toolsConfig?.allowedTools ?? []),
147
140
  ...allowedPatterns,
148
- ].map(normalizePattern);
141
+ ].map((p) => p.trim());
149
142
  const mergedDenied = [
150
143
  ...(toolsConfig?.deniedTools ?? []),
151
144
  ...deniedPatterns,
152
- ].map(normalizePattern);
145
+ ].map((p) => p.trim());
153
146
 
154
147
  const bashAllowPrefixes = mergedAllowed
155
148
  .map((pattern) => parseBashFilter(pattern))
@@ -281,22 +281,20 @@ export class OpenClawWorker implements WorkerExecutor {
281
281
  this.workspaceManager = new WorkspaceManager(config.workspace);
282
282
  this.progressProcessor = new OpenClawProgressProcessor();
283
283
 
284
- // Verify required environment variables
285
284
  const gatewayUrl = process.env.DISPATCHER_URL;
286
285
  const workerToken = process.env.WORKER_TOKEN;
287
-
288
286
  if (!gatewayUrl || !workerToken) {
289
287
  throw new Error(
290
288
  "DISPATCHER_URL and WORKER_TOKEN environment variables are required"
291
289
  );
292
290
  }
293
-
294
291
  if (!config.teamId) {
295
292
  throw new Error("teamId is required for worker initialization");
296
293
  }
297
294
  if (!config.conversationId) {
298
295
  throw new Error("conversationId is required for worker initialization");
299
296
  }
297
+
300
298
  this.workerTransport = new HttpWorkerTransport({
301
299
  gatewayUrl,
302
300
  workerToken,
@@ -327,13 +325,11 @@ export class OpenClawWorker implements WorkerExecutor {
327
325
  `[TIMING] Worker execute() started at: ${new Date(executeStartTime).toISOString()}`
328
326
  );
329
327
 
330
- // Decode user prompt
331
328
  const userPrompt = Buffer.from(this.config.userPrompt, "base64").toString(
332
329
  "utf-8"
333
330
  );
334
331
  logger.info(`User prompt: ${userPrompt.substring(0, 100)}...`);
335
332
 
336
- // Setup workspace
337
333
  logger.info("Setting up workspace...");
338
334
 
339
335
  await Sentry.startSpan(
@@ -360,13 +356,9 @@ export class OpenClawWorker implements WorkerExecutor {
360
356
  }
361
357
  );
362
358
 
363
- // Setup I/O directories for file handling
364
359
  await this.setupIODirectories();
365
-
366
- // Download input files if any
367
360
  await this.downloadInputFiles();
368
361
 
369
- // Generate custom instructions
370
362
  let customInstructions = await generateCustomInstructions(
371
363
  [
372
364
  new OpenClawCoreInstructionProvider(),
@@ -385,7 +377,7 @@ export class OpenClawWorker implements WorkerExecutor {
385
377
  }
386
378
  );
387
379
 
388
- // Call module onSessionStart hooks to allow modules to modify system prompt
380
+ // Module hooks may modify the system prompt before agent execution.
389
381
  try {
390
382
  const { onSessionStart } = await import("../modules/lifecycle");
391
383
  const moduleContext = await onSessionStart({
@@ -407,7 +399,6 @@ export class OpenClawWorker implements WorkerExecutor {
407
399
  // Add file I/O instructions AFTER module hooks so they aren't overwritten
408
400
  customInstructions += this.getFileIOInstructions();
409
401
 
410
- // Execute AI session
411
402
  logger.info(
412
403
  `[TIMING] Starting OpenClaw session at: ${new Date().toISOString()}`
413
404
  );
@@ -468,7 +459,6 @@ export class OpenClawWorker implements WorkerExecutor {
468
459
  }
469
460
  );
470
461
 
471
- // Collect module data before sending final response
472
462
  const { collectModuleData } = await import("../modules/lifecycle");
473
463
  const moduleData = await collectModuleData({
474
464
  workspaceDir: this.workspaceManager.getCurrentWorkingDirectory(),
@@ -477,7 +467,6 @@ export class OpenClawWorker implements WorkerExecutor {
477
467
  });
478
468
  this.workerTransport.setModuleData(moduleData);
479
469
 
480
- // Handle result
481
470
  if (result.success) {
482
471
  const outputSnapshot = this.progressProcessor.getOutputSnapshot();
483
472
  const hintGatewayUrl = process.env.DISPATCHER_URL;
package/src/server.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * Lightweight Hono server started before SSE gateway connection.
4
4
  */
5
5
 
6
- import { readFile } from "node:fs/promises";
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
7
  import { createServer } from "node:http";
8
8
  import { join } from "node:path";
9
9
  import { getRequestListener } from "@hono/node-server";
@@ -20,7 +20,6 @@ const logger = createLogger("worker-http");
20
20
  const app = new Hono();
21
21
 
22
22
  async function findSessionFile(): Promise<string | null> {
23
- const { readdir, stat } = await import("node:fs/promises");
24
23
  const workspaceDir = getOptionalEnv("WORKSPACE_DIR", "/workspace");
25
24
 
26
25
  // Direct path: {WORKSPACE_DIR}/.openclaw/session.jsonl
@@ -163,10 +162,8 @@ function entryToMessage(entry: SessionEntry): ParsedMessage | null {
163
162
  return null;
164
163
  }
165
164
 
166
- // Health check
167
165
  app.get("/health", (c) => c.json({ status: "ok" }));
168
166
 
169
- // Full session messages with cursor-based pagination
170
167
  app.get("/session/messages", async (c) => {
171
168
  const cursor = c.req.query("cursor");
172
169
  const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
@@ -230,7 +227,6 @@ app.get("/session/messages", async (c) => {
230
227
  }
231
228
  });
232
229
 
233
- // Session stats
234
230
  app.get("/session/stats", async (c) => {
235
231
  try {
236
232
  const sessionPath = await findSessionFile();
@@ -5,11 +5,7 @@ interface AudioProviderSuggestions {
5
5
  usedFallback: boolean;
6
6
  }
7
7
 
8
- const FALLBACK_PROVIDER_ENTRIES = [
9
- { id: "chatgpt" },
10
- { id: "gemini" },
11
- { id: "elevenlabs" },
12
- ] as const;
8
+ const FALLBACK_PROVIDER_IDS = ["chatgpt", "gemini", "elevenlabs"] as const;
13
9
 
14
10
  const KNOWN_PROVIDER_LABELS: Record<string, string> = {
15
11
  chatgpt: "ChatGPT/OpenAI",
@@ -48,7 +44,7 @@ function getFallbackSuggestions(
48
44
  available: boolean | null
49
45
  ): AudioProviderSuggestions {
50
46
  return {
51
- providerIds: FALLBACK_PROVIDER_ENTRIES.map((entry) => entry.id),
47
+ providerIds: [...FALLBACK_PROVIDER_IDS],
52
48
  providerDisplayList: "",
53
49
  available,
54
50
  usedFallback: true,
@@ -119,6 +115,8 @@ export async function fetchAudioProviderSuggestions(params: {
119
115
  `${params.gatewayUrl}/internal/audio/capabilities`,
120
116
  {
121
117
  headers: { Authorization: `Bearer ${params.workerToken}` },
118
+ // Capability probing is best-effort; never block the agent turn on it.
119
+ signal: AbortSignal.timeout(15_000),
122
120
  }
123
121
  );
124
122
  if (!response.ok) {
@@ -285,17 +285,30 @@ export async function uploadUserFile(
285
285
  const formDataBuffer = await formDataToBuffer(formData);
286
286
  const fdHeaders = formData.getHeaders();
287
287
 
288
- const response = await fetch(`${gw.gatewayUrl}/internal/files/upload`, {
289
- method: "POST",
290
- headers: {
291
- Authorization: `Bearer ${gw.workerToken}`,
292
- "X-Channel-Id": gw.channelId,
293
- "X-Conversation-Id": gw.conversationId,
294
- ...fdHeaders,
295
- "Content-Length": formDataBuffer.length.toString(),
296
- },
297
- body: formDataBuffer,
298
- });
288
+ let response: Response;
289
+ try {
290
+ response = await fetch(`${gw.gatewayUrl}/internal/files/upload`, {
291
+ method: "POST",
292
+ headers: {
293
+ Authorization: `Bearer ${gw.workerToken}`,
294
+ "X-Channel-Id": gw.channelId,
295
+ "X-Conversation-Id": gw.conversationId,
296
+ ...fdHeaders,
297
+ "Content-Length": formDataBuffer.length.toString(),
298
+ },
299
+ body: formDataBuffer,
300
+ // A stalled gateway upload must not wedge the agent turn forever —
301
+ // a 5-minute ceiling is well above any legitimate file delivery.
302
+ signal: AbortSignal.timeout(300_000),
303
+ });
304
+ } catch (err) {
305
+ if (err instanceof Error && err.name === "TimeoutError") {
306
+ return textResult(
307
+ `Error: Failed to show file to user: upload timed out`
308
+ );
309
+ }
310
+ throw err;
311
+ }
299
312
 
300
313
  if (!response.ok) {
301
314
  const error = await response.text();
@@ -582,9 +595,9 @@ async function uploadGeneratedFile(
582
595
  const formDataBuffer = await formDataToBuffer(formData);
583
596
  const fdHeaders = formData.getHeaders();
584
597
 
585
- const uploadResponse = await fetch(
586
- `${gw.gatewayUrl}/internal/files/upload`,
587
- {
598
+ let uploadResponse: Response;
599
+ try {
600
+ uploadResponse = await fetch(`${gw.gatewayUrl}/internal/files/upload`, {
588
601
  method: "POST",
589
602
  headers: {
590
603
  Authorization: `Bearer ${gw.workerToken}`,
@@ -595,8 +608,14 @@ async function uploadGeneratedFile(
595
608
  ...extraHeaders,
596
609
  },
597
610
  body: formDataBuffer,
611
+ signal: AbortSignal.timeout(300_000),
612
+ });
613
+ } catch (err) {
614
+ if (err instanceof Error && err.name === "TimeoutError") {
615
+ return textResult(`Generated content but upload timed out`);
598
616
  }
599
- );
617
+ throw err;
618
+ }
600
619
 
601
620
  if (!uploadResponse.ok) {
602
621
  const uploadError = await uploadResponse.text();
@@ -638,6 +657,7 @@ export async function generateImage(
638
657
  `${gw.gatewayUrl}/internal/images/capabilities`,
639
658
  {
640
659
  headers: { Authorization: `Bearer ${gw.workerToken}` },
660
+ signal: AbortSignal.timeout(30_000),
641
661
  }
642
662
  );
643
663
 
@@ -668,6 +688,9 @@ export async function generateImage(
668
688
  background: args.background,
669
689
  format: args.format,
670
690
  }),
691
+ // Image gen can take a while at high quality, but never minutes — cap
692
+ // the wait so a stalled upstream provider doesn't hang the agent turn.
693
+ signal: AbortSignal.timeout(120_000),
671
694
  });
672
695
 
673
696
  if (!response.ok) {
@@ -758,6 +781,7 @@ export async function generateAudio(
758
781
  voice: args.voice,
759
782
  speed: args.speed,
760
783
  }),
784
+ signal: AbortSignal.timeout(120_000),
761
785
  });
762
786
 
763
787
  if (!response.ok) {
@@ -915,11 +939,28 @@ export async function callMcpTool(
915
939
  throw err;
916
940
  }
917
941
 
918
- const data = (await response.json()) as {
942
+ // MCP proxy returns JSON on success, but a misbehaving upstream (502
943
+ // HTML, plain-text 500, empty body) would otherwise crash the tool call
944
+ // with "Unexpected token < in JSON". Treat parse failure as a transport
945
+ // error message instead of letting it bubble out as an unhandled throw.
946
+ let data: {
919
947
  content?: Array<{ type: string; text: string }>;
920
948
  error?: string;
921
949
  isError?: boolean;
922
950
  };
951
+ try {
952
+ data = (await response.json()) as {
953
+ content?: Array<{ type: string; text: string }>;
954
+ error?: string;
955
+ isError?: boolean;
956
+ };
957
+ } catch (parseErr) {
958
+ const parseMsg =
959
+ parseErr instanceof Error ? parseErr.message : String(parseErr);
960
+ return textResult(
961
+ `Error: ${toolName} returned a non-JSON response (status ${response.status}): ${parseMsg}`
962
+ );
963
+ }
923
964
 
924
965
  if (!response.ok || data.isError) {
925
966
  const contentText = data.content