skimpyclaw 0.3.14 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
@@ -1,144 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- const { mockCreateContainer, mockRemoveContainer, mockIsContainerRunning, mockValidateMountPaths, } = vi.hoisted(() => ({
3
- mockCreateContainer: vi.fn(),
4
- mockRemoveContainer: vi.fn(),
5
- mockIsContainerRunning: vi.fn(),
6
- mockValidateMountPaths: vi.fn(),
7
- }));
8
- vi.mock('../sandbox/runtime.js', () => ({
9
- createContainer: mockCreateContainer,
10
- removeContainer: mockRemoveContainer,
11
- isContainerRunning: mockIsContainerRunning,
12
- }));
13
- vi.mock('../sandbox/mount-security.js', () => ({
14
- validateMountPaths: mockValidateMountPaths,
15
- }));
16
- import { ensureContainer, releaseContainer, pruneIdle, releaseAll, resetForTesting, SANDBOX_DEFAULTS, } from '../sandbox/manager.js';
17
- const testConfig = { ...SANDBOX_DEFAULTS, enabled: true };
18
- describe('sandbox/manager', () => {
19
- beforeEach(() => {
20
- resetForTesting();
21
- vi.clearAllMocks();
22
- mockValidateMountPaths.mockReturnValue([
23
- { host: '/home/user/project', container: '/workspace/project', readOnly: false },
24
- ]);
25
- mockCreateContainer.mockResolvedValue(undefined);
26
- mockRemoveContainer.mockResolvedValue(undefined);
27
- });
28
- describe('ensureContainer', () => {
29
- it('creates container on first call', async () => {
30
- const name = await ensureContainer('sess1', testConfig, ['/home/user/project']);
31
- expect(name).toBe('skimpyclaw-sbx-sess1');
32
- expect(mockCreateContainer).toHaveBeenCalledTimes(1);
33
- });
34
- it('reuses on second call if running', async () => {
35
- mockIsContainerRunning
36
- .mockResolvedValueOnce(false)
37
- .mockResolvedValueOnce(true);
38
- await ensureContainer('sess1', testConfig, ['/p']);
39
- const name = await ensureContainer('sess1', testConfig, ['/p']);
40
- expect(name).toBe('skimpyclaw-sbx-sess1');
41
- expect(mockCreateContainer).toHaveBeenCalledTimes(1); // only first call
42
- });
43
- it('recreates if container died', async () => {
44
- mockIsContainerRunning.mockResolvedValue(false);
45
- await ensureContainer('sess1', testConfig, ['/p']);
46
- // Second call — container exists in map but isContainerRunning returns false
47
- await ensureContainer('sess1', testConfig, ['/p']);
48
- expect(mockCreateContainer).toHaveBeenCalledTimes(2);
49
- });
50
- it('adopts existing running container after process restart', async () => {
51
- mockIsContainerRunning.mockResolvedValue(true);
52
- const name = await ensureContainer('default', testConfig, ['/p']);
53
- expect(name).toBe('skimpyclaw-sbx-default');
54
- expect(mockCreateContainer).not.toHaveBeenCalled();
55
- expect(mockRemoveContainer).not.toHaveBeenCalled();
56
- });
57
- it('removes stale named container before creating', async () => {
58
- mockIsContainerRunning.mockResolvedValue(false);
59
- const name = await ensureContainer('default', testConfig, ['/p']);
60
- expect(name).toBe('skimpyclaw-sbx-default');
61
- expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-default');
62
- expect(mockCreateContainer).toHaveBeenCalledTimes(1);
63
- });
64
- it('expands ${VAR} references in env from process.env', async () => {
65
- process.env.MY_SECRET = 'hunter2';
66
- try {
67
- const configWithEnv = {
68
- ...testConfig,
69
- env: { TOKEN: '${MY_SECRET}', PLAIN: 'literal' },
70
- };
71
- await ensureContainer('env-test', configWithEnv, ['/p']);
72
- const opts = mockCreateContainer.mock.calls[0][1];
73
- expect(opts.env).toEqual({ TOKEN: 'hunter2', PLAIN: 'literal' });
74
- }
75
- finally {
76
- delete process.env.MY_SECRET;
77
- }
78
- });
79
- it('expands unset ${VAR} to empty string', async () => {
80
- delete process.env.NONEXISTENT_VAR_XYZ;
81
- const configWithEnv = {
82
- ...testConfig,
83
- env: { VAL: '${NONEXISTENT_VAR_XYZ}' },
84
- };
85
- await ensureContainer('env-test2', configWithEnv, ['/p']);
86
- const opts = mockCreateContainer.mock.calls[0][1];
87
- expect(opts.env).toEqual({ VAL: '' });
88
- });
89
- });
90
- describe('releaseContainer', () => {
91
- it('removes container and clears from map', async () => {
92
- await ensureContainer('sess1', testConfig, ['/p']);
93
- await releaseContainer('sess1');
94
- expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-sess1');
95
- // Next ensureContainer should create fresh
96
- await ensureContainer('sess1', testConfig, ['/p']);
97
- expect(mockCreateContainer).toHaveBeenCalledTimes(2);
98
- });
99
- it('no-ops for unknown session', async () => {
100
- await expect(releaseContainer('unknown')).resolves.toBeUndefined();
101
- expect(mockRemoveContainer).not.toHaveBeenCalled();
102
- });
103
- });
104
- describe('pruneIdle', () => {
105
- it('removes containers older than threshold', async () => {
106
- vi.useFakeTimers();
107
- try {
108
- await ensureContainer('old', testConfig, ['/p']);
109
- // Advance time by 10 seconds so the container is idle
110
- vi.advanceTimersByTime(10_000);
111
- const pruned = await pruneIdle(5_000);
112
- expect(pruned).toBe(1);
113
- expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-old');
114
- }
115
- finally {
116
- vi.useRealTimers();
117
- }
118
- });
119
- it('keeps recent containers', async () => {
120
- await ensureContainer('new', testConfig, ['/p']);
121
- const pruned = await pruneIdle(60_000);
122
- expect(pruned).toBe(0);
123
- });
124
- });
125
- describe('releaseAll', () => {
126
- it('removes all containers', async () => {
127
- mockIsContainerRunning.mockResolvedValue(true);
128
- await ensureContainer('a', testConfig, ['/p']);
129
- await ensureContainer('b', testConfig, ['/p']);
130
- await releaseAll();
131
- expect(mockRemoveContainer).toHaveBeenCalledTimes(2);
132
- });
133
- });
134
- describe('resetForTesting', () => {
135
- it('clears state without removing containers', async () => {
136
- mockIsContainerRunning.mockResolvedValue(false);
137
- await ensureContainer('x', testConfig, ['/p']);
138
- resetForTesting();
139
- // Next call should create fresh (map is empty)
140
- await ensureContainer('x', testConfig, ['/p']);
141
- expect(mockCreateContainer).toHaveBeenCalledTimes(2);
142
- });
143
- });
144
- });
@@ -1,139 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- vi.mock('fs', async () => {
3
- const actual = await vi.importActual('fs');
4
- return {
5
- ...actual,
6
- existsSync: vi.fn(),
7
- realpathSync: vi.fn(),
8
- };
9
- });
10
- import { existsSync, realpathSync } from 'fs';
11
- import { isBlockedPath, validateMountPaths, translatePath } from '../sandbox/mount-security.js';
12
- const mockExistsSync = existsSync;
13
- const mockRealpathSync = realpathSync;
14
- describe('sandbox/mount-security', () => {
15
- beforeEach(() => {
16
- vi.clearAllMocks();
17
- });
18
- describe('isBlockedPath', () => {
19
- it('blocks .ssh', () => {
20
- expect(isBlockedPath('/home/user/.ssh')).toBe(true);
21
- expect(isBlockedPath('/home/user/.ssh/id_rsa')).toBe(true);
22
- });
23
- it('blocks .gnupg', () => {
24
- expect(isBlockedPath('/home/user/.gnupg')).toBe(true);
25
- });
26
- it('blocks .aws', () => {
27
- expect(isBlockedPath('/home/user/.aws')).toBe(true);
28
- });
29
- it('blocks credentials', () => {
30
- expect(isBlockedPath('/some/path/credentials')).toBe(true);
31
- });
32
- it('blocks .env', () => {
33
- expect(isBlockedPath('/project/.env')).toBe(true);
34
- });
35
- it('blocks .kube', () => {
36
- expect(isBlockedPath('/home/user/.kube/config')).toBe(true);
37
- });
38
- it('blocks .docker/config.json compound pattern', () => {
39
- expect(isBlockedPath('/home/user/.docker/config.json')).toBe(true);
40
- });
41
- it('allows normal paths', () => {
42
- expect(isBlockedPath('/home/user/project')).toBe(false);
43
- expect(isBlockedPath('/workspace/src/index.ts')).toBe(false);
44
- expect(isBlockedPath('/tmp/test')).toBe(false);
45
- });
46
- });
47
- describe('validateMountPaths', () => {
48
- beforeEach(() => {
49
- mockExistsSync.mockReturnValue(true);
50
- mockRealpathSync.mockImplementation((p) => p);
51
- });
52
- it('maps to /workspace/<basename>', () => {
53
- const mounts = validateMountPaths(['/home/user/myproject']);
54
- const projectMount = mounts.find((m) => m.host === '/home/user/myproject');
55
- expect(projectMount).toBeDefined();
56
- expect(projectMount.container).toBe('/workspace/myproject');
57
- expect(projectMount.readOnly).toBe(false);
58
- });
59
- it('deduplicates basenames with suffix', () => {
60
- const mounts = validateMountPaths(['/a/project', '/b/project']);
61
- const containers = mounts.filter((m) => m.host.endsWith('/project')).map((m) => m.container);
62
- expect(containers).toContain('/workspace/project');
63
- expect(containers).toContain('/workspace/project_1');
64
- });
65
- it('adds ~/.skimpyclaw at /workspace/config', () => {
66
- const mounts = validateMountPaths(['/home/user/project']);
67
- const configMount = mounts.find((m) => m.container === '/workspace/config');
68
- expect(configMount).toBeDefined();
69
- });
70
- it('throws on blocked paths', () => {
71
- expect(() => validateMountPaths(['/home/user/.ssh'])).toThrow('Blocked path');
72
- });
73
- it('skips missing paths', () => {
74
- mockExistsSync.mockImplementation((p) => {
75
- // Only ~/.skimpyclaw exists for the auto-add
76
- return typeof p === 'string' && p.includes('.skimpyclaw');
77
- });
78
- const mounts = validateMountPaths(['/nonexistent/path']);
79
- // Should only have the auto-added config mount
80
- const nonConfig = mounts.filter((m) => m.container !== '/workspace/config');
81
- expect(nonConfig).toHaveLength(0);
82
- });
83
- it('does not duplicate ~/.skimpyclaw if already in list', () => {
84
- // Simulate ~/.skimpyclaw being passed explicitly
85
- const home = process.env.HOME || '/Users/katre';
86
- const skDir = `${home}/.skimpyclaw`;
87
- mockRealpathSync.mockImplementation((p) => p);
88
- // .skimpyclaw is NOT blocked, so it should be mounted
89
- // But it should not appear twice
90
- const mounts = validateMountPaths([skDir]);
91
- const configMounts = mounts.filter((m) => m.host === skDir);
92
- expect(configMounts).toHaveLength(1);
93
- });
94
- });
95
- describe('translatePath', () => {
96
- const mounts = [
97
- { host: '/Users/katre/Sites/skimpyclaw', container: '/workspace/skimpyclaw', readOnly: false },
98
- { host: '/Users/katre/.skimpyclaw', container: '/workspace/config', readOnly: false },
99
- ];
100
- it('translates host path to container path', () => {
101
- expect(translatePath('/Users/katre/Sites/skimpyclaw/src/index.ts', mounts))
102
- .toBe('/workspace/skimpyclaw/src/index.ts');
103
- });
104
- it('translates exact mount root', () => {
105
- expect(translatePath('/Users/katre/Sites/skimpyclaw', mounts))
106
- .toBe('/workspace/skimpyclaw');
107
- });
108
- it('translates config path', () => {
109
- expect(translatePath('/Users/katre/.skimpyclaw/config.json', mounts))
110
- .toBe('/workspace/config/config.json');
111
- });
112
- it('translates /Users path to /System/Volumes/Data mount on macOS', () => {
113
- const macMounts = [
114
- { host: '/System/Volumes/Data/Users/katre/.skimpyclaw', container: '/workspace/config', readOnly: false },
115
- ];
116
- expect(translatePath('/Users/katre/.skimpyclaw/state/japan-flight-watch.json', macMounts))
117
- .toBe('/workspace/config/state/japan-flight-watch.json');
118
- });
119
- it('returns original path if no mount matches', () => {
120
- expect(translatePath('/tmp/random/file', mounts)).toBe('/tmp/random/file');
121
- });
122
- it('expands ~ to home directory before matching', () => {
123
- const home = process.env.HOME || '/Users/katre';
124
- const homeMounts = [
125
- { host: `${home}/.skimpyclaw`, container: '/workspace/config', readOnly: false },
126
- ];
127
- expect(translatePath('~/.skimpyclaw/agents/main/HEARTBEAT.md', homeMounts))
128
- .toBe('/workspace/config/agents/main/HEARTBEAT.md');
129
- });
130
- it('matches most specific mount first', () => {
131
- const nestedMounts = [
132
- { host: '/Users/katre', container: '/workspace/home', readOnly: false },
133
- { host: '/Users/katre/Sites/skimpyclaw', container: '/workspace/skimpyclaw', readOnly: false },
134
- ];
135
- expect(translatePath('/Users/katre/Sites/skimpyclaw/src/file.ts', nestedMounts))
136
- .toBe('/workspace/skimpyclaw/src/file.ts');
137
- });
138
- });
139
- });
@@ -1,176 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- const { mockSpawn, mockSpawnSync } = vi.hoisted(() => ({
3
- mockSpawn: vi.fn(),
4
- mockSpawnSync: vi.fn().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
5
- }));
6
- vi.mock('child_process', () => ({ spawn: mockSpawn, spawnSync: mockSpawnSync }));
7
- import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, probeRuntime, } from '../sandbox/runtime.js';
8
- function fakeChild(exitCode, stdout = '', stderr = '', opts) {
9
- const stdoutCallbacks = [];
10
- const stderrCallbacks = [];
11
- const eventCallbacks = {};
12
- const child = {
13
- stdout: { on: (_e, cb) => stdoutCallbacks.push(cb) },
14
- stderr: { on: (_e, cb) => stderrCallbacks.push(cb) },
15
- stdin: opts?.stdinNeeded ? { write: vi.fn(), end: vi.fn() } : null,
16
- on(event, cb) {
17
- if (!eventCallbacks[event])
18
- eventCallbacks[event] = [];
19
- eventCallbacks[event].push(cb);
20
- return child;
21
- },
22
- };
23
- // Emit data and close on next tick (setTimeout ensures listeners are attached first)
24
- setTimeout(() => {
25
- if (stdout)
26
- for (const cb of stdoutCallbacks)
27
- cb(Buffer.from(stdout));
28
- if (stderr)
29
- for (const cb of stderrCallbacks)
30
- cb(Buffer.from(stderr));
31
- for (const cb of eventCallbacks['close'] ?? [])
32
- cb(exitCode);
33
- }, 0);
34
- return child;
35
- }
36
- describe('sandbox/runtime', () => {
37
- beforeEach(() => {
38
- vi.clearAllMocks();
39
- resetRuntime();
40
- setRuntime('container'); // skip auto-detection in tests
41
- });
42
- describe('createContainer', () => {
43
- it('builds correct docker args', async () => {
44
- mockSpawn.mockReturnValue(fakeChild(0));
45
- await createContainer('test-ctr', {
46
- image: 'myimg',
47
- cpus: 2,
48
- memory: '1G',
49
- network: 'none',
50
- user: '501:20',
51
- mounts: [{ host: '/a', container: '/b', readOnly: true }],
52
- });
53
- const args = mockSpawn.mock.calls[0][1];
54
- expect(args).toContain('--name');
55
- expect(args).toContain('test-ctr');
56
- expect(args).toContain('--cpus');
57
- expect(args).toContain('2');
58
- expect(args).toContain('--memory');
59
- expect(args).toContain('1G');
60
- expect(args).toContain('--network');
61
- expect(args).toContain('none');
62
- expect(args).toContain('--user');
63
- expect(args).toContain('501:20');
64
- expect(args.some((a) => a.includes('type=bind,src=/a,dst=/b,ro'))).toBe(true);
65
- expect(args).toContain('myimg');
66
- });
67
- it('throws on non-zero exit', async () => {
68
- mockSpawn.mockReturnValue(fakeChild(1, '', 'boom'));
69
- await expect(createContainer('c1', { image: 'img' })).rejects.toThrow('Failed to create container');
70
- });
71
- });
72
- describe('execInContainer', () => {
73
- it('builds sh -c command', async () => {
74
- mockSpawn.mockReturnValue(fakeChild(0, 'hello'));
75
- const result = await execInContainer('ctr', ['echo hello']);
76
- expect(result.stdout).toBe('hello');
77
- const args = mockSpawn.mock.calls[0][1];
78
- expect(args).toContain('sh');
79
- expect(args).toContain('-c');
80
- });
81
- it('handles stdin', async () => {
82
- mockSpawn.mockReturnValue(fakeChild(0, '', '', { stdinNeeded: true }));
83
- await execInContainer('ctr', ['cat'], { stdin: 'data' });
84
- const args = mockSpawn.mock.calls[0][1];
85
- expect(args).toContain('-i');
86
- });
87
- it('handles env vars', async () => {
88
- mockSpawn.mockReturnValue(fakeChild(0));
89
- await execInContainer('ctr', ['cmd'], { env: { FOO: 'bar' } });
90
- const args = mockSpawn.mock.calls[0][1];
91
- expect(args).toContain('-e');
92
- expect(args).toContain('FOO=bar');
93
- });
94
- });
95
- describe('removeContainer', () => {
96
- it('calls stop then rm', async () => {
97
- mockSpawn.mockImplementation((_cmd, args) => {
98
- return fakeChild(0);
99
- });
100
- await removeContainer('ctr');
101
- expect(mockSpawn).toHaveBeenCalledTimes(2);
102
- expect(mockSpawn.mock.calls[0][1]).toContain('stop');
103
- expect(mockSpawn.mock.calls[1][1]).toContain('rm');
104
- }, 10_000);
105
- it('does not throw on failure', async () => {
106
- mockSpawn.mockImplementation(() => fakeChild(1, '', 'fail'));
107
- await expect(removeContainer('ctr')).resolves.toBeUndefined();
108
- }, 10_000);
109
- });
110
- describe('isContainerRunning', () => {
111
- it('returns true when inspect shows running state', async () => {
112
- mockSpawn.mockReturnValue(fakeChild(0, '{"State": {"Status": "running"}}'));
113
- expect(await isContainerRunning('ctr')).toBe(true);
114
- });
115
- it('returns false otherwise', async () => {
116
- mockSpawn.mockReturnValue(fakeChild(1));
117
- expect(await isContainerRunning('ctr')).toBe(false);
118
- });
119
- });
120
- describe('probeRuntime', () => {
121
- it('returns preferred runtime when available', () => {
122
- mockSpawnSync.mockReturnValue({ status: 0 });
123
- expect(probeRuntime('docker')).toBe('docker');
124
- expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], { stdio: 'ignore' });
125
- });
126
- it('falls back to auto-detect when preferred is unavailable', () => {
127
- mockSpawnSync.mockImplementation((cmd) => {
128
- // preferred 'docker' fails, but 'container' succeeds
129
- if (cmd === 'docker')
130
- return { status: 1 };
131
- if (cmd === 'container')
132
- return { status: 0 };
133
- return { status: 1 };
134
- });
135
- expect(probeRuntime('docker')).toBe('container');
136
- });
137
- it('returns null when no runtime is available', () => {
138
- mockSpawnSync.mockReturnValue({ status: 1 });
139
- expect(probeRuntime('docker')).toBeNull();
140
- });
141
- it('auto-detects without preferred runtime', () => {
142
- mockSpawnSync.mockImplementation((cmd) => {
143
- if (cmd === 'container')
144
- return { status: 0 };
145
- return { status: 1 };
146
- });
147
- expect(probeRuntime()).toBe('container');
148
- });
149
- it('prefers container over docker in auto-detect', () => {
150
- mockSpawnSync.mockReturnValue({ status: 0 });
151
- expect(probeRuntime()).toBe('container');
152
- // First call should be to 'container'
153
- expect(mockSpawnSync.mock.calls[0][0]).toBe('container');
154
- });
155
- });
156
- describe('cleanupOrphans', () => {
157
- it('lists containers, filters by prefix, removes matches', async () => {
158
- let callCount = 0;
159
- mockSpawn.mockImplementation(() => {
160
- callCount++;
161
- if (callCount === 1) {
162
- // ps call
163
- return fakeChild(0, 'skimpyclaw-sbx-abc\nother-ctr\nskimpyclaw-sbx-def\n');
164
- }
165
- // stop/rm calls
166
- return fakeChild(0);
167
- });
168
- const count = await cleanupOrphans();
169
- expect(count).toBe(2);
170
- });
171
- it('returns 0 on ps failure', async () => {
172
- mockSpawn.mockImplementation(() => fakeChild(1));
173
- expect(await cleanupOrphans()).toBe(0);
174
- });
175
- });
176
- });