@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.
- package/dist/core/error-handler.d.ts +0 -4
- package/dist/core/error-handler.d.ts.map +1 -1
- package/dist/core/error-handler.js +4 -15
- package/dist/core/error-handler.js.map +1 -1
- package/dist/core/types.d.ts +19 -19
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +0 -4
- package/dist/core/types.js.map +1 -1
- package/dist/core/workspace.d.ts +2 -11
- package/dist/core/workspace.d.ts.map +1 -1
- package/dist/core/workspace.js +14 -36
- package/dist/core/workspace.js.map +1 -1
- package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
- package/dist/embedded/just-bash-bootstrap.js +34 -4
- package/dist/embedded/just-bash-bootstrap.js.map +1 -1
- package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
- package/dist/embedded/mcp-cli-commands.js +3 -38
- package/dist/embedded/mcp-cli-commands.js.map +1 -1
- package/dist/gateway/sse-client.d.ts.map +1 -1
- package/dist/gateway/sse-client.js +72 -10
- package/dist/gateway/sse-client.js.map +1 -1
- package/dist/gateway/types.d.ts +2 -0
- package/dist/gateway/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -24
- package/dist/index.js.map +1 -1
- package/dist/instructions/builder.d.ts.map +1 -1
- package/dist/instructions/builder.js +2 -1
- package/dist/instructions/builder.js.map +1 -1
- package/dist/openclaw/plugin-loader.d.ts.map +1 -1
- package/dist/openclaw/plugin-loader.js +8 -19
- package/dist/openclaw/plugin-loader.js.map +1 -1
- package/dist/openclaw/processor.d.ts.map +1 -1
- package/dist/openclaw/processor.js +2 -0
- package/dist/openclaw/processor.js.map +1 -1
- package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
- package/dist/openclaw/sandbox-leak.js +1 -6
- package/dist/openclaw/sandbox-leak.js.map +1 -1
- package/dist/openclaw/session-context.d.ts.map +1 -1
- package/dist/openclaw/session-context.js +3 -0
- package/dist/openclaw/session-context.js.map +1 -1
- package/dist/openclaw/tool-policy.d.ts.map +1 -1
- package/dist/openclaw/tool-policy.js +5 -11
- package/dist/openclaw/tool-policy.js.map +1 -1
- package/dist/openclaw/transcript-snapshot.d.ts +88 -0
- package/dist/openclaw/transcript-snapshot.d.ts.map +1 -0
- package/dist/openclaw/transcript-snapshot.js +223 -0
- package/dist/openclaw/transcript-snapshot.js.map +1 -0
- package/dist/openclaw/worker.d.ts +14 -0
- package/dist/openclaw/worker.d.ts.map +1 -1
- package/dist/openclaw/worker.js +147 -10
- package/dist/openclaw/worker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -40
- package/dist/server.js.map +1 -1
- package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
- package/dist/shared/audio-provider-suggestions.js +4 -6
- package/dist/shared/audio-provider-suggestions.js.map +1 -1
- package/dist/shared/tool-implementations.d.ts.map +1 -1
- package/dist/shared/tool-implementations.js +62 -24
- package/dist/shared/tool-implementations.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/processor-harden.test.ts +6 -16
- package/src/__tests__/sse-client.test.ts +99 -0
- package/src/__tests__/transcript-snapshot.test.ts +275 -0
- package/src/core/error-handler.ts +5 -20
- package/src/core/types.ts +19 -35
- package/src/core/workspace.ts +22 -45
- package/src/embedded/just-bash-bootstrap.ts +36 -4
- package/src/embedded/mcp-cli-commands.ts +9 -6
- package/src/gateway/sse-client.ts +87 -22
- package/src/gateway/types.ts +15 -0
- package/src/index.ts +8 -26
- package/src/instructions/builder.ts +2 -3
- package/src/openclaw/plugin-loader.ts +15 -19
- package/src/openclaw/processor.ts +1 -0
- package/src/openclaw/sandbox-leak.ts +1 -6
- package/src/openclaw/session-context.ts +3 -0
- package/src/openclaw/tool-policy.ts +5 -12
- package/src/openclaw/transcript-snapshot.ts +238 -0
- package/src/openclaw/worker.ts +167 -13
- package/src/server.ts +1 -5
- package/src/shared/audio-provider-suggestions.ts +4 -6
- 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
*/
|
package/src/core/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
process.env.CONVERSATION_ID || sessionKey || username || "default";
|
|
30
|
+
const conversationId =
|
|
31
|
+
process.env.CONVERSATION_ID || sessionKey || username || "default";
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
await this.ensureDirectory(userDirectory);
|
|
37
|
+
const sanitized = sanitizeConversationId(conversationId);
|
|
38
|
+
const userDirectory = `${this.config.baseDirectory}/${sanitized}`;
|
|
53
39
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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: ${
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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.
|