clawmini 0.0.2 → 0.0.3

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 (102) hide show
  1. package/.github/workflows/ci.yml +59 -0
  2. package/README.md +4 -2
  3. package/dist/adapter-discord/index.d.mts.map +1 -1
  4. package/dist/adapter-discord/index.mjs +13 -4
  5. package/dist/adapter-discord/index.mjs.map +1 -1
  6. package/dist/cli/index.mjs +7 -6
  7. package/dist/cli/index.mjs.map +1 -1
  8. package/dist/cli/lite.mjs +16 -10
  9. package/dist/cli/lite.mjs.map +1 -1
  10. package/dist/daemon/index.mjs +590 -401
  11. package/dist/daemon/index.mjs.map +1 -1
  12. package/dist/{fetch-BjZVyU3Z.mjs → fetch-Cn1XNyiO.mjs} +1 -1
  13. package/dist/{fetch-BjZVyU3Z.mjs.map → fetch-Cn1XNyiO.mjs.map} +1 -1
  14. package/dist/lite-oSYSvaOr.mjs +164 -0
  15. package/dist/lite-oSYSvaOr.mjs.map +1 -0
  16. package/dist/web/_app/immutable/chunks/{CAZeqksE.js → 8YNcRyEk.js} +1 -1
  17. package/dist/web/_app/immutable/chunks/{B3YcEpQV.js → DQoygso7.js} +1 -1
  18. package/dist/web/_app/immutable/entry/{app.ZuicLpkH.js → app.DO5eYwVz.js} +2 -2
  19. package/dist/web/_app/immutable/entry/start.D48mVn1m.js +1 -0
  20. package/dist/web/_app/immutable/nodes/{0.BB1CjKco.js → 0.B-0CcADM.js} +1 -1
  21. package/dist/web/_app/immutable/nodes/{1.CdSgEHu9.js → 1.FixKgvRO.js} +1 -1
  22. package/{web/.svelte-kit/output/client/_app/immutable/nodes/3.CKp7Wkn8.js → dist/web/_app/immutable/nodes/3.ncP0xLO6.js} +1 -1
  23. package/dist/web/_app/immutable/nodes/{4.FyeoMY-Y.js → 4.CQYJEgv8.js} +1 -1
  24. package/dist/web/_app/immutable/nodes/{5.D6mVN7l7.js → 5.BpJUN6QH.js} +1 -1
  25. package/dist/web/_app/version.json +1 -1
  26. package/dist/web/index.html +6 -6
  27. package/dist/{workspace-BC1ahx4R.mjs → workspace-DjoNjhW0.mjs} +12 -42
  28. package/dist/workspace-DjoNjhW0.mjs.map +1 -0
  29. package/docs/15_lite_fetch_pending/development_log.md +31 -0
  30. package/docs/15_lite_fetch_pending/notes.md +48 -0
  31. package/docs/15_lite_fetch_pending/prd.md +39 -0
  32. package/docs/15_lite_fetch_pending/questions.md +3 -0
  33. package/docs/15_lite_fetch_pending/tickets.md +42 -0
  34. package/docs/CHECKS.md +2 -2
  35. package/eslint.config.js +12 -0
  36. package/package.json +3 -2
  37. package/src/adapter-discord/client.ts +1 -1
  38. package/src/adapter-discord/index.ts +22 -5
  39. package/src/cli/client.ts +8 -3
  40. package/src/cli/e2e/adapter-discord.test.ts +2 -2
  41. package/src/cli/e2e/daemon.test.ts +2 -1
  42. package/src/cli/e2e/export-lite-func.test.ts +41 -13
  43. package/src/cli/e2e/fallbacks.test.ts +4 -0
  44. package/src/cli/lite.ts +24 -6
  45. package/src/daemon/api/agent-router.ts +191 -0
  46. package/src/daemon/{router.test.ts → api/index.test.ts} +101 -34
  47. package/src/daemon/api/index.ts +4 -0
  48. package/src/daemon/{router-policy-request.test.ts → api/policy-request.test.ts} +27 -13
  49. package/src/daemon/api/router-utils.ts +159 -0
  50. package/src/daemon/api/trpc.ts +30 -0
  51. package/src/daemon/api/user-router.ts +221 -0
  52. package/src/daemon/index.ts +3 -3
  53. package/src/daemon/message-interruption.test.ts +17 -10
  54. package/src/daemon/message-typing.test.ts +1 -1
  55. package/src/daemon/message.ts +260 -239
  56. package/src/daemon/observation.test.ts +1 -1
  57. package/src/daemon/queue.test.ts +28 -0
  58. package/src/daemon/queue.ts +30 -15
  59. package/src/daemon/request-store.test.ts +4 -4
  60. package/src/daemon/request-store.ts +3 -1
  61. package/src/shared/workspace.ts +4 -5
  62. package/templates/debug/settings.json +5 -0
  63. package/templates/environments/macos/env.json +1 -1
  64. package/templates/environments/macos-proxy/env.json +1 -1
  65. package/templates/gemini-claw/.gemini/hooks/insert-pending.sh +9 -0
  66. package/templates/gemini-claw/.gemini/settings.json +14 -1
  67. package/templates/gemini-claw/.gemini/system.md +2 -0
  68. package/web/.svelte-kit/generated/server/internal.js +1 -1
  69. package/web/.svelte-kit/output/client/.vite/manifest.json +26 -26
  70. package/web/.svelte-kit/output/client/_app/immutable/chunks/{CAZeqksE.js → 8YNcRyEk.js} +1 -1
  71. package/web/.svelte-kit/output/client/_app/immutable/chunks/{B3YcEpQV.js → DQoygso7.js} +1 -1
  72. package/web/.svelte-kit/output/client/_app/immutable/entry/{app.ZuicLpkH.js → app.DO5eYwVz.js} +2 -2
  73. package/web/.svelte-kit/output/client/_app/immutable/entry/start.D48mVn1m.js +1 -0
  74. package/web/.svelte-kit/output/client/_app/immutable/nodes/{0.BB1CjKco.js → 0.B-0CcADM.js} +1 -1
  75. package/web/.svelte-kit/output/client/_app/immutable/nodes/{1.CdSgEHu9.js → 1.FixKgvRO.js} +1 -1
  76. package/{dist/web/_app/immutable/nodes/3.CKp7Wkn8.js → web/.svelte-kit/output/client/_app/immutable/nodes/3.ncP0xLO6.js} +1 -1
  77. package/web/.svelte-kit/output/client/_app/immutable/nodes/{4.FyeoMY-Y.js → 4.CQYJEgv8.js} +1 -1
  78. package/web/.svelte-kit/output/client/_app/immutable/nodes/{5.D6mVN7l7.js → 5.BpJUN6QH.js} +1 -1
  79. package/web/.svelte-kit/output/client/_app/version.json +1 -1
  80. package/web/.svelte-kit/output/server/chunks/internal.js +1 -1
  81. package/web/.svelte-kit/output/server/manifest-full.js +1 -1
  82. package/web/.svelte-kit/output/server/manifest.js +1 -1
  83. package/web/.svelte-kit/output/server/nodes/0.js +1 -1
  84. package/web/.svelte-kit/output/server/nodes/1.js +1 -1
  85. package/web/.svelte-kit/output/server/nodes/3.js +1 -1
  86. package/web/.svelte-kit/output/server/nodes/4.js +1 -1
  87. package/web/.svelte-kit/output/server/nodes/5.js +1 -1
  88. package/dist/chats-BcbxvPlj.mjs +0 -29
  89. package/dist/chats-BcbxvPlj.mjs.map +0 -1
  90. package/dist/chats-CpRQrNHj.mjs +0 -91
  91. package/dist/chats-CpRQrNHj.mjs.map +0 -1
  92. package/dist/fs-B5wW0oaH.mjs +0 -14
  93. package/dist/fs-B5wW0oaH.mjs.map +0 -1
  94. package/dist/lite-DBUuHsX0.mjs +0 -80
  95. package/dist/lite-DBUuHsX0.mjs.map +0 -1
  96. package/dist/policy-utils-BvfOK6Ih.mjs +0 -114
  97. package/dist/policy-utils-BvfOK6Ih.mjs.map +0 -1
  98. package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
  99. package/dist/web/_app/immutable/entry/start.DuQwh4Nz.js +0 -1
  100. package/dist/workspace-BC1ahx4R.mjs.map +0 -1
  101. package/src/daemon/router.ts +0 -510
  102. package/web/.svelte-kit/output/client/_app/immutable/entry/start.DuQwh4Nz.js +0 -1
@@ -1,10 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
- import { appRouter } from './router.js';
4
- import * as workspace from '../shared/workspace.js';
5
- import * as chats from '../shared/chats.js';
6
- import type { CronJob } from '../shared/config.js';
7
- import * as message from './message.js';
3
+ import { userRouter, agentRouter } from './index.js';
4
+ // No merged router to avoid duplicate key errors.
5
+ import * as workspace from '../../shared/workspace.js';
6
+ import * as chats from '../../shared/chats.js';
7
+ import type { CronJob } from '../../shared/config.js';
8
+ import * as message from '../message.js';
9
+ import { getMessageQueue } from '../queue.js';
8
10
  import * as fs from 'node:fs/promises';
9
11
  import path from 'node:path';
10
12
 
@@ -32,12 +34,16 @@ vi.mock('node:fs/promises', async (importOriginal) => {
32
34
  };
33
35
  });
34
36
 
35
- vi.mock('./message.js', () => ({
36
- handleUserMessage: vi.fn(),
37
- }));
37
+ vi.mock('../message.js', async (importOriginal) => {
38
+ const actual = await importOriginal<typeof import('../message.js')>();
39
+ return {
40
+ ...actual,
41
+ handleUserMessage: vi.fn(),
42
+ };
43
+ });
38
44
 
39
- vi.mock('../shared/workspace.js', async (importOriginal) => {
40
- const actual = await importOriginal<typeof import('../shared/workspace.js')>();
45
+ vi.mock('../../shared/workspace.js', async (importOriginal) => {
46
+ const actual = await importOriginal<typeof import('../../shared/workspace.js')>();
41
47
  return {
42
48
  ...actual,
43
49
  readChatSettings: vi.fn(),
@@ -46,14 +52,14 @@ vi.mock('../shared/workspace.js', async (importOriginal) => {
46
52
  getAgent: vi.fn(),
47
53
  getWorkspaceRoot: vi.fn().mockReturnValue(process.cwd()),
48
54
  getActiveEnvironmentName: vi.fn().mockResolvedValue(null),
49
- getActiveEnvironmentInfo: vi.fn().mockResolvedValue(null),
55
+ getActiveEnvironmentInfo: vi.fn().mockResolvedValue(null),
50
56
  getEnvironmentPath: vi.fn().mockReturnValue(''),
51
57
  readEnvironment: vi.fn().mockResolvedValue(null),
52
58
  };
53
59
  });
54
60
 
55
- vi.mock('../shared/chats.js', async (importOriginal) => {
56
- const actual = await importOriginal<typeof import('../shared/chats.js')>();
61
+ vi.mock('../../shared/chats.js', async (importOriginal) => {
62
+ const actual = await importOriginal<typeof import('../../shared/chats.js')>();
57
63
  return {
58
64
  ...actual,
59
65
  getDefaultChatId: vi.fn(),
@@ -77,7 +83,7 @@ describe('Daemon TRPC Router', () => {
77
83
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
78
84
  vi.mocked(workspace.readChatSettings).mockResolvedValue({});
79
85
 
80
- const caller = appRouter.createCaller({});
86
+ const caller = userRouter.createCaller({});
81
87
  const jobs = await caller.listCronJobs({});
82
88
  expect(jobs).toEqual([]);
83
89
  expect(workspace.readChatSettings).toHaveBeenCalledWith('default-chat');
@@ -87,7 +93,7 @@ describe('Daemon TRPC Router', () => {
87
93
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
88
94
  vi.mocked(workspace.readChatSettings).mockResolvedValue({ jobs: [mockJob] });
89
95
 
90
- const caller = appRouter.createCaller({});
96
+ const caller = userRouter.createCaller({});
91
97
  const jobs = await caller.listCronJobs({ chatId: 'custom-chat' });
92
98
  expect(jobs).toEqual([mockJob]);
93
99
  expect(workspace.readChatSettings).toHaveBeenCalledWith('custom-chat');
@@ -97,7 +103,7 @@ describe('Daemon TRPC Router', () => {
97
103
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
98
104
  vi.mocked(workspace.readChatSettings).mockResolvedValue({});
99
105
 
100
- const caller = appRouter.createCaller({});
106
+ const caller = userRouter.createCaller({});
101
107
  const result = await caller.addCronJob({ job: mockJob });
102
108
 
103
109
  expect(result.success).toBe(true);
@@ -110,7 +116,7 @@ describe('Daemon TRPC Router', () => {
110
116
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
111
117
  vi.mocked(workspace.readChatSettings).mockResolvedValue({ jobs: [mockJob] });
112
118
 
113
- const caller = appRouter.createCaller({});
119
+ const caller = userRouter.createCaller({});
114
120
  const updatedJob = { ...mockJob, message: 'updated' };
115
121
  const result = await caller.addCronJob({ job: updatedJob });
116
122
 
@@ -124,7 +130,7 @@ describe('Daemon TRPC Router', () => {
124
130
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
125
131
  vi.mocked(workspace.readChatSettings).mockResolvedValue({ jobs: [mockJob] });
126
132
 
127
- const caller = appRouter.createCaller({});
133
+ const caller = userRouter.createCaller({});
128
134
  const result = await caller.deleteCronJob({ id: 'job-1' });
129
135
 
130
136
  expect(result.success).toBe(true);
@@ -136,7 +142,7 @@ describe('Daemon TRPC Router', () => {
136
142
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
137
143
  vi.mocked(workspace.readChatSettings).mockResolvedValue({ jobs: [mockJob] });
138
144
 
139
- const caller = appRouter.createCaller({});
145
+ const caller = userRouter.createCaller({});
140
146
  const result = await caller.deleteCronJob({ id: 'non-existent' });
141
147
 
142
148
  expect(result.success).toBe(true);
@@ -150,7 +156,7 @@ describe('Daemon TRPC Router', () => {
150
156
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
151
157
  vi.mocked((fs as any).default.readFile).mockResolvedValue('{}');
152
158
 
153
- const caller = appRouter.createCaller({});
159
+ const caller = userRouter.createCaller({});
154
160
  await caller.sendMessage({
155
161
  type: 'send-message',
156
162
  client: 'cli',
@@ -178,7 +184,7 @@ describe('Daemon TRPC Router', () => {
178
184
  vi.mocked((fs as any).default.rename).mockResolvedValue(undefined);
179
185
  vi.mocked((fs as any).default.access).mockResolvedValue(undefined);
180
186
 
181
- const caller = appRouter.createCaller({});
187
+ const caller = userRouter.createCaller({});
182
188
  await caller.sendMessage({
183
189
  type: 'send-message',
184
190
  client: 'cli',
@@ -216,7 +222,7 @@ describe('Daemon TRPC Router', () => {
216
222
  .mockRejectedValue(new Error('not found'));
217
223
  vi.mocked((fs as any).default.rename).mockResolvedValue(undefined);
218
224
 
219
- const caller = appRouter.createCaller({});
225
+ const caller = userRouter.createCaller({});
220
226
  await caller.sendMessage({
221
227
  type: 'send-message',
222
228
  client: 'cli',
@@ -243,7 +249,7 @@ describe('Daemon TRPC Router', () => {
243
249
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
244
250
  vi.mocked((fs as any).default.readFile).mockResolvedValue('{}');
245
251
 
246
- const caller = appRouter.createCaller({});
252
+ const caller = userRouter.createCaller({});
247
253
  await expect(
248
254
  caller.sendMessage({
249
255
  type: 'send-message',
@@ -262,7 +268,7 @@ describe('Daemon TRPC Router', () => {
262
268
  vi.mocked((fs as any).default.readFile).mockResolvedValue('{}');
263
269
  vi.mocked((fs as any).default.access).mockRejectedValue(new Error('ENOENT'));
264
270
 
265
- const caller = appRouter.createCaller({});
271
+ const caller = userRouter.createCaller({});
266
272
  await expect(
267
273
  caller.sendMessage({
268
274
  type: 'send-message',
@@ -282,9 +288,11 @@ describe('Daemon TRPC Router', () => {
282
288
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
283
289
  vi.mocked(chats.appendMessage).mockResolvedValue(undefined);
284
290
 
285
- const caller = appRouter.createCaller({});
291
+ const caller = agentRouter.createCaller({
292
+ isApiServer: true,
293
+ tokenPayload: { agentId: 'default', chatId: 'default-chat' },
294
+ } as any);
286
295
  const result = await caller.logMessage({
287
- chatId: 'default-chat',
288
296
  message: 'Test log',
289
297
  });
290
298
 
@@ -304,9 +312,11 @@ describe('Daemon TRPC Router', () => {
304
312
  vi.mocked(chats.appendMessage).mockResolvedValue(undefined);
305
313
  vi.mocked((fs as any).default.access).mockResolvedValue(undefined);
306
314
 
307
- const caller = appRouter.createCaller({});
315
+ const caller = agentRouter.createCaller({
316
+ isApiServer: true,
317
+ tokenPayload: { agentId: 'default', chatId: 'default-chat' },
318
+ } as any);
308
319
  const result = await caller.logMessage({
309
- chatId: 'default-chat',
310
320
  message: 'Test log with file',
311
321
  files: ['attachments/discord/image.png'],
312
322
  });
@@ -325,10 +335,12 @@ describe('Daemon TRPC Router', () => {
325
335
  it('should reject file path with directory traversal (..)', async () => {
326
336
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
327
337
 
328
- const caller = appRouter.createCaller({});
338
+ const caller = agentRouter.createCaller({
339
+ isApiServer: true,
340
+ tokenPayload: { agentId: 'default', chatId: 'default-chat' },
341
+ } as any);
329
342
  await expect(
330
343
  caller.logMessage({
331
- chatId: 'default-chat',
332
344
  message: 'Malicious log',
333
345
  files: ['../secret.txt'],
334
346
  })
@@ -338,10 +350,12 @@ describe('Daemon TRPC Router', () => {
338
350
  it('should reject file path with absolute path', async () => {
339
351
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
340
352
 
341
- const caller = appRouter.createCaller({});
353
+ const caller = agentRouter.createCaller({
354
+ isApiServer: true,
355
+ tokenPayload: { agentId: 'default', chatId: 'default-chat' },
356
+ } as any);
342
357
  await expect(
343
358
  caller.logMessage({
344
- chatId: 'default-chat',
345
359
  message: 'Malicious log',
346
360
  files: ['/etc/passwd'],
347
361
  })
@@ -353,7 +367,7 @@ describe('Daemon TRPC Router', () => {
353
367
  it('waitForTyping should yield typing events for the correct chatId', async () => {
354
368
  vi.mocked(chats.getDefaultChatId).mockResolvedValue('default-chat');
355
369
 
356
- const caller = appRouter.createCaller({});
370
+ const caller = userRouter.createCaller({});
357
371
  const iterable = await caller.waitForTyping({ chatId: 'default-chat' });
358
372
  const iterator = iterable[Symbol.asyncIterator]();
359
373
 
@@ -365,7 +379,7 @@ describe('Daemon TRPC Router', () => {
365
379
  if (e2.value) events.push(e2.value);
366
380
  })();
367
381
 
368
- const { daemonEvents, DAEMON_EVENT_TYPING } = await import('./events.js');
382
+ const { daemonEvents, DAEMON_EVENT_TYPING } = await import('../events.js');
369
383
  await new Promise((resolve) => setTimeout(resolve, 10));
370
384
 
371
385
  daemonEvents.emit(DAEMON_EVENT_TYPING, { chatId: 'default-chat' });
@@ -377,4 +391,57 @@ describe('Daemon TRPC Router', () => {
377
391
  expect(events).toEqual([{ chatId: 'default-chat' }, { chatId: 'default-chat' }]);
378
392
  });
379
393
  });
394
+
395
+ describe('fetchPendingMessages', () => {
396
+ let queue: ReturnType<typeof getMessageQueue>;
397
+ beforeEach(() => {
398
+ queue = getMessageQueue(process.cwd());
399
+ queue.clear();
400
+ });
401
+
402
+ it('should extract pending messages from queue matching the session and format them', async () => {
403
+ let resolveFirstTask: () => void;
404
+ const firstTaskPromise = new Promise<void>((r) => {
405
+ resolveFirstTask = r;
406
+ });
407
+
408
+ // The first task will start and block, leaving the others in pending
409
+ queue.enqueue(
410
+ async () => {
411
+ await firstTaskPromise;
412
+ },
413
+ { text: 'Task 1', sessionId: 's1' }
414
+ );
415
+
416
+ // These will stay in pending
417
+ const p2 = queue.enqueue(async () => {}, { text: 'Task 2', sessionId: 's1' });
418
+ const p3 = queue.enqueue(async () => {}, { text: 'Task 3', sessionId: 's1' });
419
+ const p4 = queue.enqueue(async () => {}, { text: 'Task 4', sessionId: 's2' });
420
+
421
+ // We expect them to throw AbortError when extracted
422
+ p2.catch(() => {});
423
+ p3.catch(() => {});
424
+ p4.catch(() => {});
425
+
426
+ const caller = agentRouter.createCaller({
427
+ tokenPayload: { sessionId: 's1', chatId: 'c1', agentId: 'a1', timestamp: 123 },
428
+ });
429
+ const result = await caller.fetchPendingMessages();
430
+
431
+ expect(result.messages).toBe(
432
+ '<message>\nTask 2\n</message>\n\n<message>\nTask 3\n</message>'
433
+ );
434
+ expect(queue.extractPending((p) => p.sessionId === 's2')).toEqual([
435
+ { text: 'Task 4', sessionId: 's2' },
436
+ ]);
437
+
438
+ resolveFirstTask!(); // cleanup
439
+ });
440
+
441
+ it('should return empty string if no pending messages', async () => {
442
+ const caller = agentRouter.createCaller({});
443
+ const result = await caller.fetchPendingMessages();
444
+ expect(result.messages).toBe('');
445
+ });
446
+ });
380
447
  });
@@ -0,0 +1,4 @@
1
+ export * from './trpc.js';
2
+ export * from './router-utils.js';
3
+ export * from './user-router.js';
4
+ export * from './agent-router.js';
@@ -1,19 +1,19 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
- import { appRouter } from './router.js';
4
- import * as chats from '../shared/chats.js';
3
+ import { agentRouter as appRouter } from './index.js';
4
+ import * as chats from '../../shared/chats.js';
5
5
 
6
- vi.mock('../shared/chats.js', () => ({
6
+ vi.mock('../../shared/chats.js', () => ({
7
7
  getDefaultChatId: vi.fn().mockResolvedValue('default-chat'),
8
8
  appendMessage: vi.fn().mockResolvedValue(undefined),
9
9
  }));
10
10
 
11
- vi.mock('../shared/workspace.js', () => ({
11
+ vi.mock('../../shared/workspace.js', () => ({
12
12
  getWorkspaceRoot: vi.fn().mockReturnValue('/mock/workspace'),
13
13
  getClawminiDir: vi.fn().mockReturnValue('/mock/.clawmini'),
14
14
  }));
15
15
 
16
- vi.mock('./policy-request-service.js', () => {
16
+ vi.mock('../policy-request-service.js', () => {
17
17
  return {
18
18
  PolicyRequestService: class {
19
19
  async createRequest() {
@@ -39,12 +39,23 @@ const { mockReadFile } = vi.hoisted(() => {
39
39
  return { mockReadFile: vi.fn() };
40
40
  });
41
41
 
42
- vi.mock('node:fs/promises', () => ({
43
- default: {
42
+ vi.mock('node:fs/promises', async (importOriginal) => {
43
+ const actual = await importOriginal<typeof import('node:fs/promises')>();
44
+ return {
45
+ ...actual,
46
+ default: {
47
+ ...actual,
48
+ readFile: mockReadFile,
49
+ mkdir: vi.fn(),
50
+ readdir: vi.fn().mockResolvedValue([]),
51
+ realpath: vi.fn().mockImplementation((p) => Promise.resolve(p)),
52
+ },
44
53
  readFile: mockReadFile,
45
- },
46
- readFile: mockReadFile,
47
- }));
54
+ mkdir: vi.fn(),
55
+ readdir: vi.fn().mockResolvedValue([]),
56
+ realpath: vi.fn().mockImplementation((p) => Promise.resolve(p)),
57
+ };
58
+ });
48
59
 
49
60
  describe('createPolicyRequest preview message', () => {
50
61
  beforeEach(() => {
@@ -52,7 +63,10 @@ describe('createPolicyRequest preview message', () => {
52
63
  });
53
64
 
54
65
  it('should create a request and append a preview message truncating long files', async () => {
55
- const caller = appRouter.createCaller({});
66
+ const caller = appRouter.createCaller({
67
+ isApiServer: true,
68
+ tokenPayload: { agentId: 'default', chatId: 'default-chat' },
69
+ } as any);
56
70
 
57
71
  // file1 is short, file2 is long
58
72
  const shortContent = 'Hello world!';
@@ -68,8 +82,8 @@ describe('createPolicyRequest preview message', () => {
68
82
  commandName: 'test-cmd',
69
83
  args: ['arg1', 'arg2'],
70
84
  fileMappings: {
71
- file1: '/some/path1',
72
- file2: '/some/path2',
85
+ file1: '/mock/workspace/file1',
86
+ file2: '/mock/workspace/file2',
73
87
  },
74
88
  });
75
89
 
@@ -0,0 +1,159 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { TRPCError } from '@trpc/server';
4
+ import { pathIsInsideDir } from '../../shared/utils/fs.js';
5
+ import {
6
+ getAgent,
7
+ getClawminiDir,
8
+ readChatSettings,
9
+ writeChatSettings,
10
+ } from '../../shared/workspace.js';
11
+ import { cronManager } from '../cron.js';
12
+ import type { z } from 'zod';
13
+ import type { CronJobSchema } from '../../shared/config.js';
14
+
15
+ export async function getUniquePath(p: string): Promise<string> {
16
+ let currentPath = p;
17
+ let counter = 1;
18
+ while (true) {
19
+ try {
20
+ await fs.stat(currentPath);
21
+ const ext = path.extname(p);
22
+ const base = path.basename(p, ext);
23
+ currentPath = path.join(path.dirname(p), `${base}-${counter}${ext}`);
24
+ counter++;
25
+ } catch {
26
+ return currentPath;
27
+ }
28
+ }
29
+ }
30
+
31
+ export async function resolveAgentDir(
32
+ agentId: string | undefined | null,
33
+ workspaceRoot: string
34
+ ): Promise<string> {
35
+ if (agentId && agentId !== 'default') {
36
+ try {
37
+ const agent = await getAgent(agentId, workspaceRoot);
38
+ if (agent && agent.directory) {
39
+ return path.resolve(workspaceRoot, agent.directory);
40
+ }
41
+ } catch (err: unknown) {
42
+ console.warn(`Could not load custom agent '${agentId}' for resolving directory:`, err);
43
+ }
44
+ return path.resolve(workspaceRoot, agentId);
45
+ }
46
+ return workspaceRoot;
47
+ }
48
+
49
+ export async function getAgentFilesDir(
50
+ agentId: string | undefined,
51
+ chatId: string,
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ settings: any,
54
+ workspaceRoot: string
55
+ ): Promise<string> {
56
+ const chatSettings = (await readChatSettings(chatId)) ?? {};
57
+ const targetAgentId = agentId ?? chatSettings.defaultAgent ?? 'default';
58
+ let agentFilesDir = settings?.defaultAgent?.files || './attachments';
59
+ const agentDir = await resolveAgentDir(targetAgentId, workspaceRoot);
60
+
61
+ if (targetAgentId !== 'default') {
62
+ try {
63
+ const customAgent = await getAgent(targetAgentId, workspaceRoot);
64
+ if (customAgent?.files) {
65
+ agentFilesDir = customAgent.files;
66
+ }
67
+ } catch (err: unknown) {
68
+ console.warn(
69
+ `Could not load custom agent '${targetAgentId}' for resolving files directory:`,
70
+ err
71
+ );
72
+ }
73
+ }
74
+
75
+ return path.resolve(agentDir, agentFilesDir);
76
+ }
77
+
78
+ export async function validateAttachments(files: string[]): Promise<void> {
79
+ const tmpDir = path.join(getClawminiDir(process.cwd()), 'tmp');
80
+
81
+ for (const file of files) {
82
+ const absoluteFile = path.resolve(process.cwd(), file);
83
+ if (!pathIsInsideDir(absoluteFile, tmpDir)) {
84
+ throw new TRPCError({
85
+ code: 'BAD_REQUEST',
86
+ message: 'File must be inside the temporary directory.',
87
+ });
88
+ }
89
+ try {
90
+ await fs.access(absoluteFile);
91
+ } catch {
92
+ throw new TRPCError({
93
+ code: 'BAD_REQUEST',
94
+ message: `File does not exist: ${file}`,
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ export async function validateLogFile(
101
+ file: string,
102
+ agentDir: string,
103
+ workspaceRoot: string
104
+ ): Promise<string> {
105
+ const resolvedPath = path.resolve(agentDir, file);
106
+
107
+ if (!pathIsInsideDir(resolvedPath, agentDir, { allowSameDir: true })) {
108
+ throw new TRPCError({
109
+ code: 'BAD_REQUEST',
110
+ message: 'File must be within the agent workspace.',
111
+ });
112
+ }
113
+
114
+ try {
115
+ await fs.access(resolvedPath);
116
+ } catch {
117
+ throw new TRPCError({
118
+ code: 'BAD_REQUEST',
119
+ message: `File does not exist: ${file}`,
120
+ });
121
+ }
122
+
123
+ return path.relative(workspaceRoot, resolvedPath);
124
+ }
125
+
126
+ export async function listCronJobsShared(chatId: string) {
127
+ const settings = await readChatSettings(chatId);
128
+ return settings?.jobs ?? [];
129
+ }
130
+
131
+ export async function addCronJobShared(chatId: string, job: z.infer<typeof CronJobSchema>) {
132
+ const settings = (await readChatSettings(chatId)) || {};
133
+ const cronJobs = settings.jobs ?? [];
134
+ const existingIndex = cronJobs.findIndex((j) => j.id === job.id);
135
+ if (existingIndex >= 0) {
136
+ cronJobs[existingIndex] = job;
137
+ } else {
138
+ cronJobs.push(job);
139
+ }
140
+ settings.jobs = cronJobs;
141
+ await writeChatSettings(chatId, settings);
142
+ cronManager.scheduleJob(chatId, job);
143
+ return { success: true };
144
+ }
145
+
146
+ export async function deleteCronJobShared(chatId: string, id: string) {
147
+ const settings = await readChatSettings(chatId);
148
+ if (!settings || !settings.jobs) {
149
+ return { success: true, deleted: false };
150
+ }
151
+ const initialLength = settings.jobs.length;
152
+ settings.jobs = settings.jobs.filter((j) => j.id !== id);
153
+ if (settings.jobs.length !== initialLength) {
154
+ await writeChatSettings(chatId, settings);
155
+ cronManager.unscheduleJob(chatId, id);
156
+ return { success: true, deleted: true };
157
+ }
158
+ return { success: true, deleted: false };
159
+ }
@@ -0,0 +1,30 @@
1
+ import { initTRPC, TRPCError } from '@trpc/server';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import type { TokenPayload } from '../auth.js';
4
+
5
+ export interface Context {
6
+ req?: IncomingMessage | undefined;
7
+ res?: ServerResponse | undefined;
8
+ isApiServer?: boolean | undefined;
9
+ tokenPayload?: TokenPayload | null | undefined;
10
+ }
11
+
12
+ const t = initTRPC.context<Context>().create();
13
+ export const router = t.router;
14
+ export const publicProcedure = t.procedure;
15
+
16
+ const apiAuthMiddleware = t.middleware(({ ctx, next }) => {
17
+ if (ctx.isApiServer) {
18
+ if (!ctx.tokenPayload) {
19
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Missing or invalid token' });
20
+ }
21
+ }
22
+ return next({
23
+ ctx: {
24
+ ...ctx,
25
+ tokenPayload: ctx.tokenPayload,
26
+ },
27
+ });
28
+ });
29
+
30
+ export const apiProcedure = t.procedure.use(apiAuthMiddleware);