@lobu/worker 7.0.0 → 7.2.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 (84) 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 +19 -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 +72 -10
  21. package/dist/gateway/sse-client.js.map +1 -1
  22. package/dist/gateway/types.d.ts +2 -0
  23. package/dist/gateway/types.d.ts.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -24
  26. package/dist/index.js.map +1 -1
  27. package/dist/instructions/builder.d.ts.map +1 -1
  28. package/dist/instructions/builder.js +2 -1
  29. package/dist/instructions/builder.js.map +1 -1
  30. package/dist/openclaw/plugin-loader.d.ts.map +1 -1
  31. package/dist/openclaw/plugin-loader.js +8 -19
  32. package/dist/openclaw/plugin-loader.js.map +1 -1
  33. package/dist/openclaw/processor.d.ts.map +1 -1
  34. package/dist/openclaw/processor.js +2 -0
  35. package/dist/openclaw/processor.js.map +1 -1
  36. package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
  37. package/dist/openclaw/sandbox-leak.js +1 -6
  38. package/dist/openclaw/sandbox-leak.js.map +1 -1
  39. package/dist/openclaw/session-context.d.ts.map +1 -1
  40. package/dist/openclaw/session-context.js +3 -0
  41. package/dist/openclaw/session-context.js.map +1 -1
  42. package/dist/openclaw/tool-policy.d.ts.map +1 -1
  43. package/dist/openclaw/tool-policy.js +5 -11
  44. package/dist/openclaw/tool-policy.js.map +1 -1
  45. package/dist/openclaw/transcript-snapshot.d.ts +88 -0
  46. package/dist/openclaw/transcript-snapshot.d.ts.map +1 -0
  47. package/dist/openclaw/transcript-snapshot.js +223 -0
  48. package/dist/openclaw/transcript-snapshot.js.map +1 -0
  49. package/dist/openclaw/worker.d.ts +14 -0
  50. package/dist/openclaw/worker.d.ts.map +1 -1
  51. package/dist/openclaw/worker.js +147 -10
  52. package/dist/openclaw/worker.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +3 -40
  55. package/dist/server.js.map +1 -1
  56. package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
  57. package/dist/shared/audio-provider-suggestions.js +4 -6
  58. package/dist/shared/audio-provider-suggestions.js.map +1 -1
  59. package/dist/shared/tool-implementations.d.ts.map +1 -1
  60. package/dist/shared/tool-implementations.js +62 -24
  61. package/dist/shared/tool-implementations.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/__tests__/processor-harden.test.ts +6 -16
  64. package/src/__tests__/sse-client.test.ts +99 -0
  65. package/src/__tests__/transcript-snapshot.test.ts +275 -0
  66. package/src/core/error-handler.ts +5 -20
  67. package/src/core/types.ts +19 -35
  68. package/src/core/workspace.ts +22 -45
  69. package/src/embedded/just-bash-bootstrap.ts +36 -4
  70. package/src/embedded/mcp-cli-commands.ts +9 -6
  71. package/src/gateway/sse-client.ts +87 -22
  72. package/src/gateway/types.ts +15 -0
  73. package/src/index.ts +8 -26
  74. package/src/instructions/builder.ts +2 -3
  75. package/src/openclaw/plugin-loader.ts +15 -19
  76. package/src/openclaw/processor.ts +1 -0
  77. package/src/openclaw/sandbox-leak.ts +1 -6
  78. package/src/openclaw/session-context.ts +3 -0
  79. package/src/openclaw/tool-policy.ts +5 -12
  80. package/src/openclaw/transcript-snapshot.ts +238 -0
  81. package/src/openclaw/worker.ts +167 -13
  82. package/src/server.ts +1 -5
  83. package/src/shared/audio-provider-suggestions.ts +4 -6
  84. package/src/shared/tool-implementations.ts +57 -16
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Unit tests for the worker-side per-run snapshot helpers
3
+ * (`hydrateFromSnapshot`, `writeSnapshot`).
4
+ *
5
+ * These exercise the HTTP-client side of the snapshot path against a mocked
6
+ * gateway. Coverage:
7
+ * - Hydrate writes the gateway's bytes verbatim to disk, fsyncs, returns
8
+ * the post-hydrate file size matching the byte_size column contract.
9
+ * - Hydrate handles 404 (no completed snapshot) → returns false, leaves
10
+ * the local file untouched.
11
+ * - Hydrate failures are non-fatal at the caller's discretion (we re-throw,
12
+ * caller logs+continues; behaviour verified in worker.ts but we assert
13
+ * the throw shape here).
14
+ * - writeSnapshot reads the session file, POSTs body, handles 409 (race
15
+ * win), missing file (early-exit worker), and empty file all silently.
16
+ * - The transport layer never throws — `cleanup()` runs in the worker's
17
+ * dying breath and any throw would abort the surrounding `finally`.
18
+ *
19
+ * The gateway test (`packages/server/src/gateway/__tests__/
20
+ * agent-transcript-snapshot.test.ts`) covers the route + PG side; this
21
+ * file is the symmetric client side.
22
+ */
23
+
24
+ import { promises as fs } from "node:fs";
25
+ import { mkdtemp, rm } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
29
+ import {
30
+ hydrateFromSnapshot,
31
+ writeSnapshot,
32
+ } from "../openclaw/transcript-snapshot";
33
+
34
+ let tmp: string;
35
+ let originalFetch: typeof globalThis.fetch;
36
+
37
+ beforeEach(async () => {
38
+ tmp = await mkdtemp(join(tmpdir(), "snapshot-test-"));
39
+ originalFetch = globalThis.fetch;
40
+ });
41
+
42
+ afterEach(async () => {
43
+ globalThis.fetch = originalFetch;
44
+ await rm(tmp, { recursive: true, force: true });
45
+ });
46
+
47
+ function stubFetch(
48
+ handler: (url: string, init: RequestInit) => Response
49
+ ): void {
50
+ globalThis.fetch = mock(
51
+ async (input: RequestInfo | URL, init?: RequestInit) => {
52
+ const url = typeof input === "string" ? input : input.toString();
53
+ return handler(url, init ?? {});
54
+ }
55
+ ) as unknown as typeof globalThis.fetch;
56
+ }
57
+
58
+ describe("hydrateFromSnapshot", () => {
59
+ test("boot-hydrate-fsync: writes bytes verbatim, file size matches body length", async () => {
60
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
61
+ const expected =
62
+ `{"type":"session","version":3,"id":"hydrate","timestamp":"2026-05-18T10:00:00Z","cwd":"/w"}\n` +
63
+ `{"type":"message","id":"m1","parentId":null,"timestamp":"2026-05-18T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"resume"}]}}\n`;
64
+
65
+ stubFetch((url, init) => {
66
+ expect(url.endsWith("/worker/transcript/snapshot")).toBe(true);
67
+ expect(init.method).toBe("GET");
68
+ expect((init.headers as Record<string, string>).Authorization).toBe(
69
+ "Bearer test-jwt"
70
+ );
71
+ return new Response(expected, { status: 200 });
72
+ });
73
+
74
+ const hydrated = await hydrateFromSnapshot({
75
+ sessionFile,
76
+ gatewayUrl: "http://gw.test/lobu",
77
+ workerToken: "test-jwt",
78
+ });
79
+ expect(hydrated).toBe(true);
80
+
81
+ // File written + fsynced → stat size matches byte_size we'd compute.
82
+ const stats = await fs.stat(sessionFile);
83
+ expect(stats.size).toBe(Buffer.byteLength(expected, "utf-8"));
84
+ const back = await fs.readFile(sessionFile, "utf-8");
85
+ expect(back).toBe(expected);
86
+ });
87
+
88
+ test("returns false on 404 and does not touch the file", async () => {
89
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
90
+ stubFetch(() => new Response("", { status: 404 }));
91
+
92
+ const hydrated = await hydrateFromSnapshot({
93
+ sessionFile,
94
+ gatewayUrl: "http://gw.test/lobu",
95
+ workerToken: "test-jwt",
96
+ });
97
+ expect(hydrated).toBe(false);
98
+ // No file created.
99
+ let exists = false;
100
+ try {
101
+ await fs.stat(sessionFile);
102
+ exists = true;
103
+ } catch {
104
+ exists = false;
105
+ }
106
+ expect(exists).toBe(false);
107
+ });
108
+
109
+ test("throws on non-2xx, non-404 — caller logs + continues with local file", async () => {
110
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
111
+ stubFetch(() => new Response("boom", { status: 500 }));
112
+ await expect(
113
+ hydrateFromSnapshot({
114
+ sessionFile,
115
+ gatewayUrl: "http://gw.test/lobu",
116
+ workerToken: "test-jwt",
117
+ })
118
+ ).rejects.toThrow(/transcript hydrate failed: 500/);
119
+ });
120
+ });
121
+
122
+ describe("writeSnapshot", () => {
123
+ test("happy path: reads file, POSTs body + terminalStatus, gateway 200", async () => {
124
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
125
+ await fs.mkdir(join(tmp, ".openclaw"), { recursive: true });
126
+ const body =
127
+ `{"type":"session","version":3,"id":"write","timestamp":"2026-05-18T10:00:00Z","cwd":"/w"}\n` +
128
+ `{"type":"message","id":"u1","parentId":null,"timestamp":"2026-05-18T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}\n`;
129
+ await fs.writeFile(sessionFile, body, "utf-8");
130
+
131
+ let postedBody: string | null = null;
132
+ stubFetch((url, init) => {
133
+ expect(url.endsWith("/worker/transcript/snapshot")).toBe(true);
134
+ expect(init.method).toBe("POST");
135
+ postedBody = init.body as string;
136
+ return new Response('{"id":1}', { status: 200 });
137
+ });
138
+
139
+ await writeSnapshot({
140
+ sessionFile,
141
+ gatewayUrl: "http://gw.test/lobu",
142
+ workerToken: "test-jwt",
143
+ terminalStatus: "completed",
144
+ runId: 42,
145
+ });
146
+ expect(postedBody).not.toBeNull();
147
+ const parsed = JSON.parse(postedBody!);
148
+ expect(parsed.snapshotJsonl).toBe(body);
149
+ expect(parsed.terminalStatus).toBe("completed");
150
+ // P1#1: runId MUST be on the POST body so the route attributes the
151
+ // snapshot to the exact run this worker claimed, not "latest run for
152
+ // (org, agent, conv)".
153
+ expect(parsed.runId).toBe(42);
154
+ });
155
+
156
+ test("non-completed terminalStatus is skipped (no POST, no waste)", async () => {
157
+ // Hydrate filters terminal_status='completed' — writing failed/
158
+ // timeout/cancelled rows is pure network waste. Codex round 2
159
+ // quality win C on PR #865. The cleanup() path is also gated on
160
+ // `terminalStatus === "completed"`, but writeSnapshot defends in
161
+ // depth so any future caller can't accidentally write a row that
162
+ // hydrate will never read.
163
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
164
+ await fs.mkdir(join(tmp, ".openclaw"), { recursive: true });
165
+ await fs.writeFile(sessionFile, `{"type":"session"}\n`, "utf-8");
166
+
167
+ let calls = 0;
168
+ stubFetch(() => {
169
+ calls++;
170
+ return new Response("{}", { status: 200 });
171
+ });
172
+
173
+ for (const terminalStatus of ["failed", "timeout", "cancelled"] as const) {
174
+ await writeSnapshot({
175
+ sessionFile,
176
+ gatewayUrl: "http://gw.test/lobu",
177
+ workerToken: "test-jwt",
178
+ terminalStatus,
179
+ runId: 42,
180
+ });
181
+ }
182
+ expect(calls).toBe(0);
183
+ });
184
+
185
+ test("race-win-409 is benign — no throw", async () => {
186
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
187
+ await fs.mkdir(join(tmp, ".openclaw"), { recursive: true });
188
+ await fs.writeFile(sessionFile, `{"type":"session"}\n`, "utf-8");
189
+
190
+ stubFetch(() => new Response("conflict", { status: 409 }));
191
+
192
+ // No throw — cleanup() in the worker's dying breath must never
193
+ // re-throw inside a `finally`.
194
+ await writeSnapshot({
195
+ sessionFile,
196
+ gatewayUrl: "http://gw.test/lobu",
197
+ workerToken: "test-jwt",
198
+ terminalStatus: "completed",
199
+ runId: 42,
200
+ });
201
+ });
202
+
203
+ test("no session file (early-exit worker): silently skips, no fetch", async () => {
204
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
205
+ let calls = 0;
206
+ stubFetch(() => {
207
+ calls++;
208
+ return new Response("", { status: 200 });
209
+ });
210
+
211
+ await writeSnapshot({
212
+ sessionFile,
213
+ gatewayUrl: "http://gw.test/lobu",
214
+ workerToken: "test-jwt",
215
+ terminalStatus: "failed",
216
+ runId: 42,
217
+ });
218
+ expect(calls).toBe(0);
219
+ });
220
+
221
+ test("empty session file is skipped — never POST an empty snapshot", async () => {
222
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
223
+ await fs.mkdir(join(tmp, ".openclaw"), { recursive: true });
224
+ await fs.writeFile(sessionFile, "", "utf-8");
225
+ let calls = 0;
226
+ stubFetch(() => {
227
+ calls++;
228
+ return new Response("{}", { status: 200 });
229
+ });
230
+
231
+ await writeSnapshot({
232
+ sessionFile,
233
+ gatewayUrl: "http://gw.test/lobu",
234
+ workerToken: "test-jwt",
235
+ terminalStatus: "completed",
236
+ runId: 42,
237
+ });
238
+ expect(calls).toBe(0);
239
+ });
240
+
241
+ test("server 500 is logged, not thrown", async () => {
242
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
243
+ await fs.mkdir(join(tmp, ".openclaw"), { recursive: true });
244
+ await fs.writeFile(sessionFile, `{"type":"session"}\n`, "utf-8");
245
+ stubFetch(() => new Response("boom", { status: 500 }));
246
+
247
+ // No throw — same invariant as the 409 case. Logs go to pino; we
248
+ // don't assert log content here.
249
+ await writeSnapshot({
250
+ sessionFile,
251
+ gatewayUrl: "http://gw.test/lobu",
252
+ workerToken: "test-jwt",
253
+ terminalStatus: "completed",
254
+ runId: 42,
255
+ });
256
+ });
257
+
258
+ test("fetch throw is caught — cleanup() must never re-throw", async () => {
259
+ const sessionFile = join(tmp, ".openclaw", "session.jsonl");
260
+ await fs.mkdir(join(tmp, ".openclaw"), { recursive: true });
261
+ await fs.writeFile(sessionFile, `{"type":"session"}\n`, "utf-8");
262
+ globalThis.fetch = (() => {
263
+ throw new Error("ECONNREFUSED");
264
+ }) as unknown as typeof globalThis.fetch;
265
+
266
+ // No throw escapes — caller is the cleanup() finally block.
267
+ await writeSnapshot({
268
+ sessionFile,
269
+ gatewayUrl: "http://gw.test/lobu",
270
+ workerToken: "test-jwt",
271
+ terminalStatus: "completed",
272
+ runId: 42,
273
+ });
274
+ });
275
+ });
@@ -2,10 +2,6 @@ import { createLogger, type WorkerTransport } from "@lobu/core";
2
2
 
3
3
  const logger = createLogger("worker");
4
4
 
5
- /**
6
- * Format error message for display
7
- * Generic error formatter that works for any AI agent
8
- */
9
5
  function formatErrorMessage(error: unknown): string {
10
6
  if (!(error instanceof Error)) {
11
7
  return `💥 Worker crashed: Unknown error`;
@@ -27,10 +23,6 @@ function classifyError(error: unknown): string | undefined {
27
23
  return undefined;
28
24
  }
29
25
 
30
- /**
31
- * Handle execution error - decides between authentication and generic errors
32
- * Generic error handler that works for any AI agent
33
- */
34
26
  export async function handleExecutionError(
35
27
  error: unknown,
36
28
  transport: WorkerTransport
@@ -38,25 +30,18 @@ export async function handleExecutionError(
38
30
  logger.error("Worker execution failed:", error);
39
31
 
40
32
  const code = classifyError(error);
33
+ const errorInstance =
34
+ error instanceof Error ? error : new Error(String(error));
41
35
 
42
36
  try {
43
37
  if (code) {
44
- // Known error — clean message, no "Worker crashed" text
45
- await transport.signalError(
46
- error instanceof Error ? error : new Error(String(error)),
47
- code
48
- );
38
+ await transport.signalError(errorInstance, code);
49
39
  } else {
50
- // Unknown error existing behavior
51
- const errorMsg = formatErrorMessage(error);
52
- await transport.sendStreamDelta(errorMsg, true, true);
53
- await transport.signalError(
54
- error instanceof Error ? error : new Error(String(error))
55
- );
40
+ await transport.sendStreamDelta(formatErrorMessage(error), true, true);
41
+ await transport.signalError(errorInstance);
56
42
  }
57
43
  } catch (gatewayError) {
58
44
  logger.error("Failed to send error via gateway:", gatewayError);
59
- // Re-throw the original error
60
45
  throw error;
61
46
  }
62
47
  }
package/src/core/types.ts CHANGED
@@ -1,41 +1,16 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- /**
4
- * Consolidated types for worker package
5
- * Merged from: base/types.ts, types.ts, interfaces.ts
6
- */
7
-
8
3
  import type { WorkerTransport } from "@lobu/core";
9
4
 
10
- // ============================================================================
11
- // WORKER INTERFACES
12
- // ============================================================================
13
-
14
5
  /**
15
- * Interface for worker executors
16
- * Allows different agent implementations
6
+ * Interface for worker executors. Allows different agent implementations.
17
7
  */
18
8
  export interface WorkerExecutor {
19
- /**
20
- * Execute the worker job
21
- */
22
9
  execute(): Promise<void>;
23
-
24
- /**
25
- * Cleanup worker resources
26
- */
27
10
  cleanup(): Promise<void>;
28
-
29
- /**
30
- * Get the worker transport for sending updates to gateway
31
- */
32
11
  getWorkerTransport(): WorkerTransport | null;
33
12
  }
34
13
 
35
- // ============================================================================
36
- // WORKER CONFIG & WORKSPACE
37
- // ============================================================================
38
-
39
14
  export interface WorkerConfig {
40
15
  sessionKey: string;
41
16
  userId: string;
@@ -53,6 +28,24 @@ export interface WorkerConfig {
53
28
  workspace: {
54
29
  baseDirectory: string;
55
30
  };
31
+ /**
32
+ * The runs.id of the row that dispatched this job. Set by the gateway
33
+ * (MessageConsumer stamps it from the runs-queue claim's job.id) so the
34
+ * worker's cleanup() snapshot can attribute itself to the correct run
35
+ * even when a follow-up run for the same conversation has already been
36
+ * enqueued (codex P1#1 on PR #865). Optional for backward-compatibility
37
+ * with legacy direct-enqueue paths that don't go through the runs queue.
38
+ */
39
+ runId?: number;
40
+ /**
41
+ * Per-run worker JWT bound to `runId`. Set by MessageConsumer at
42
+ * dispatch time and used by cleanup()'s writeSnapshot call as the
43
+ * Authorization bearer — replaces the deployment-lifetime WORKER_TOKEN
44
+ * for the snapshot path so the gateway's route can require token-runId
45
+ * equality with body.runId (codex round 2 finding A on PR #865).
46
+ * When absent (legacy direct-enqueue), the snapshot write is skipped.
47
+ */
48
+ runJobToken?: string;
56
49
  }
57
50
 
58
51
  export interface WorkspaceSetupConfig {
@@ -64,10 +57,6 @@ export interface WorkspaceInfo {
64
57
  userDirectory: string;
65
58
  }
66
59
 
67
- // ============================================================================
68
- // PROGRESS & EXECUTION TYPES
69
- // ============================================================================
70
-
71
60
  /**
72
61
  * Progress update from AI agent execution
73
62
  */
@@ -109,11 +98,6 @@ export type ProgressUpdate =
109
98
  timestamp: number;
110
99
  };
111
100
 
112
- /**
113
- * Session context for AI execution
114
- * Contains information about the current session (platform, user, workspace)
115
- */
116
-
117
101
  /**
118
102
  * Result from session execution (includes session metadata)
119
103
  */
@@ -8,17 +8,12 @@ import type { WorkspaceInfo, WorkspaceSetupConfig } from "./types";
8
8
 
9
9
  const logger = createLogger("workspace");
10
10
 
11
- // ============================================================================
12
- // WORKSPACE MANAGER
13
- // ============================================================================
14
-
15
11
  /**
16
- * Simplified WorkspaceManager - only handles directory creation.
17
- * All VCS operations (git, etc.) are handled by modules via hooks.
18
- *
19
12
  * Workspace layout:
20
13
  * baseDirectory/ ← agent-level root (e.g. /workspace)
21
14
  * baseDirectory/{conversationId}/ ← thread-specific working directory
15
+ *
16
+ * VCS operations (git, etc.) are handled by modules via hooks.
22
17
  */
23
18
  export class WorkspaceManager {
24
19
  private config: WorkspaceSetupConfig;
@@ -28,39 +23,23 @@ export class WorkspaceManager {
28
23
  this.config = config;
29
24
  }
30
25
 
31
- /**
32
- * Setup workspace directory - creates thread-specific directory only.
33
- * VCS operations are handled by module hooks (e.g., GitHub module).
34
- */
35
26
  async setupWorkspace(
36
27
  username: string,
37
28
  sessionKey?: string
38
29
  ): Promise<WorkspaceInfo> {
39
- try {
40
- const conversationId =
41
- process.env.CONVERSATION_ID || sessionKey || username || "default";
30
+ const conversationId =
31
+ process.env.CONVERSATION_ID || sessionKey || username || "default";
42
32
 
43
- logger.info(
44
- `Setting up workspace directory for ${username}, conversation: ${conversationId}...`
45
- );
46
-
47
- const sanitized = sanitizeConversationId(conversationId);
48
- const userDirectory = `${this.config.baseDirectory}/${sanitized}`;
33
+ logger.info(
34
+ `Setting up workspace directory for ${username}, conversation: ${conversationId}...`
35
+ );
49
36
 
50
- // Ensure directories exist
51
- await this.ensureDirectory(this.config.baseDirectory);
52
- await this.ensureDirectory(userDirectory);
37
+ const sanitized = sanitizeConversationId(conversationId);
38
+ const userDirectory = `${this.config.baseDirectory}/${sanitized}`;
53
39
 
54
- this.workspaceInfo = {
55
- baseDirectory: this.config.baseDirectory,
56
- userDirectory,
57
- };
58
-
59
- logger.info(
60
- `Workspace directory setup completed for ${username} (conversation: ${conversationId}) at ${userDirectory}`
61
- );
62
-
63
- return this.workspaceInfo;
40
+ try {
41
+ await mkdir(this.config.baseDirectory, { recursive: true });
42
+ await mkdir(userDirectory, { recursive: true });
64
43
  } catch (error) {
65
44
  throw new WorkspaceError(
66
45
  "setupWorkspace",
@@ -68,21 +47,19 @@ export class WorkspaceManager {
68
47
  error as Error
69
48
  );
70
49
  }
71
- }
72
50
 
73
- private async ensureDirectory(path: string): Promise<void> {
74
- try {
75
- await mkdir(path, { recursive: true });
76
- } catch (error: any) {
77
- if (error.code !== "EEXIST") {
78
- throw error;
79
- }
80
- }
51
+ this.workspaceInfo = {
52
+ baseDirectory: this.config.baseDirectory,
53
+ userDirectory,
54
+ };
55
+
56
+ logger.info(
57
+ `Workspace directory setup completed for ${username} (conversation: ${conversationId}) at ${userDirectory}`
58
+ );
59
+
60
+ return this.workspaceInfo;
81
61
  }
82
62
 
83
- /**
84
- * Get current working directory (thread-specific).
85
- */
86
63
  getCurrentWorkingDirectory(): string {
87
64
  return this.workspaceInfo?.userDirectory || this.config.baseDirectory;
88
65
  }
@@ -248,6 +248,13 @@ async function buildCustomCommands(
248
248
  } else if (ctx.env && typeof ctx.env === "object") {
249
249
  Object.assign(envRecord, ctx.env);
250
250
  }
251
+ // The agent can `export WORKER_TOKEN=...` inside just-bash to slip a
252
+ // value through `ctx.env`. Re-strip so spawned binaries (and anything
253
+ // that may echo or log env) never see a sensitive-shaped key, even an
254
+ // attacker-controlled one.
255
+ for (const key of SENSITIVE_WORKER_ENV_KEYS) {
256
+ delete envRecord[key];
257
+ }
251
258
 
252
259
  // Pin HOME / TMPDIR to dedicated subdirs so tool dotfiles (~/.gitconfig,
253
260
  // ~/.cache, ~/.config) and temp files don't collide with workspace
@@ -413,14 +420,39 @@ export async function createEmbeddedBashOps(
413
420
  }
414
421
  const bashFs = new ReadWriteFs({ root: workspaceDir });
415
422
 
416
- // Parse allowed domains from env var (set by gateway)
423
+ // Parse allowed domains from env var (set by gateway).
424
+ // Defense-in-depth: the gateway is trusted, but a malformed env (non-array,
425
+ // non-string entries, embedded "/" or whitespace) would either crash
426
+ // `.flatMap(...)` or, worse, expand an "allow https://${domain}/" prefix
427
+ // into something attacker-shaped (`evil.com/ ` or `attacker.com/path`).
428
+ // Validate the parsed shape and the per-domain syntax explicitly.
429
+ const DOMAIN_PATTERN = /^[A-Za-z0-9.*_-]+(?::\d+)?$/;
417
430
  let allowedDomains: string[] = [];
418
431
  if (process.env.JUST_BASH_ALLOWED_DOMAINS) {
419
432
  try {
420
- allowedDomains = JSON.parse(process.env.JUST_BASH_ALLOWED_DOMAINS);
421
- } catch {
433
+ const parsed: unknown = JSON.parse(process.env.JUST_BASH_ALLOWED_DOMAINS);
434
+ if (!Array.isArray(parsed)) {
435
+ throw new Error("expected a JSON array of domain strings");
436
+ }
437
+ const accepted: string[] = [];
438
+ for (const entry of parsed) {
439
+ if (typeof entry !== "string") continue;
440
+ const trimmed = entry.trim();
441
+ if (!trimmed) continue;
442
+ if (!DOMAIN_PATTERN.test(trimmed)) {
443
+ console.warn(
444
+ `[embedded] Ignoring invalid JUST_BASH_ALLOWED_DOMAINS entry: ${JSON.stringify(entry)}`
445
+ );
446
+ continue;
447
+ }
448
+ accepted.push(trimmed);
449
+ }
450
+ allowedDomains = accepted;
451
+ } catch (err) {
422
452
  console.error(
423
- `[embedded] Failed to parse JUST_BASH_ALLOWED_DOMAINS: ${process.env.JUST_BASH_ALLOWED_DOMAINS}`
453
+ `[embedded] Failed to parse JUST_BASH_ALLOWED_DOMAINS: ${
454
+ err instanceof Error ? err.message : String(err)
455
+ }`
424
456
  );
425
457
  }
426
458
  }
@@ -16,7 +16,12 @@
16
16
  import type { McpStatus, McpToolDef } from "@lobu/core";
17
17
  import { createLogger } from "@lobu/core";
18
18
  import type { GatewayParams } from "../shared/tool-implementations";
19
- import { callMcpTool } from "../shared/tool-implementations";
19
+ import {
20
+ callMcpTool,
21
+ checkMcpLogin,
22
+ logoutMcp,
23
+ startMcpLogin,
24
+ } from "../shared/tool-implementations";
20
25
  import { isDirectPackageInstallCommand } from "../openclaw/tool-policy";
21
26
 
22
27
  const logger = createLogger("mcp-cli");
@@ -269,11 +274,9 @@ async function runAuthSubcommand(
269
274
  ref: McpRuntimeRef
270
275
  ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
271
276
  const verb = args[0];
272
- // Lazy import to avoid a heavy dependency cycle in tests.
273
- const impl = await import("../shared/tool-implementations");
274
277
 
275
278
  if (verb === "login") {
276
- const res = await impl.startMcpLogin(gw, { mcpId });
279
+ const res = await startMcpLogin(gw, { mcpId });
277
280
  const text = extractText(res.content);
278
281
  return {
279
282
  stdout: `${summariseAuthStart(text, mcpId)}\n`,
@@ -283,7 +286,7 @@ async function runAuthSubcommand(
283
286
  }
284
287
 
285
288
  if (verb === "check") {
286
- const res = await impl.checkMcpLogin(gw, { mcpId });
289
+ const res = await checkMcpLogin(gw, { mcpId });
287
290
  const text = extractText(res.content);
288
291
  const parsed = tryJson(text);
289
292
  if (parsed?.authenticated === true) {
@@ -297,7 +300,7 @@ async function runAuthSubcommand(
297
300
  }
298
301
 
299
302
  if (verb === "logout") {
300
- const res = await impl.logoutMcp(gw, { mcpId });
303
+ const res = await logoutMcp(gw, { mcpId });
301
304
  const text = extractText(res.content);
302
305
  // Tools that required auth are now unreachable — refresh so the next
303
306
  // invocation sees the empty state.