@lobu/worker 3.0.5 → 3.0.7
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/USAGE.md +120 -0
- package/docs/custom-base-image.md +88 -0
- package/package.json +2 -2
- package/scripts/worker-entrypoint.sh +184 -0
- package/src/__tests__/audio-provider-suggestions.test.ts +198 -0
- package/src/__tests__/embedded-just-bash-bootstrap.test.ts +39 -0
- package/src/__tests__/embedded-tools.test.ts +558 -0
- package/src/__tests__/instructions.test.ts +59 -0
- package/src/__tests__/memory-flush-runtime.test.ts +138 -0
- package/src/__tests__/memory-flush.test.ts +64 -0
- package/src/__tests__/model-resolver.test.ts +156 -0
- package/src/__tests__/processor.test.ts +225 -0
- package/src/__tests__/setup.ts +109 -0
- package/src/__tests__/sse-client.test.ts +48 -0
- package/src/__tests__/tool-policy.test.ts +269 -0
- package/src/__tests__/worker.test.ts +89 -0
- package/src/core/error-handler.ts +70 -0
- package/src/core/project-scanner.ts +65 -0
- package/src/core/types.ts +125 -0
- package/src/core/url-utils.ts +9 -0
- package/src/core/workspace.ts +138 -0
- package/src/embedded/just-bash-bootstrap.ts +228 -0
- package/src/gateway/gateway-integration.ts +287 -0
- package/src/gateway/message-batcher.ts +128 -0
- package/src/gateway/sse-client.ts +955 -0
- package/src/gateway/types.ts +68 -0
- package/src/index.ts +146 -0
- package/src/instructions/builder.ts +80 -0
- package/src/instructions/providers.ts +27 -0
- package/src/modules/lifecycle.ts +92 -0
- package/src/openclaw/custom-tools.ts +290 -0
- package/src/openclaw/instructions.ts +38 -0
- package/src/openclaw/model-resolver.ts +150 -0
- package/src/openclaw/plugin-loader.ts +427 -0
- package/src/openclaw/processor.ts +216 -0
- package/src/openclaw/session-context.ts +277 -0
- package/src/openclaw/tool-policy.ts +212 -0
- package/src/openclaw/tools.ts +208 -0
- package/src/openclaw/worker.ts +1792 -0
- package/src/server.ts +329 -0
- package/src/shared/audio-provider-suggestions.ts +132 -0
- package/src/shared/processor-utils.ts +33 -0
- package/src/shared/provider-auth-hints.ts +64 -0
- package/src/shared/tool-display-config.ts +75 -0
- package/src/shared/tool-implementations.ts +768 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { GatewayClient } from "../gateway/sse-client";
|
|
3
|
+
|
|
4
|
+
describe("GatewayClient heartbeat ACKs", () => {
|
|
5
|
+
const originalFetch = globalThis.fetch;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
globalThis.fetch = originalFetch;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("ACKs heartbeat pings over the worker response endpoint", async () => {
|
|
12
|
+
const fetchMock = mock(
|
|
13
|
+
async (_url: string | URL | Request, _options?: RequestInit) =>
|
|
14
|
+
new Response(JSON.stringify({ success: true }), {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
20
|
+
|
|
21
|
+
const client = new GatewayClient(
|
|
22
|
+
"https://gateway.example.com",
|
|
23
|
+
"worker-token",
|
|
24
|
+
"user-1",
|
|
25
|
+
"worker-1"
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
await (client as any).handleEvent(
|
|
29
|
+
"ping",
|
|
30
|
+
JSON.stringify({ timestamp: Date.now() })
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
35
|
+
"https://gateway.example.com/worker/response"
|
|
36
|
+
);
|
|
37
|
+
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
Authorization: "Bearer worker-token",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
expect(fetchMock.mock.calls[0]?.[1]?.body).toBe(
|
|
45
|
+
JSON.stringify({ received: true, heartbeat: true })
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type BashCommandPolicy,
|
|
4
|
+
buildToolPolicy,
|
|
5
|
+
enforceBashCommandPolicy,
|
|
6
|
+
isToolAllowedByPolicy,
|
|
7
|
+
normalizeToolList,
|
|
8
|
+
} from "../openclaw/tool-policy";
|
|
9
|
+
|
|
10
|
+
describe("normalizeToolList", () => {
|
|
11
|
+
test("returns empty array for undefined", () => {
|
|
12
|
+
expect(normalizeToolList(undefined)).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns empty array for empty string", () => {
|
|
16
|
+
expect(normalizeToolList("")).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("splits comma-separated string", () => {
|
|
20
|
+
expect(normalizeToolList("read,write,edit")).toEqual([
|
|
21
|
+
"read",
|
|
22
|
+
"write",
|
|
23
|
+
"edit",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("splits newline-separated string", () => {
|
|
28
|
+
expect(normalizeToolList("read\nwrite\nedit")).toEqual([
|
|
29
|
+
"read",
|
|
30
|
+
"write",
|
|
31
|
+
"edit",
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("trims whitespace from entries", () => {
|
|
36
|
+
expect(normalizeToolList(" read , write , edit ")).toEqual([
|
|
37
|
+
"read",
|
|
38
|
+
"write",
|
|
39
|
+
"edit",
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("filters empty entries", () => {
|
|
44
|
+
expect(normalizeToolList("read,,write,,")).toEqual(["read", "write"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("passes through arrays", () => {
|
|
48
|
+
expect(normalizeToolList(["read", "write"])).toEqual(["read", "write"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("buildToolPolicy", () => {
|
|
53
|
+
test("returns default policy with no inputs", () => {
|
|
54
|
+
const policy = buildToolPolicy({});
|
|
55
|
+
expect(policy.allowedPatterns).toEqual([]);
|
|
56
|
+
expect(policy.deniedPatterns).toEqual([]);
|
|
57
|
+
expect(policy.strictMode).toBe(false);
|
|
58
|
+
expect(policy.bashPolicy.allowAll).toBe(false);
|
|
59
|
+
expect(policy.bashPolicy.allowPrefixes).toEqual([]);
|
|
60
|
+
expect(policy.bashPolicy.denyPrefixes).toContain("apt-get ");
|
|
61
|
+
expect(policy.bashPolicy.denyPrefixes).toContain("nix-shell ");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("merges toolsConfig with params", () => {
|
|
65
|
+
const policy = buildToolPolicy({
|
|
66
|
+
toolsConfig: { allowedTools: ["Read"], deniedTools: ["Write"] },
|
|
67
|
+
allowedTools: "Edit",
|
|
68
|
+
disallowedTools: "Bash",
|
|
69
|
+
});
|
|
70
|
+
expect(policy.allowedPatterns).toEqual(["Read", "Edit"]);
|
|
71
|
+
expect(policy.deniedPatterns).toEqual(["Write", "Bash"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("sets strictMode from toolsConfig", () => {
|
|
75
|
+
const policy = buildToolPolicy({
|
|
76
|
+
toolsConfig: { strictMode: true },
|
|
77
|
+
});
|
|
78
|
+
expect(policy.strictMode).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("extracts Bash allow prefixes", () => {
|
|
82
|
+
const policy = buildToolPolicy({
|
|
83
|
+
allowedTools: ["Bash(npm:*)", "Bash(git:*)"],
|
|
84
|
+
});
|
|
85
|
+
expect(policy.bashPolicy.allowPrefixes).toEqual(["npm", "git"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("extracts Bash deny prefixes", () => {
|
|
89
|
+
const policy = buildToolPolicy({
|
|
90
|
+
disallowedTools: ["Bash(rm:*)"],
|
|
91
|
+
});
|
|
92
|
+
expect(policy.bashPolicy.denyPrefixes).toContain("rm");
|
|
93
|
+
expect(policy.bashPolicy.denyPrefixes).toContain("apt ");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("detects bash allowAll when Bash is in allowed patterns", () => {
|
|
97
|
+
const policy = buildToolPolicy({ allowedTools: ["Bash", "Read"] });
|
|
98
|
+
expect(policy.bashPolicy.allowAll).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("wildcard * enables bash allowAll", () => {
|
|
102
|
+
const policy = buildToolPolicy({ allowedTools: ["*"] });
|
|
103
|
+
expect(policy.bashPolicy.allowAll).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("isToolAllowedByPolicy", () => {
|
|
108
|
+
test("allows all tools in non-strict mode", () => {
|
|
109
|
+
const policy = buildToolPolicy({});
|
|
110
|
+
expect(isToolAllowedByPolicy("Read", policy)).toBe(true);
|
|
111
|
+
expect(isToolAllowedByPolicy("Write", policy)).toBe(true);
|
|
112
|
+
expect(isToolAllowedByPolicy("CustomTool", policy)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("denies explicitly denied tools", () => {
|
|
116
|
+
const policy = buildToolPolicy({ disallowedTools: ["Write"] });
|
|
117
|
+
expect(isToolAllowedByPolicy("Write", policy)).toBe(false);
|
|
118
|
+
expect(isToolAllowedByPolicy("Read", policy)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("allows bash in non-strict mode even without explicit allow", () => {
|
|
122
|
+
const policy = buildToolPolicy({});
|
|
123
|
+
expect(isToolAllowedByPolicy("Bash", policy)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("blocks bash in strict mode without explicit allow", () => {
|
|
127
|
+
const policy = buildToolPolicy({
|
|
128
|
+
toolsConfig: { strictMode: true },
|
|
129
|
+
});
|
|
130
|
+
expect(isToolAllowedByPolicy("Bash", policy)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("allows bash in strict mode with explicit allow", () => {
|
|
134
|
+
const policy = buildToolPolicy({
|
|
135
|
+
toolsConfig: { strictMode: true, allowedTools: ["Bash"] },
|
|
136
|
+
});
|
|
137
|
+
expect(isToolAllowedByPolicy("Bash", policy)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("allows bash in strict mode with command allowlist", () => {
|
|
141
|
+
const policy = buildToolPolicy({
|
|
142
|
+
toolsConfig: { strictMode: true },
|
|
143
|
+
allowedTools: ["Bash(npm:*)"],
|
|
144
|
+
});
|
|
145
|
+
expect(isToolAllowedByPolicy("Bash", policy)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("blocks non-allowed tools in strict mode", () => {
|
|
149
|
+
const policy = buildToolPolicy({
|
|
150
|
+
toolsConfig: { strictMode: true, allowedTools: ["Read"] },
|
|
151
|
+
});
|
|
152
|
+
expect(isToolAllowedByPolicy("Read", policy)).toBe(true);
|
|
153
|
+
expect(isToolAllowedByPolicy("Write", policy)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("wildcard in allowed patterns allows all tools", () => {
|
|
157
|
+
const policy = buildToolPolicy({
|
|
158
|
+
toolsConfig: { strictMode: true, allowedTools: ["*"] },
|
|
159
|
+
});
|
|
160
|
+
expect(isToolAllowedByPolicy("AnythingGoes", policy)).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("case-insensitive tool matching", () => {
|
|
164
|
+
const policy = buildToolPolicy({ disallowedTools: ["write"] });
|
|
165
|
+
expect(isToolAllowedByPolicy("Write", policy)).toBe(false);
|
|
166
|
+
expect(isToolAllowedByPolicy("WRITE", policy)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("Bash filters in deny list do not block non-Bash tool matching", () => {
|
|
170
|
+
// Bash(rm:*) should only affect bash command filtering, not block the Bash tool itself
|
|
171
|
+
const policy = buildToolPolicy({ disallowedTools: ["Bash(rm:*)"] });
|
|
172
|
+
expect(isToolAllowedByPolicy("Bash", policy)).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("enforceBashCommandPolicy", () => {
|
|
177
|
+
test("allows empty command", () => {
|
|
178
|
+
const policy: BashCommandPolicy = {
|
|
179
|
+
allowAll: false,
|
|
180
|
+
allowPrefixes: [],
|
|
181
|
+
denyPrefixes: [],
|
|
182
|
+
};
|
|
183
|
+
expect(() => enforceBashCommandPolicy("", policy)).not.toThrow();
|
|
184
|
+
expect(() => enforceBashCommandPolicy(" ", policy)).not.toThrow();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("throws on denied prefix match", () => {
|
|
188
|
+
const policy: BashCommandPolicy = {
|
|
189
|
+
allowAll: true,
|
|
190
|
+
allowPrefixes: [],
|
|
191
|
+
denyPrefixes: ["rm"],
|
|
192
|
+
};
|
|
193
|
+
expect(() => enforceBashCommandPolicy("rm -rf /", policy)).toThrow(
|
|
194
|
+
"Bash command denied by policy"
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("deny check is case-insensitive", () => {
|
|
199
|
+
const policy: BashCommandPolicy = {
|
|
200
|
+
allowAll: true,
|
|
201
|
+
allowPrefixes: [],
|
|
202
|
+
denyPrefixes: ["rm"],
|
|
203
|
+
};
|
|
204
|
+
expect(() => enforceBashCommandPolicy("RM -rf /", policy)).toThrow(
|
|
205
|
+
"Bash command denied by policy"
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("package manager commands are blocked", () => {
|
|
210
|
+
const policy = buildToolPolicy({});
|
|
211
|
+
expect(() =>
|
|
212
|
+
enforceBashCommandPolicy("apt-get install -y ffmpeg", policy.bashPolicy)
|
|
213
|
+
).toThrow("Bash command denied by policy");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("allows all when allowAll is true", () => {
|
|
217
|
+
const policy: BashCommandPolicy = {
|
|
218
|
+
allowAll: true,
|
|
219
|
+
allowPrefixes: [],
|
|
220
|
+
denyPrefixes: [],
|
|
221
|
+
};
|
|
222
|
+
expect(() =>
|
|
223
|
+
enforceBashCommandPolicy("any command here", policy)
|
|
224
|
+
).not.toThrow();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("allows when no allow prefixes (no filter)", () => {
|
|
228
|
+
const policy: BashCommandPolicy = {
|
|
229
|
+
allowAll: false,
|
|
230
|
+
allowPrefixes: [],
|
|
231
|
+
denyPrefixes: [],
|
|
232
|
+
};
|
|
233
|
+
expect(() =>
|
|
234
|
+
enforceBashCommandPolicy("some command", policy)
|
|
235
|
+
).not.toThrow();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("allows commands matching allow prefixes", () => {
|
|
239
|
+
const policy: BashCommandPolicy = {
|
|
240
|
+
allowAll: false,
|
|
241
|
+
allowPrefixes: ["npm", "git"],
|
|
242
|
+
denyPrefixes: [],
|
|
243
|
+
};
|
|
244
|
+
expect(() => enforceBashCommandPolicy("npm install", policy)).not.toThrow();
|
|
245
|
+
expect(() => enforceBashCommandPolicy("git status", policy)).not.toThrow();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("rejects commands not matching allow prefixes", () => {
|
|
249
|
+
const policy: BashCommandPolicy = {
|
|
250
|
+
allowAll: false,
|
|
251
|
+
allowPrefixes: ["npm", "git"],
|
|
252
|
+
denyPrefixes: [],
|
|
253
|
+
};
|
|
254
|
+
expect(() =>
|
|
255
|
+
enforceBashCommandPolicy("curl http://example.com", policy)
|
|
256
|
+
).toThrow("Bash command not allowed by policy");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("deny takes priority over allow", () => {
|
|
260
|
+
const policy: BashCommandPolicy = {
|
|
261
|
+
allowAll: false,
|
|
262
|
+
allowPrefixes: ["rm"],
|
|
263
|
+
denyPrefixes: ["rm"],
|
|
264
|
+
};
|
|
265
|
+
expect(() => enforceBashCommandPolicy("rm file.txt", policy)).toThrow(
|
|
266
|
+
"Bash command denied by policy"
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for OpenClawWorker constructor validation and basic setup.
|
|
3
|
+
* Full execution tests require the OpenClaw runtime and are covered
|
|
4
|
+
* by integration tests via test-bot.sh.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { OpenClawWorker } from "../openclaw/worker";
|
|
9
|
+
import { mockWorkerConfig, TestHelpers } from "./setup";
|
|
10
|
+
|
|
11
|
+
describe("OpenClawWorker", () => {
|
|
12
|
+
let restoreFetch: () => void;
|
|
13
|
+
let originalDispatcherUrl: string | undefined;
|
|
14
|
+
let originalWorkerToken: string | undefined;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
originalDispatcherUrl = process.env.DISPATCHER_URL;
|
|
18
|
+
originalWorkerToken = process.env.WORKER_TOKEN;
|
|
19
|
+
process.env.DISPATCHER_URL = "https://test-dispatcher.example.com";
|
|
20
|
+
process.env.WORKER_TOKEN = "test-worker-token";
|
|
21
|
+
restoreFetch = TestHelpers.mockFetch();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
restoreFetch();
|
|
26
|
+
if (originalDispatcherUrl) {
|
|
27
|
+
process.env.DISPATCHER_URL = originalDispatcherUrl;
|
|
28
|
+
} else {
|
|
29
|
+
delete process.env.DISPATCHER_URL;
|
|
30
|
+
}
|
|
31
|
+
if (originalWorkerToken) {
|
|
32
|
+
process.env.WORKER_TOKEN = originalWorkerToken;
|
|
33
|
+
} else {
|
|
34
|
+
delete process.env.WORKER_TOKEN;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("constructor requires DISPATCHER_URL", () => {
|
|
39
|
+
const original = process.env.DISPATCHER_URL;
|
|
40
|
+
delete process.env.DISPATCHER_URL;
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
() => new OpenClawWorker({ ...mockWorkerConfig, sessionKey: "missing" })
|
|
44
|
+
).toThrow(
|
|
45
|
+
"DISPATCHER_URL and WORKER_TOKEN environment variables are required"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
process.env.DISPATCHER_URL = original;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("constructor requires WORKER_TOKEN", () => {
|
|
52
|
+
const original = process.env.WORKER_TOKEN;
|
|
53
|
+
delete process.env.WORKER_TOKEN;
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
() => new OpenClawWorker({ ...mockWorkerConfig, sessionKey: "missing" })
|
|
57
|
+
).toThrow(
|
|
58
|
+
"DISPATCHER_URL and WORKER_TOKEN environment variables are required"
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
process.env.WORKER_TOKEN = original;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("constructor requires teamId", () => {
|
|
65
|
+
expect(
|
|
66
|
+
() => new OpenClawWorker({ ...mockWorkerConfig, teamId: undefined })
|
|
67
|
+
).toThrow("teamId is required for worker initialization");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("constructor requires conversationId", () => {
|
|
71
|
+
expect(
|
|
72
|
+
() =>
|
|
73
|
+
new OpenClawWorker({
|
|
74
|
+
...mockWorkerConfig,
|
|
75
|
+
conversationId: undefined as any,
|
|
76
|
+
})
|
|
77
|
+
).toThrow("conversationId is required for worker initialization");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("getWorkerTransport returns transport after construction", () => {
|
|
81
|
+
const worker = new OpenClawWorker(mockWorkerConfig);
|
|
82
|
+
expect(worker.getWorkerTransport()).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("cleanup completes without error", async () => {
|
|
86
|
+
const worker = new OpenClawWorker(mockWorkerConfig);
|
|
87
|
+
await expect(worker.cleanup()).resolves.toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createLogger, type WorkerTransport } from "@lobu/core";
|
|
2
|
+
|
|
3
|
+
const logger = createLogger("worker");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format error message for display
|
|
7
|
+
* Generic error formatter that works for any AI agent
|
|
8
|
+
*/
|
|
9
|
+
function formatErrorMessage(error: unknown): string {
|
|
10
|
+
let errorMsg = `💥 Worker crashed`;
|
|
11
|
+
|
|
12
|
+
if (error instanceof Error) {
|
|
13
|
+
errorMsg += `: ${error.message}`;
|
|
14
|
+
// Add error type if it's not generic
|
|
15
|
+
if (
|
|
16
|
+
error.constructor.name !== "Error" &&
|
|
17
|
+
error.constructor.name !== "WorkspaceError"
|
|
18
|
+
) {
|
|
19
|
+
errorMsg = `💥 Worker crashed (${error.constructor.name}): ${error.message}`;
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
errorMsg += ": Unknown error";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return errorMsg;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function classifyError(error: unknown): string | undefined {
|
|
29
|
+
if (!(error instanceof Error)) return undefined;
|
|
30
|
+
if (
|
|
31
|
+
error.message.includes("No model configured") ||
|
|
32
|
+
error.message.includes("No provider specified")
|
|
33
|
+
)
|
|
34
|
+
return "NO_MODEL_CONFIGURED";
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Handle execution error - decides between authentication and generic errors
|
|
40
|
+
* Generic error handler that works for any AI agent
|
|
41
|
+
*/
|
|
42
|
+
export async function handleExecutionError(
|
|
43
|
+
error: unknown,
|
|
44
|
+
transport: WorkerTransport
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
logger.error("Worker execution failed:", error);
|
|
47
|
+
|
|
48
|
+
const code = classifyError(error);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (code) {
|
|
52
|
+
// Known error — clean message, no "Worker crashed" text
|
|
53
|
+
await transport.signalError(
|
|
54
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
55
|
+
code
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
// Unknown error — existing behavior
|
|
59
|
+
const errorMsg = formatErrorMessage(error);
|
|
60
|
+
await transport.sendStreamDelta(errorMsg, true, true);
|
|
61
|
+
await transport.signalError(
|
|
62
|
+
error instanceof Error ? error : new Error(String(error))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
} catch (gatewayError) {
|
|
66
|
+
logger.error("Failed to send error via gateway:", gatewayError);
|
|
67
|
+
// Re-throw the original error
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scan a directory tree and find all project directories
|
|
5
|
+
* A project directory is one that contains a build config file
|
|
6
|
+
* (Makefile, package.json, pyproject.toml, etc.)
|
|
7
|
+
*
|
|
8
|
+
* Generic utility that works for any AI agent
|
|
9
|
+
*/
|
|
10
|
+
export function listAppDirectories(rootDirectory: string): string[] {
|
|
11
|
+
const foundDirectories: string[] = [];
|
|
12
|
+
const ignored = new Set([
|
|
13
|
+
"node_modules",
|
|
14
|
+
".git",
|
|
15
|
+
".next",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
"vendor",
|
|
19
|
+
"target",
|
|
20
|
+
".venv",
|
|
21
|
+
"venv",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const buildConfigFiles = new Set([
|
|
25
|
+
"Makefile",
|
|
26
|
+
"makefile",
|
|
27
|
+
"package.json",
|
|
28
|
+
"pyproject.toml",
|
|
29
|
+
"Cargo.toml",
|
|
30
|
+
"pom.xml",
|
|
31
|
+
"build.gradle",
|
|
32
|
+
"build.gradle.kts",
|
|
33
|
+
"CMakeLists.txt",
|
|
34
|
+
"go.mod",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const walk = (dir: string): void => {
|
|
38
|
+
let entries: fs.Dirent[] = [];
|
|
39
|
+
try {
|
|
40
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if current directory has any build config files
|
|
46
|
+
const hasConfigFile = entries.some(
|
|
47
|
+
(entry) => entry.isFile() && buildConfigFiles.has(entry.name)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (hasConfigFile) {
|
|
51
|
+
foundDirectories.push(dir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Recursively walk subdirectories
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const p = `${dir}/${entry.name}`;
|
|
57
|
+
if (entry.isDirectory() && !ignored.has(entry.name)) {
|
|
58
|
+
walk(p);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
walk(rootDirectory);
|
|
64
|
+
return foundDirectories;
|
|
65
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Consolidated types for worker package
|
|
5
|
+
* Merged from: base/types.ts, types.ts, interfaces.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WorkerTransport } from "@lobu/core";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// WORKER INTERFACES
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Interface for worker executors
|
|
16
|
+
* Allows different agent implementations
|
|
17
|
+
*/
|
|
18
|
+
export interface WorkerExecutor {
|
|
19
|
+
/**
|
|
20
|
+
* Execute the worker job
|
|
21
|
+
*/
|
|
22
|
+
execute(): Promise<void>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cleanup worker resources
|
|
26
|
+
*/
|
|
27
|
+
cleanup(): Promise<void>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the worker transport for sending updates to gateway
|
|
31
|
+
*/
|
|
32
|
+
getWorkerTransport(): WorkerTransport | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// WORKER CONFIG & WORKSPACE
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
export interface WorkerConfig {
|
|
40
|
+
sessionKey: string;
|
|
41
|
+
userId: string;
|
|
42
|
+
agentId: string; // Space identifier for multi-tenant isolation
|
|
43
|
+
channelId: string;
|
|
44
|
+
conversationId: string;
|
|
45
|
+
userPrompt: string; // Base64 encoded
|
|
46
|
+
responseChannel: string; // Platform-agnostic response channel
|
|
47
|
+
responseId: string; // Platform-agnostic response message ID
|
|
48
|
+
botResponseId?: string; // Bot's response message ID for updates
|
|
49
|
+
agentOptions: string; // JSON string
|
|
50
|
+
teamId?: string; // Platform team/workspace ID (e.g., Slack team ID)
|
|
51
|
+
platform: string; // Platform identifier (e.g., "slack", "discord")
|
|
52
|
+
platformMetadata?: any; // Platform-specific metadata (e.g., files, user info)
|
|
53
|
+
workspace: {
|
|
54
|
+
baseDirectory: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkspaceSetupConfig {
|
|
59
|
+
baseDirectory: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkspaceInfo {
|
|
63
|
+
baseDirectory: string;
|
|
64
|
+
userDirectory: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// PROGRESS & EXECUTION TYPES
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Progress update from AI agent execution
|
|
73
|
+
*/
|
|
74
|
+
export type ProgressUpdate =
|
|
75
|
+
| {
|
|
76
|
+
type: "output";
|
|
77
|
+
data: unknown; // Agent-specific message format
|
|
78
|
+
timestamp: number;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
type: "completion";
|
|
82
|
+
data: {
|
|
83
|
+
exitCode?: number;
|
|
84
|
+
message?: string;
|
|
85
|
+
success?: boolean;
|
|
86
|
+
sessionId?: string;
|
|
87
|
+
};
|
|
88
|
+
timestamp: number;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
type: "error";
|
|
92
|
+
data: Error | { message?: string; stack?: string; error?: string };
|
|
93
|
+
timestamp: number;
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
type: "status_update";
|
|
97
|
+
data: {
|
|
98
|
+
elapsedSeconds: number;
|
|
99
|
+
state: string;
|
|
100
|
+
};
|
|
101
|
+
timestamp: number;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Callback for receiving progress updates during AI execution
|
|
106
|
+
*/
|
|
107
|
+
export type ProgressCallback = (update: ProgressUpdate) => Promise<void>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Session context for AI execution
|
|
111
|
+
* Contains information about the current session (platform, user, workspace)
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Result from session execution (includes session metadata)
|
|
116
|
+
*/
|
|
117
|
+
export interface SessionExecutionResult {
|
|
118
|
+
success: boolean;
|
|
119
|
+
exitCode: number;
|
|
120
|
+
output: string;
|
|
121
|
+
error?: string;
|
|
122
|
+
sessionKey: string;
|
|
123
|
+
persisted?: boolean;
|
|
124
|
+
storagePath?: string;
|
|
125
|
+
}
|