@lobu/gateway 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.
Files changed (175) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/agent-config-routes.test.ts +254 -0
  3. package/src/__tests__/agent-history-routes.test.ts +72 -0
  4. package/src/__tests__/agent-routes.test.ts +68 -0
  5. package/src/__tests__/agent-schedules-routes.test.ts +59 -0
  6. package/src/__tests__/agent-settings-store.test.ts +323 -0
  7. package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
  8. package/src/__tests__/chat-response-bridge.test.ts +131 -0
  9. package/src/__tests__/config-memory-plugins.test.ts +92 -0
  10. package/src/__tests__/config-request-store.test.ts +127 -0
  11. package/src/__tests__/connection-routes.test.ts +144 -0
  12. package/src/__tests__/core-services-store-selection.test.ts +92 -0
  13. package/src/__tests__/docker-deployment.test.ts +1211 -0
  14. package/src/__tests__/embedded-deployment.test.ts +342 -0
  15. package/src/__tests__/grant-store.test.ts +148 -0
  16. package/src/__tests__/http-proxy.test.ts +281 -0
  17. package/src/__tests__/instruction-service.test.ts +37 -0
  18. package/src/__tests__/link-buttons.test.ts +112 -0
  19. package/src/__tests__/lobu.test.ts +32 -0
  20. package/src/__tests__/mcp-config-service.test.ts +347 -0
  21. package/src/__tests__/mcp-proxy.test.ts +696 -0
  22. package/src/__tests__/message-handler-bridge.test.ts +17 -0
  23. package/src/__tests__/model-selection.test.ts +172 -0
  24. package/src/__tests__/oauth-templates.test.ts +39 -0
  25. package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
  26. package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
  27. package/src/__tests__/provider-inheritance.test.ts +212 -0
  28. package/src/__tests__/routes/cli-auth.test.ts +337 -0
  29. package/src/__tests__/routes/interactions.test.ts +121 -0
  30. package/src/__tests__/secret-proxy.test.ts +85 -0
  31. package/src/__tests__/session-manager.test.ts +572 -0
  32. package/src/__tests__/setup.ts +133 -0
  33. package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
  34. package/src/__tests__/slack-routes.test.ts +161 -0
  35. package/src/__tests__/system-config-resolver.test.ts +75 -0
  36. package/src/__tests__/system-message-limiter.test.ts +89 -0
  37. package/src/__tests__/system-skills-service.test.ts +362 -0
  38. package/src/__tests__/transcription-service.test.ts +222 -0
  39. package/src/__tests__/utils/rate-limiter.test.ts +102 -0
  40. package/src/__tests__/worker-connection-manager.test.ts +497 -0
  41. package/src/__tests__/worker-job-router.test.ts +722 -0
  42. package/src/api/index.ts +1 -0
  43. package/src/api/platform.ts +292 -0
  44. package/src/api/response-renderer.ts +157 -0
  45. package/src/auth/agent-metadata-store.ts +168 -0
  46. package/src/auth/api-auth-middleware.ts +69 -0
  47. package/src/auth/api-key-provider-module.ts +213 -0
  48. package/src/auth/base-provider-module.ts +201 -0
  49. package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
  50. package/src/auth/chatgpt/device-code-client.ts +218 -0
  51. package/src/auth/chatgpt/index.ts +1 -0
  52. package/src/auth/claude/oauth-module.ts +280 -0
  53. package/src/auth/cli/token-service.ts +249 -0
  54. package/src/auth/external/client.ts +560 -0
  55. package/src/auth/external/device-code-client.ts +225 -0
  56. package/src/auth/mcp/config-service.ts +392 -0
  57. package/src/auth/mcp/proxy.ts +1088 -0
  58. package/src/auth/mcp/string-substitution.ts +17 -0
  59. package/src/auth/mcp/tool-cache.ts +90 -0
  60. package/src/auth/oauth/base-client.ts +267 -0
  61. package/src/auth/oauth/client.ts +153 -0
  62. package/src/auth/oauth/credentials.ts +7 -0
  63. package/src/auth/oauth/providers.ts +69 -0
  64. package/src/auth/oauth/state-store.ts +150 -0
  65. package/src/auth/oauth-templates.ts +179 -0
  66. package/src/auth/provider-catalog.ts +220 -0
  67. package/src/auth/provider-model-options.ts +41 -0
  68. package/src/auth/settings/agent-settings-store.ts +565 -0
  69. package/src/auth/settings/auth-profiles-manager.ts +216 -0
  70. package/src/auth/settings/index.ts +12 -0
  71. package/src/auth/settings/model-preference-store.ts +52 -0
  72. package/src/auth/settings/model-selection.ts +135 -0
  73. package/src/auth/settings/resolved-settings-view.ts +298 -0
  74. package/src/auth/settings/template-utils.ts +44 -0
  75. package/src/auth/settings/token-service.ts +88 -0
  76. package/src/auth/system-env-store.ts +98 -0
  77. package/src/auth/user-agents-store.ts +68 -0
  78. package/src/channels/binding-service.ts +214 -0
  79. package/src/channels/index.ts +4 -0
  80. package/src/cli/gateway.ts +1304 -0
  81. package/src/cli/index.ts +74 -0
  82. package/src/commands/built-in-commands.ts +80 -0
  83. package/src/commands/command-dispatcher.ts +94 -0
  84. package/src/commands/command-reply-adapters.ts +27 -0
  85. package/src/config/file-loader.ts +618 -0
  86. package/src/config/index.ts +588 -0
  87. package/src/config/network-allowlist.ts +71 -0
  88. package/src/connections/chat-instance-manager.ts +1284 -0
  89. package/src/connections/chat-response-bridge.ts +618 -0
  90. package/src/connections/index.ts +7 -0
  91. package/src/connections/interaction-bridge.ts +831 -0
  92. package/src/connections/message-handler-bridge.ts +415 -0
  93. package/src/connections/platform-auth-methods.ts +15 -0
  94. package/src/connections/types.ts +84 -0
  95. package/src/gateway/connection-manager.ts +291 -0
  96. package/src/gateway/index.ts +700 -0
  97. package/src/gateway/job-router.ts +201 -0
  98. package/src/gateway-main.ts +200 -0
  99. package/src/index.ts +41 -0
  100. package/src/infrastructure/queue/index.ts +12 -0
  101. package/src/infrastructure/queue/queue-producer.ts +148 -0
  102. package/src/infrastructure/queue/redis-queue.ts +361 -0
  103. package/src/infrastructure/queue/types.ts +133 -0
  104. package/src/infrastructure/redis/system-message-limiter.ts +94 -0
  105. package/src/interactions/config-request-store.ts +198 -0
  106. package/src/interactions.ts +363 -0
  107. package/src/lobu.ts +311 -0
  108. package/src/metrics/prometheus.ts +159 -0
  109. package/src/modules/module-system.ts +179 -0
  110. package/src/orchestration/base-deployment-manager.ts +900 -0
  111. package/src/orchestration/deployment-utils.ts +98 -0
  112. package/src/orchestration/impl/docker-deployment.ts +620 -0
  113. package/src/orchestration/impl/embedded-deployment.ts +268 -0
  114. package/src/orchestration/impl/index.ts +8 -0
  115. package/src/orchestration/impl/k8s/deployment.ts +1061 -0
  116. package/src/orchestration/impl/k8s/helpers.ts +610 -0
  117. package/src/orchestration/impl/k8s/index.ts +1 -0
  118. package/src/orchestration/index.ts +333 -0
  119. package/src/orchestration/message-consumer.ts +584 -0
  120. package/src/orchestration/scheduled-wakeup.ts +704 -0
  121. package/src/permissions/approval-policy.ts +36 -0
  122. package/src/permissions/grant-store.ts +219 -0
  123. package/src/platform/file-handler.ts +66 -0
  124. package/src/platform/link-buttons.ts +57 -0
  125. package/src/platform/renderer-utils.ts +44 -0
  126. package/src/platform/response-renderer.ts +84 -0
  127. package/src/platform/unified-thread-consumer.ts +187 -0
  128. package/src/platform.ts +318 -0
  129. package/src/proxy/http-proxy.ts +752 -0
  130. package/src/proxy/proxy-manager.ts +81 -0
  131. package/src/proxy/secret-proxy.ts +402 -0
  132. package/src/proxy/token-refresh-job.ts +143 -0
  133. package/src/routes/internal/audio.ts +141 -0
  134. package/src/routes/internal/device-auth.ts +566 -0
  135. package/src/routes/internal/files.ts +226 -0
  136. package/src/routes/internal/history.ts +69 -0
  137. package/src/routes/internal/images.ts +127 -0
  138. package/src/routes/internal/interactions.ts +84 -0
  139. package/src/routes/internal/middleware.ts +23 -0
  140. package/src/routes/internal/schedule.ts +226 -0
  141. package/src/routes/internal/types.ts +22 -0
  142. package/src/routes/openapi-auto.ts +239 -0
  143. package/src/routes/public/agent-access.ts +23 -0
  144. package/src/routes/public/agent-config.ts +675 -0
  145. package/src/routes/public/agent-history.ts +422 -0
  146. package/src/routes/public/agent-schedules.ts +296 -0
  147. package/src/routes/public/agent.ts +1086 -0
  148. package/src/routes/public/agents.ts +373 -0
  149. package/src/routes/public/channels.ts +191 -0
  150. package/src/routes/public/cli-auth.ts +883 -0
  151. package/src/routes/public/connections.ts +574 -0
  152. package/src/routes/public/landing.ts +16 -0
  153. package/src/routes/public/oauth.ts +147 -0
  154. package/src/routes/public/settings-auth.ts +104 -0
  155. package/src/routes/public/slack.ts +173 -0
  156. package/src/routes/shared/agent-ownership.ts +101 -0
  157. package/src/routes/shared/token-verifier.ts +34 -0
  158. package/src/services/core-services.ts +1053 -0
  159. package/src/services/image-generation-service.ts +257 -0
  160. package/src/services/instruction-service.ts +318 -0
  161. package/src/services/mcp-registry.ts +94 -0
  162. package/src/services/platform-helpers.ts +287 -0
  163. package/src/services/session-manager.ts +262 -0
  164. package/src/services/settings-resolver.ts +74 -0
  165. package/src/services/system-config-resolver.ts +90 -0
  166. package/src/services/system-skills-service.ts +229 -0
  167. package/src/services/transcription-service.ts +684 -0
  168. package/src/session.ts +110 -0
  169. package/src/spaces/index.ts +1 -0
  170. package/src/spaces/space-resolver.ts +17 -0
  171. package/src/stores/in-memory-agent-store.ts +403 -0
  172. package/src/stores/redis-agent-store.ts +279 -0
  173. package/src/utils/public-url.ts +44 -0
  174. package/src/utils/rate-limiter.ts +94 -0
  175. package/tsconfig.json +33 -0
@@ -0,0 +1,1211 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import fs from "node:fs";
11
+ import type {
12
+ MessagePayload,
13
+ OrchestratorConfig,
14
+ } from "../orchestration/base-deployment-manager";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Mock dockerode
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const mockContainer = {
21
+ id: "container-id-123",
22
+ start: mock(async () => {
23
+ /* noop */
24
+ }),
25
+ stop: mock(async () => {
26
+ /* noop */
27
+ }),
28
+ remove: mock(async () => {
29
+ /* noop */
30
+ }),
31
+ inspect: mock(async () => ({ State: { Running: true } })),
32
+ wait: mock(async () => {
33
+ /* noop */
34
+ }),
35
+ };
36
+
37
+ const mockVolume = {
38
+ inspect: mock(async () => ({})),
39
+ };
40
+
41
+ const mockNetwork = {
42
+ inspect: mock(async () => ({ Internal: true })),
43
+ connect: mock(async () => {
44
+ /* noop */
45
+ }),
46
+ };
47
+
48
+ const mockDocker = {
49
+ info: mock(async () => ({ Runtimes: {} })),
50
+ createContainer: mock(async () => mockContainer),
51
+ getContainer: mock(() => mockContainer),
52
+ createVolume: mock(async () => mockVolume),
53
+ getVolume: mock(() => mockVolume),
54
+ createNetwork: mock(async () => mockNetwork),
55
+ getNetwork: mock(() => mockNetwork),
56
+ listContainers: mock(async () => []),
57
+ getImage: mock(() => ({ inspect: mock(async () => ({})) })),
58
+ pull: mock((_name: string, cb: Function) =>
59
+ cb(null, {
60
+ on: () => {
61
+ /* noop */
62
+ },
63
+ })
64
+ ),
65
+ modem: { followProgress: (_stream: any, cb: Function) => cb(null) },
66
+ };
67
+
68
+ mock.module("dockerode", () => ({
69
+ default: class MockDocker {
70
+ constructor() {
71
+ Object.assign(this, mockDocker);
72
+ }
73
+ },
74
+ }));
75
+
76
+ // Must import after mocks are set up
77
+ const { DockerDeploymentManager } = await import(
78
+ "../orchestration/impl/docker-deployment"
79
+ );
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Constants & helpers
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const TEST_ENCRYPTION_KEY =
86
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
87
+
88
+ const TEST_CONFIG: OrchestratorConfig = {
89
+ queues: {
90
+ connectionString: "redis://localhost:6379",
91
+ retryLimit: 3,
92
+ retryDelay: 5,
93
+ expireInSeconds: 300,
94
+ },
95
+ worker: {
96
+ image: {
97
+ repository: "lobu-worker",
98
+ tag: "latest",
99
+ pullPolicy: "IfNotPresent",
100
+ },
101
+ resources: {
102
+ requests: { cpu: "100m", memory: "128Mi" },
103
+ limits: { cpu: "500m", memory: "512Mi" },
104
+ },
105
+ idleCleanupMinutes: 30,
106
+ maxDeployments: 10,
107
+ },
108
+ kubernetes: { namespace: "default" },
109
+ cleanup: { initialDelayMs: 5000, intervalMs: 60000, veryOldDays: 7 },
110
+ };
111
+
112
+ function createTestMessagePayload(
113
+ overrides?: Partial<MessagePayload>
114
+ ): MessagePayload {
115
+ return {
116
+ userId: "user-1",
117
+ conversationId: "conv-1",
118
+ channelId: "ch-1",
119
+ messageId: "msg-1",
120
+ teamId: "team-1",
121
+ agentId: "test-agent",
122
+ botId: "bot-1",
123
+ platform: "slack",
124
+ messageText: "hello",
125
+ platformMetadata: {},
126
+ agentOptions: {},
127
+ ...overrides,
128
+ } as MessagePayload;
129
+ }
130
+
131
+ const MOCK_DEFAULTS: Array<
132
+ [{ mockReset: Function; mockImplementation: Function }, Function]
133
+ > = [
134
+ [
135
+ mockContainer.start,
136
+ async () => {
137
+ /* noop */
138
+ },
139
+ ],
140
+ [
141
+ mockContainer.stop,
142
+ async () => {
143
+ /* noop */
144
+ },
145
+ ],
146
+ [
147
+ mockContainer.remove,
148
+ async () => {
149
+ /* noop */
150
+ },
151
+ ],
152
+ [mockContainer.inspect, async () => ({ State: { Running: true } })],
153
+ [mockDocker.info, async () => ({ Runtimes: {} })],
154
+ [mockDocker.createContainer, async () => mockContainer],
155
+ [mockDocker.getContainer, () => mockContainer],
156
+ [mockDocker.createVolume, async () => mockVolume],
157
+ [mockDocker.getVolume, () => mockVolume],
158
+ [mockDocker.createNetwork, async () => mockNetwork],
159
+ [mockDocker.getNetwork, () => mockNetwork],
160
+ [mockDocker.listContainers, async () => []],
161
+ [mockDocker.getImage, () => ({ inspect: mock(async () => ({})) })],
162
+ [
163
+ mockDocker.pull,
164
+ (_name: string, cb: Function) =>
165
+ cb(null, {
166
+ on: () => {
167
+ /* noop */
168
+ },
169
+ }),
170
+ ],
171
+ [mockVolume.inspect, async () => ({})],
172
+ [mockNetwork.inspect, async () => ({ Internal: true })],
173
+ [
174
+ mockNetwork.connect,
175
+ async () => {
176
+ /* noop */
177
+ },
178
+ ],
179
+ ];
180
+
181
+ function resetAllMocks() {
182
+ for (const [mockFn, impl] of MOCK_DEFAULTS) {
183
+ mockFn.mockReset();
184
+ mockFn.mockImplementation(impl);
185
+ }
186
+ }
187
+
188
+ /** Extract the main container creation options (skips init containers like alpine). */
189
+ function getMainCreateOpts(): any {
190
+ const call = mockDocker.createContainer.mock.calls.find(
191
+ (c: any) => c[0]?.name === "test-deploy"
192
+ );
193
+ return call?.[0];
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Test suite
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe("DockerDeploymentManager", () => {
201
+ let savedEnv: NodeJS.ProcessEnv;
202
+
203
+ beforeEach(() => {
204
+ savedEnv = { ...process.env };
205
+ process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
206
+ resetAllMocks();
207
+ });
208
+
209
+ afterEach(() => {
210
+ process.env = savedEnv;
211
+ });
212
+
213
+ function createManager(configOverrides?: Partial<OrchestratorConfig>) {
214
+ const config = { ...TEST_CONFIG, ...configOverrides };
215
+ return new DockerDeploymentManager(config);
216
+ }
217
+
218
+ // =========================================================================
219
+ // ResourceParser (tested indirectly via createContainer args)
220
+ // =========================================================================
221
+
222
+ describe("ResourceParser (via createDeployment)", () => {
223
+ test("parseMemory: 512Mi -> 512 * 1024 * 1024", async () => {
224
+ const manager = createManager({
225
+ worker: {
226
+ ...TEST_CONFIG.worker,
227
+ resources: {
228
+ requests: { cpu: "100m", memory: "128Mi" },
229
+ limits: { cpu: "500m", memory: "512Mi" },
230
+ },
231
+ },
232
+ });
233
+
234
+ await manager.createDeployment(
235
+ "test-deploy",
236
+ "user",
237
+ "user-id",
238
+ createTestMessagePayload()
239
+ );
240
+
241
+ const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
242
+ expect(opts.HostConfig.Memory).toBe(512 * 1024 * 1024);
243
+ });
244
+
245
+ test("parseMemory: 1Gi -> 1024 * 1024 * 1024", async () => {
246
+ const manager = createManager({
247
+ worker: {
248
+ ...TEST_CONFIG.worker,
249
+ resources: {
250
+ requests: { cpu: "100m", memory: "128Mi" },
251
+ limits: { cpu: "1", memory: "1Gi" },
252
+ },
253
+ },
254
+ });
255
+
256
+ await manager.createDeployment(
257
+ "test-deploy",
258
+ "user",
259
+ "user-id",
260
+ createTestMessagePayload()
261
+ );
262
+
263
+ const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
264
+ expect(opts.HostConfig.Memory).toBe(1024 * 1024 * 1024);
265
+ });
266
+
267
+ test("parseCpu: 500m -> 500_000_000 nanocpus", async () => {
268
+ const manager = createManager();
269
+
270
+ await manager.createDeployment(
271
+ "test-deploy",
272
+ "user",
273
+ "user-id",
274
+ createTestMessagePayload()
275
+ );
276
+
277
+ const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
278
+ expect(opts.HostConfig.NanoCpus).toBe(500_000_000);
279
+ });
280
+
281
+ test("parseCpu: 1 core -> 1_000_000_000 nanocpus", async () => {
282
+ const manager = createManager({
283
+ worker: {
284
+ ...TEST_CONFIG.worker,
285
+ resources: {
286
+ requests: { cpu: "100m", memory: "128Mi" },
287
+ limits: { cpu: "1", memory: "512Mi" },
288
+ },
289
+ },
290
+ });
291
+
292
+ await manager.createDeployment(
293
+ "test-deploy",
294
+ "user",
295
+ "user-id",
296
+ createTestMessagePayload()
297
+ );
298
+
299
+ const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
300
+ expect(opts.HostConfig.NanoCpus).toBe(1_000_000_000);
301
+ });
302
+ });
303
+
304
+ // =========================================================================
305
+ // Container creation & security
306
+ // =========================================================================
307
+
308
+ describe("createDeployment", () => {
309
+ test("calls docker.createContainer with correct image", async () => {
310
+ const manager = createManager();
311
+ await manager.createDeployment(
312
+ "test-deploy",
313
+ "user",
314
+ "user-id",
315
+ createTestMessagePayload()
316
+ );
317
+
318
+ const opts = getMainCreateOpts();
319
+ expect(opts).toBeDefined();
320
+ expect(opts.Image).toBe("lobu-worker:latest");
321
+ });
322
+
323
+ test("drops all capabilities: CapDrop=['ALL']", async () => {
324
+ const manager = createManager();
325
+ await manager.createDeployment(
326
+ "test-deploy",
327
+ "user",
328
+ "user-id",
329
+ createTestMessagePayload()
330
+ );
331
+ expect(getMainCreateOpts().HostConfig.CapDrop).toEqual(["ALL"]);
332
+ });
333
+
334
+ test("adds configurable capabilities via WORKER_CAPABILITIES env var", async () => {
335
+ process.env.WORKER_CAPABILITIES = "NET_BIND_SERVICE,SYS_PTRACE";
336
+ const manager = createManager();
337
+ await manager.createDeployment(
338
+ "test-deploy",
339
+ "user",
340
+ "user-id",
341
+ createTestMessagePayload()
342
+ );
343
+ expect(getMainCreateOpts().HostConfig.CapAdd).toEqual([
344
+ "NET_BIND_SERVICE",
345
+ "SYS_PTRACE",
346
+ ]);
347
+ });
348
+
349
+ test("empty CapAdd when WORKER_CAPABILITIES not set", async () => {
350
+ delete process.env.WORKER_CAPABILITIES;
351
+ const manager = createManager();
352
+ await manager.createDeployment(
353
+ "test-deploy",
354
+ "user",
355
+ "user-id",
356
+ createTestMessagePayload()
357
+ );
358
+ expect(getMainCreateOpts().HostConfig.CapAdd).toEqual([]);
359
+ });
360
+
361
+ test("enables no-new-privileges security option", async () => {
362
+ const manager = createManager();
363
+ await manager.createDeployment(
364
+ "test-deploy",
365
+ "user",
366
+ "user-id",
367
+ createTestMessagePayload()
368
+ );
369
+ expect(getMainCreateOpts().HostConfig.SecurityOpt).toContain(
370
+ "no-new-privileges:true"
371
+ );
372
+ });
373
+
374
+ test("uses readonly rootfs by default", async () => {
375
+ const manager = createManager();
376
+ await manager.createDeployment(
377
+ "test-deploy",
378
+ "user",
379
+ "user-id",
380
+ createTestMessagePayload()
381
+ );
382
+ expect(getMainCreateOpts().HostConfig.ReadonlyRootfs).toBe(true);
383
+ });
384
+
385
+ test("disables readonly rootfs when Nix packages configured", async () => {
386
+ const manager = createManager();
387
+ await manager.createDeployment(
388
+ "test-deploy",
389
+ "user",
390
+ "user-id",
391
+ createTestMessagePayload({ nixConfig: { packages: ["nodejs"] } })
392
+ );
393
+ expect(getMainCreateOpts().HostConfig.ReadonlyRootfs).toBe(false);
394
+ });
395
+
396
+ test("disables readonly rootfs when Nix flakeUrl configured", async () => {
397
+ const manager = createManager();
398
+ await manager.createDeployment(
399
+ "test-deploy",
400
+ "user",
401
+ "user-id",
402
+ createTestMessagePayload({
403
+ nixConfig: { flakeUrl: "github:owner/repo" },
404
+ })
405
+ );
406
+ expect(getMainCreateOpts().HostConfig.ReadonlyRootfs).toBe(false);
407
+ });
408
+
409
+ test("adds tmpfs for /tmp when readonly rootfs enabled", async () => {
410
+ const manager = createManager();
411
+ await manager.createDeployment(
412
+ "test-deploy",
413
+ "user",
414
+ "user-id",
415
+ createTestMessagePayload()
416
+ );
417
+ expect(getMainCreateOpts().HostConfig.Tmpfs).toEqual({
418
+ "/tmp": "rw,noexec,nosuid,size=100m",
419
+ });
420
+ });
421
+
422
+ test("does not add tmpfs when Nix packages configured", async () => {
423
+ const manager = createManager();
424
+ await manager.createDeployment(
425
+ "test-deploy",
426
+ "user",
427
+ "user-id",
428
+ createTestMessagePayload({ nixConfig: { packages: ["nodejs"] } })
429
+ );
430
+ expect(getMainCreateOpts().HostConfig.Tmpfs).toBeUndefined();
431
+ });
432
+
433
+ test("sets ShmSize to 256MB (268435456)", async () => {
434
+ const manager = createManager();
435
+ await manager.createDeployment(
436
+ "test-deploy",
437
+ "user",
438
+ "user-id",
439
+ createTestMessagePayload()
440
+ );
441
+ expect(getMainCreateOpts().HostConfig.ShmSize).toBe(268435456);
442
+ });
443
+
444
+ test("uses gvisor runtime when available", async () => {
445
+ mockDocker.info.mockImplementation(async () => ({
446
+ Runtimes: { runsc: {} },
447
+ }));
448
+ const manager = createManager();
449
+ await new Promise((r) => setTimeout(r, 50));
450
+
451
+ await manager.createDeployment(
452
+ "test-deploy",
453
+ "user",
454
+ "user-id",
455
+ createTestMessagePayload()
456
+ );
457
+ expect(getMainCreateOpts().HostConfig.Runtime).toBe("runsc");
458
+ });
459
+
460
+ test("uses default runtime when gvisor unavailable", async () => {
461
+ const manager = createManager();
462
+ await new Promise((r) => setTimeout(r, 50));
463
+
464
+ await manager.createDeployment(
465
+ "test-deploy",
466
+ "user",
467
+ "user-id",
468
+ createTestMessagePayload()
469
+ );
470
+ expect(getMainCreateOpts().HostConfig.Runtime).toBeUndefined();
471
+ });
472
+
473
+ test("starts container after creation", async () => {
474
+ const manager = createManager();
475
+ await manager.createDeployment(
476
+ "test-deploy",
477
+ "user",
478
+ "user-id",
479
+ createTestMessagePayload()
480
+ );
481
+ expect(mockContainer.start).toHaveBeenCalled();
482
+ });
483
+
484
+ test("removes container if start fails", async () => {
485
+ const removeMock = mock(async () => {
486
+ /* noop */
487
+ });
488
+ mockDocker.createContainer.mockImplementation(async (opts: any) => {
489
+ if (opts?.name === "test-deploy") {
490
+ return {
491
+ ...mockContainer,
492
+ id: "failed-container",
493
+ start: mock(async () => {
494
+ throw new Error("start failed");
495
+ }),
496
+ remove: removeMock,
497
+ };
498
+ }
499
+ return mockContainer;
500
+ });
501
+
502
+ const manager = createManager();
503
+
504
+ await expect(
505
+ manager.createDeployment(
506
+ "test-deploy",
507
+ "user",
508
+ "user-id",
509
+ createTestMessagePayload()
510
+ )
511
+ ).rejects.toThrow();
512
+
513
+ expect(removeMock).toHaveBeenCalledWith({ force: true });
514
+ });
515
+
516
+ test("sets WorkingDir to /workspace", async () => {
517
+ const manager = createManager();
518
+ await manager.createDeployment(
519
+ "test-deploy",
520
+ "user",
521
+ "user-id",
522
+ createTestMessagePayload()
523
+ );
524
+ expect(getMainCreateOpts().WorkingDir).toBe("/workspace");
525
+ });
526
+ });
527
+
528
+ // =========================================================================
529
+ // Docker volume management
530
+ // =========================================================================
531
+
532
+ describe("volume management", () => {
533
+ test("volume created as lobu-workspace-{agentId}", async () => {
534
+ // Make getVolume throw so ensureVolume creates a new one
535
+ mockDocker.getVolume.mockImplementation(() => ({
536
+ inspect: mock(async () => {
537
+ throw new Error("no such volume");
538
+ }),
539
+ }));
540
+
541
+ const manager = createManager();
542
+
543
+ await manager.createDeployment(
544
+ "test-deploy",
545
+ "user",
546
+ "user-id",
547
+ createTestMessagePayload({ agentId: "my-agent" })
548
+ );
549
+
550
+ expect(mockDocker.createVolume).toHaveBeenCalledWith(
551
+ expect.objectContaining({
552
+ Name: "lobu-workspace-my-agent",
553
+ })
554
+ );
555
+ });
556
+
557
+ test("volume shared across threads with same agentId", async () => {
558
+ const manager = createManager();
559
+
560
+ // First deployment
561
+ await manager.createDeployment(
562
+ "deploy-1",
563
+ "user",
564
+ "user-id",
565
+ createTestMessagePayload({ agentId: "shared-agent" })
566
+ );
567
+
568
+ // Second deployment with same agentId
569
+ await manager.createDeployment(
570
+ "deploy-2",
571
+ "user",
572
+ "user-id",
573
+ createTestMessagePayload({ agentId: "shared-agent" })
574
+ );
575
+
576
+ // Both main container calls should reference the same volume name
577
+ const mainCalls = mockDocker.createContainer.mock.calls.filter(
578
+ (call: any) =>
579
+ call[0]?.name === "deploy-1" || call[0]?.name === "deploy-2"
580
+ );
581
+ expect(mainCalls.length).toBe(2);
582
+
583
+ // In production mode (non-development), uses Mounts with volume
584
+ for (const call of mainCalls) {
585
+ const opts = call[0] as any;
586
+ if (opts.HostConfig.Mounts) {
587
+ expect(opts.HostConfig.Mounts[0].Source).toBe(
588
+ "lobu-workspace-shared-agent"
589
+ );
590
+ }
591
+ }
592
+ });
593
+
594
+ test("handles race condition on concurrent volume creation (409 conflict)", async () => {
595
+ mockDocker.getVolume.mockImplementation(() => ({
596
+ inspect: mock(async () => {
597
+ throw new Error("no such volume");
598
+ }),
599
+ }));
600
+ mockDocker.createVolume.mockImplementation(async () => {
601
+ const err: any = new Error("already exists");
602
+ err.statusCode = 409;
603
+ throw err;
604
+ });
605
+
606
+ const manager = createManager();
607
+
608
+ // Should not throw despite 409
609
+ await expect(
610
+ manager.createDeployment(
611
+ "test-deploy",
612
+ "user",
613
+ "user-id",
614
+ createTestMessagePayload()
615
+ )
616
+ ).resolves.toBeUndefined();
617
+ });
618
+
619
+ test("development mode uses bind mounts", async () => {
620
+ process.env.NODE_ENV = "development";
621
+ process.env.DEPLOYMENT_MODE = "docker";
622
+ process.env.LOBU_DEV_PROJECT_PATH = "/app";
623
+
624
+ const manager = createManager();
625
+
626
+ await manager.createDeployment(
627
+ "test-deploy",
628
+ "user",
629
+ "user-id",
630
+ createTestMessagePayload({ agentId: "dev-agent" })
631
+ );
632
+
633
+ const mainCall = mockDocker.createContainer.mock.calls.find(
634
+ (call: any) => call[0]?.name === "test-deploy"
635
+ );
636
+ const opts = mainCall![0] as any;
637
+ expect(opts.HostConfig.Binds).toBeDefined();
638
+ expect(opts.HostConfig.Binds[0]).toContain(
639
+ "/app/workspaces/dev-agent:/workspace"
640
+ );
641
+ });
642
+ });
643
+
644
+ // =========================================================================
645
+ // Docker network
646
+ // =========================================================================
647
+
648
+ describe("network management", () => {
649
+ test("internal network created/checked with Internal flag", async () => {
650
+ // The constructor calls ensureInternalNetwork
651
+ delete process.env.WORKER_NETWORK;
652
+ mockNetwork.inspect.mockImplementation(async () => ({ Internal: true }));
653
+
654
+ createManager();
655
+ // ensureInternalNetwork is fire-and-forget, give it time
656
+ await new Promise((r) => setTimeout(r, 50));
657
+
658
+ expect(mockDocker.getNetwork).toHaveBeenCalled();
659
+ });
660
+
661
+ test("WORKER_NETWORK env var overrides network name", async () => {
662
+ process.env.WORKER_NETWORK = "custom-network";
663
+ const manager = createManager();
664
+ await manager.createDeployment(
665
+ "test-deploy",
666
+ "user",
667
+ "user-id",
668
+ createTestMessagePayload()
669
+ );
670
+ expect(getMainCreateOpts().HostConfig.NetworkMode).toBe("custom-network");
671
+ });
672
+
673
+ test("uses compose project name for default network", async () => {
674
+ delete process.env.WORKER_NETWORK;
675
+ process.env.COMPOSE_PROJECT_NAME = "myproject";
676
+ const manager = createManager();
677
+ await manager.createDeployment(
678
+ "test-deploy",
679
+ "user",
680
+ "user-id",
681
+ createTestMessagePayload()
682
+ );
683
+ expect(getMainCreateOpts().HostConfig.NetworkMode).toBe(
684
+ "myproject_lobu-internal"
685
+ );
686
+ });
687
+
688
+ test("host mode connects to public network too", async () => {
689
+ delete process.env.WORKER_NETWORK;
690
+ // Simulate running on host (not in container)
691
+ delete process.env.CONTAINER;
692
+ const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
693
+
694
+ const manager = createManager();
695
+ await new Promise((r) => setTimeout(r, 50));
696
+
697
+ await manager.createDeployment(
698
+ "test-deploy",
699
+ "user",
700
+ "user-id",
701
+ createTestMessagePayload()
702
+ );
703
+
704
+ // Should attempt to connect to public network
705
+ expect(mockNetwork.connect).toHaveBeenCalled();
706
+
707
+ existsSpy.mockRestore();
708
+ });
709
+ });
710
+
711
+ // =========================================================================
712
+ // Dispatcher host
713
+ // =========================================================================
714
+
715
+ describe("dispatcher host", () => {
716
+ function getDispatcherUrlEnv() {
717
+ return (getMainCreateOpts().Env as string[]).find((e: string) =>
718
+ e.startsWith("DISPATCHER_URL=")
719
+ );
720
+ }
721
+
722
+ test('returns "gateway" when running in container (/.dockerenv exists)', async () => {
723
+ const existsSpy = spyOn(fs, "existsSync").mockImplementation(
724
+ (p: any) => String(p) === "/.dockerenv"
725
+ );
726
+ const manager = createManager();
727
+ await manager.createDeployment(
728
+ "test-deploy",
729
+ "user",
730
+ "user-id",
731
+ createTestMessagePayload()
732
+ );
733
+ expect(getDispatcherUrlEnv()).toContain("gateway");
734
+ existsSpy.mockRestore();
735
+ });
736
+
737
+ test('returns "gateway" when CONTAINER=true', async () => {
738
+ process.env.CONTAINER = "true";
739
+ const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
740
+ const manager = createManager();
741
+ await manager.createDeployment(
742
+ "test-deploy",
743
+ "user",
744
+ "user-id",
745
+ createTestMessagePayload()
746
+ );
747
+ expect(getDispatcherUrlEnv()).toContain("gateway");
748
+ existsSpy.mockRestore();
749
+ });
750
+
751
+ test('returns "host.docker.internal" when running on host', async () => {
752
+ delete process.env.CONTAINER;
753
+ const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
754
+ const manager = createManager();
755
+ await manager.createDeployment(
756
+ "test-deploy",
757
+ "user",
758
+ "user-id",
759
+ createTestMessagePayload()
760
+ );
761
+ expect(getDispatcherUrlEnv()).toContain("host.docker.internal");
762
+ existsSpy.mockRestore();
763
+ });
764
+ });
765
+
766
+ // =========================================================================
767
+ // Docker image reference
768
+ // =========================================================================
769
+
770
+ describe("image reference", () => {
771
+ test("uses digest reference when configured: repo@sha256:abc123", async () => {
772
+ const manager = createManager({
773
+ worker: {
774
+ ...TEST_CONFIG.worker,
775
+ image: {
776
+ repository: "lobu-worker",
777
+ tag: "latest",
778
+ digest: "abc123def456",
779
+ pullPolicy: "IfNotPresent",
780
+ },
781
+ },
782
+ });
783
+ await manager.createDeployment(
784
+ "test-deploy",
785
+ "user",
786
+ "user-id",
787
+ createTestMessagePayload()
788
+ );
789
+ expect(getMainCreateOpts().Image).toBe("lobu-worker@sha256:abc123def456");
790
+ });
791
+
792
+ test("uses tag reference when no digest: repo:tag", async () => {
793
+ const manager = createManager();
794
+ await manager.createDeployment(
795
+ "test-deploy",
796
+ "user",
797
+ "user-id",
798
+ createTestMessagePayload()
799
+ );
800
+ expect(getMainCreateOpts().Image).toBe("lobu-worker:latest");
801
+ });
802
+
803
+ test("handles digest that already has sha256: prefix", async () => {
804
+ const manager = createManager({
805
+ worker: {
806
+ ...TEST_CONFIG.worker,
807
+ image: {
808
+ repository: "lobu-worker",
809
+ tag: "latest",
810
+ digest: "sha256:abc123def456",
811
+ pullPolicy: "IfNotPresent",
812
+ },
813
+ },
814
+ });
815
+ await manager.createDeployment(
816
+ "test-deploy",
817
+ "user",
818
+ "user-id",
819
+ createTestMessagePayload()
820
+ );
821
+ expect(getMainCreateOpts().Image).toBe("lobu-worker@sha256:abc123def456");
822
+ });
823
+ });
824
+
825
+ // =========================================================================
826
+ // deleteDeployment
827
+ // =========================================================================
828
+
829
+ describe("deleteDeployment", () => {
830
+ test("calls stop + remove on container", async () => {
831
+ const manager = createManager();
832
+
833
+ await manager.deleteDeployment("test-deploy");
834
+
835
+ expect(mockContainer.stop).toHaveBeenCalled();
836
+ expect(mockContainer.remove).toHaveBeenCalled();
837
+ });
838
+
839
+ test("handles 404 (container already deleted) gracefully", async () => {
840
+ const stopMock = mock(async () => {
841
+ /* noop */
842
+ });
843
+ const removeMock = mock(async () => {
844
+ const err: any = new Error("not found");
845
+ err.statusCode = 404;
846
+ throw err;
847
+ });
848
+ mockDocker.getContainer.mockImplementation(() => ({
849
+ stop: stopMock,
850
+ remove: removeMock,
851
+ }));
852
+
853
+ const manager = createManager();
854
+
855
+ // Should not throw on 404
856
+ await expect(
857
+ manager.deleteDeployment("nonexistent")
858
+ ).resolves.toBeUndefined();
859
+ });
860
+
861
+ test("handles already-stopped container gracefully", async () => {
862
+ mockContainer.stop.mockImplementation(async () => {
863
+ throw new Error("container already stopped");
864
+ });
865
+
866
+ const manager = createManager();
867
+
868
+ // Should not throw - stop failure is caught
869
+ await expect(
870
+ manager.deleteDeployment("test-deploy")
871
+ ).resolves.toBeUndefined();
872
+ expect(mockContainer.remove).toHaveBeenCalled();
873
+ });
874
+ });
875
+
876
+ // =========================================================================
877
+ // scaleDeployment
878
+ // =========================================================================
879
+
880
+ describe("scaleDeployment", () => {
881
+ test("scaleDeployment(0) stops running container", async () => {
882
+ mockContainer.inspect.mockImplementation(async () => ({
883
+ State: { Running: true },
884
+ }));
885
+
886
+ const manager = createManager();
887
+ await manager.scaleDeployment("test-deploy", 0);
888
+
889
+ expect(mockContainer.stop).toHaveBeenCalled();
890
+ });
891
+
892
+ test("scaleDeployment(1) starts stopped container", async () => {
893
+ mockContainer.inspect.mockImplementation(async () => ({
894
+ State: { Running: false },
895
+ }));
896
+
897
+ const manager = createManager();
898
+ await manager.scaleDeployment("test-deploy", 1);
899
+
900
+ expect(mockContainer.start).toHaveBeenCalled();
901
+ });
902
+
903
+ test("scaleDeployment(0) is a no-op if container already stopped", async () => {
904
+ mockContainer.inspect.mockImplementation(async () => ({
905
+ State: { Running: false },
906
+ }));
907
+
908
+ const manager = createManager();
909
+ await manager.scaleDeployment("test-deploy", 0);
910
+
911
+ expect(mockContainer.stop).not.toHaveBeenCalled();
912
+ });
913
+
914
+ test("scaleDeployment(1) is a no-op if container already running", async () => {
915
+ mockContainer.inspect.mockImplementation(async () => ({
916
+ State: { Running: true },
917
+ }));
918
+
919
+ const manager = createManager();
920
+ await manager.scaleDeployment("test-deploy", 1);
921
+
922
+ expect(mockContainer.start).not.toHaveBeenCalled();
923
+ });
924
+ });
925
+
926
+ // =========================================================================
927
+ // listDeployments
928
+ // =========================================================================
929
+
930
+ describe("listDeployments", () => {
931
+ test("with no containers returns empty", async () => {
932
+ mockDocker.listContainers.mockImplementation(async () => []);
933
+
934
+ const manager = createManager();
935
+ const result = await manager.listDeployments();
936
+
937
+ expect(result).toEqual([]);
938
+ });
939
+
940
+ test("with containers returns DeploymentInfo entries", async () => {
941
+ const now = Math.floor(Date.now() / 1000);
942
+ mockDocker.listContainers.mockImplementation(async () => [
943
+ {
944
+ Names: ["/lobu-worker-test-123"],
945
+ State: "running",
946
+ Created: now - 60, // 60 seconds ago
947
+ Labels: {
948
+ "app.kubernetes.io/component": "worker",
949
+ "lobu.io/created": new Date((now - 60) * 1000).toISOString(),
950
+ },
951
+ },
952
+ {
953
+ Names: ["/lobu-worker-test-456"],
954
+ State: "exited",
955
+ Created: now - 3600, // 1 hour ago
956
+ Labels: {
957
+ "app.kubernetes.io/component": "worker",
958
+ },
959
+ },
960
+ ]);
961
+
962
+ const manager = createManager();
963
+ const result = await manager.listDeployments();
964
+
965
+ expect(result).toHaveLength(2);
966
+ expect(result[0].deploymentName).toBe("lobu-worker-test-123");
967
+ expect(result[0].replicas).toBe(1); // running
968
+ expect(result[1].deploymentName).toBe("lobu-worker-test-456");
969
+ expect(result[1].replicas).toBe(0); // exited
970
+ });
971
+
972
+ test("filters by worker label", async () => {
973
+ const manager = createManager();
974
+ await manager.listDeployments();
975
+
976
+ expect(mockDocker.listContainers).toHaveBeenCalledWith({
977
+ all: true,
978
+ filters: {
979
+ label: ["app.kubernetes.io/component=worker"],
980
+ },
981
+ });
982
+ });
983
+ });
984
+
985
+ // =========================================================================
986
+ // Activity tracking
987
+ // =========================================================================
988
+
989
+ describe("activity tracking", () => {
990
+ test("updateDeploymentActivity stores timestamp", async () => {
991
+ const manager = createManager();
992
+
993
+ await manager.updateDeploymentActivity("test-deploy");
994
+
995
+ // Verify by listing deployments with a container matching the name
996
+ const now = Math.floor(Date.now() / 1000);
997
+ mockDocker.listContainers.mockImplementation(async () => [
998
+ {
999
+ Names: ["/test-deploy"],
1000
+ State: "running",
1001
+ Created: now - 86400, // 1 day ago
1002
+ Labels: {
1003
+ "app.kubernetes.io/component": "worker",
1004
+ "lobu.io/created": new Date((now - 86400) * 1000).toISOString(),
1005
+ },
1006
+ },
1007
+ ]);
1008
+
1009
+ const deployments = await manager.listDeployments();
1010
+ // The tracked activity should be very recent (just now), not 1 day ago
1011
+ expect(deployments[0].minutesIdle).toBeLessThan(1);
1012
+ });
1013
+
1014
+ test("listDeployments uses most recent of tracked vs label activity", async () => {
1015
+ const manager = createManager();
1016
+ const now = Math.floor(Date.now() / 1000);
1017
+
1018
+ // Set up a container with an old label timestamp
1019
+ mockDocker.listContainers.mockImplementation(async () => [
1020
+ {
1021
+ Names: ["/tracked-deploy"],
1022
+ State: "running",
1023
+ Created: now - 7200, // 2 hours ago
1024
+ Labels: {
1025
+ "app.kubernetes.io/component": "worker",
1026
+ "lobu.io/last-activity": new Date(
1027
+ (now - 7200) * 1000
1028
+ ).toISOString(),
1029
+ },
1030
+ },
1031
+ ]);
1032
+
1033
+ // Update activity in-memory (very recent)
1034
+ await manager.updateDeploymentActivity("tracked-deploy");
1035
+
1036
+ const deployments = await manager.listDeployments();
1037
+ // Should use tracked (recent) not label (2 hours ago)
1038
+ expect(deployments[0].minutesIdle).toBeLessThan(1);
1039
+ });
1040
+ });
1041
+
1042
+ // =========================================================================
1043
+ // validateWorkerImage
1044
+ // =========================================================================
1045
+
1046
+ describe("validateWorkerImage", () => {
1047
+ test("succeeds when image exists locally", async () => {
1048
+ const manager = createManager();
1049
+ await expect(manager.validateWorkerImage()).resolves.toBeUndefined();
1050
+ });
1051
+
1052
+ test("pulls image when not found locally", async () => {
1053
+ mockDocker.getImage.mockImplementation(() => ({
1054
+ inspect: mock(async () => {
1055
+ throw new Error("No such image");
1056
+ }),
1057
+ }));
1058
+
1059
+ const manager = createManager();
1060
+ await expect(manager.validateWorkerImage()).resolves.toBeUndefined();
1061
+ expect(mockDocker.pull).toHaveBeenCalled();
1062
+ });
1063
+
1064
+ test("throws when image not found and pull fails", async () => {
1065
+ mockDocker.getImage.mockImplementation(() => ({
1066
+ inspect: mock(async () => {
1067
+ throw new Error("No such image");
1068
+ }),
1069
+ }));
1070
+ mockDocker.pull.mockImplementation((_name: string, cb: Function) =>
1071
+ cb(new Error("pull failed"))
1072
+ );
1073
+
1074
+ const manager = createManager();
1075
+ await expect(manager.validateWorkerImage()).rejects.toThrow(
1076
+ "does not exist locally and pull failed"
1077
+ );
1078
+ });
1079
+ });
1080
+
1081
+ // =========================================================================
1082
+ // Labels & compose integration
1083
+ // =========================================================================
1084
+
1085
+ describe("labels", () => {
1086
+ test("sets base worker labels", async () => {
1087
+ const manager = createManager();
1088
+ await manager.createDeployment(
1089
+ "test-deploy",
1090
+ "user",
1091
+ "user-id",
1092
+ createTestMessagePayload()
1093
+ );
1094
+ const labels = getMainCreateOpts().Labels;
1095
+ expect(labels["app.kubernetes.io/name"]).toBe("lobu");
1096
+ expect(labels["app.kubernetes.io/component"]).toBe("worker");
1097
+ expect(labels["lobu/managed-by"]).toBe("orchestrator");
1098
+ });
1099
+
1100
+ test("sets Docker Compose project labels", async () => {
1101
+ process.env.COMPOSE_PROJECT_NAME = "myproject";
1102
+ const manager = createManager();
1103
+ await manager.createDeployment(
1104
+ "test-deploy",
1105
+ "user",
1106
+ "user-id",
1107
+ createTestMessagePayload()
1108
+ );
1109
+ const labels = getMainCreateOpts().Labels;
1110
+ expect(labels["com.docker.compose.project"]).toBe("myproject");
1111
+ expect(labels["com.docker.compose.service"]).toBe("test-deploy");
1112
+ });
1113
+
1114
+ test("sets agent-id label", async () => {
1115
+ const manager = createManager();
1116
+ await manager.createDeployment(
1117
+ "test-deploy",
1118
+ "user",
1119
+ "user-id",
1120
+ createTestMessagePayload({ agentId: "my-agent-123" })
1121
+ );
1122
+ expect(getMainCreateOpts().Labels["lobu.io/agent-id"]).toBe(
1123
+ "my-agent-123"
1124
+ );
1125
+ });
1126
+ });
1127
+
1128
+ // =========================================================================
1129
+ // Extra hosts & host mode
1130
+ // =========================================================================
1131
+
1132
+ describe("extra hosts", () => {
1133
+ test("adds ExtraHosts when running on host (not in container)", async () => {
1134
+ delete process.env.CONTAINER;
1135
+ const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
1136
+ const manager = createManager();
1137
+ await manager.createDeployment(
1138
+ "test-deploy",
1139
+ "user",
1140
+ "user-id",
1141
+ createTestMessagePayload()
1142
+ );
1143
+ expect(getMainCreateOpts().HostConfig.ExtraHosts).toEqual([
1144
+ "host.docker.internal:host-gateway",
1145
+ ]);
1146
+ existsSpy.mockRestore();
1147
+ });
1148
+
1149
+ test("does not add ExtraHosts when running in container", async () => {
1150
+ process.env.CONTAINER = "true";
1151
+ const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
1152
+ const manager = createManager();
1153
+ await manager.createDeployment(
1154
+ "test-deploy",
1155
+ "user",
1156
+ "user-id",
1157
+ createTestMessagePayload()
1158
+ );
1159
+ expect(getMainCreateOpts().HostConfig.ExtraHosts).toBeUndefined();
1160
+ existsSpy.mockRestore();
1161
+ });
1162
+ });
1163
+
1164
+ // =========================================================================
1165
+ // Security options
1166
+ // =========================================================================
1167
+
1168
+ describe("advanced security options", () => {
1169
+ test("adds seccomp profile when WORKER_SECCOMP_PROFILE set", async () => {
1170
+ process.env.WORKER_SECCOMP_PROFILE = "/path/to/seccomp.json";
1171
+ const manager = createManager();
1172
+ await manager.createDeployment(
1173
+ "test-deploy",
1174
+ "user",
1175
+ "user-id",
1176
+ createTestMessagePayload()
1177
+ );
1178
+ expect(getMainCreateOpts().HostConfig.SecurityOpt).toContain(
1179
+ "seccomp=/path/to/seccomp.json"
1180
+ );
1181
+ });
1182
+
1183
+ test("adds apparmor profile when WORKER_APPARMOR_PROFILE set", async () => {
1184
+ process.env.WORKER_APPARMOR_PROFILE = "docker-custom";
1185
+ const manager = createManager();
1186
+ await manager.createDeployment(
1187
+ "test-deploy",
1188
+ "user",
1189
+ "user-id",
1190
+ createTestMessagePayload()
1191
+ );
1192
+ expect(getMainCreateOpts().HostConfig.SecurityOpt).toContain(
1193
+ "apparmor=docker-custom"
1194
+ );
1195
+ });
1196
+
1197
+ test("disables readonly rootfs when WORKER_READONLY_ROOTFS=false", async () => {
1198
+ process.env.WORKER_READONLY_ROOTFS = "false";
1199
+ const manager = createManager();
1200
+ await manager.createDeployment(
1201
+ "test-deploy",
1202
+ "user",
1203
+ "user-id",
1204
+ createTestMessagePayload()
1205
+ );
1206
+ const hc = getMainCreateOpts().HostConfig;
1207
+ expect(hc.ReadonlyRootfs).toBe(false);
1208
+ expect(hc.Tmpfs).toBeUndefined();
1209
+ });
1210
+ });
1211
+ });