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
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { _setThreadAgentStorePathForTesting, bindThreadAgent, getAgentProfileByAlias, getThreadAgentByThreadId, listAgentProfiles, listThreadAgentBindings, parseDiscordAgentMention, removeAgentProfile, setAgentProfileModel, setAgentProfilePrompt, setAgentProfileThinking, upsertAgentProfile, } from '../channels/discord/thread-agents.js';
6
+ let tempDir;
7
+ let storePath;
8
+ describe('Discord thread agents registry', () => {
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), 'skimpyclaw-thread-agents-'));
11
+ storePath = join(tempDir, 'thread-agents.json');
12
+ _setThreadAgentStorePathForTesting(storePath);
13
+ });
14
+ afterEach(() => {
15
+ _setThreadAgentStorePathForTesting(null);
16
+ rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+ it('stores reusable profiles and binds them to threads', () => {
19
+ const profile = upsertAgentProfile({
20
+ alias: 'Reviewer',
21
+ agentId: 'main',
22
+ createdBy: 'user-1',
23
+ });
24
+ const binding = bindThreadAgent({
25
+ threadId: 'thread-1',
26
+ alias: 'reviewer',
27
+ createdBy: 'user-1',
28
+ guildId: 'guild-1',
29
+ channelId: 'channel-1',
30
+ });
31
+ expect(profile.alias).toBe('reviewer');
32
+ expect(binding.alias).toBe('reviewer');
33
+ expect(getThreadAgentByThreadId('thread-1')?.agentId).toBe('main');
34
+ expect(listAgentProfiles()).toHaveLength(1);
35
+ expect(listThreadAgentBindings()).toHaveLength(1);
36
+ });
37
+ it('allows one profile to be reused across multiple threads', () => {
38
+ upsertAgentProfile({
39
+ alias: 'claude-coder',
40
+ agentId: 'main',
41
+ createdBy: 'user-1',
42
+ });
43
+ bindThreadAgent({ threadId: 'thread-1', alias: 'claude-coder', createdBy: 'user-1' });
44
+ bindThreadAgent({ threadId: 'thread-2', alias: 'claude-coder', createdBy: 'user-1' });
45
+ expect(listAgentProfiles()).toHaveLength(1);
46
+ expect(listThreadAgentBindings().map(record => record.threadId)).toEqual(['thread-1', 'thread-2']);
47
+ });
48
+ it('updates profile prompts, models, and thinking for all bound threads', () => {
49
+ upsertAgentProfile({
50
+ alias: 'claude-coder',
51
+ agentId: 'main',
52
+ createdBy: 'user-1',
53
+ });
54
+ bindThreadAgent({ threadId: 'thread-1', alias: 'claude-coder', createdBy: 'user-1' });
55
+ bindThreadAgent({ threadId: 'thread-2', alias: 'claude-coder', createdBy: 'user-1' });
56
+ expect(setAgentProfilePrompt('claude-coder', 'Review code like a senior engineer.')?.promptOverlay)
57
+ .toBe('Review code like a senior engineer.');
58
+ expect(setAgentProfileModel('claude-coder', 'anthropic/claude-sonnet-4-5')?.model)
59
+ .toBe('anthropic/claude-sonnet-4-5');
60
+ expect(setAgentProfileThinking('claude-coder', 'xhigh')?.thinking).toBe('xhigh');
61
+ expect(getThreadAgentByThreadId('thread-2')?.model).toBe('anthropic/claude-sonnet-4-5');
62
+ expect(getThreadAgentByThreadId('thread-2')?.thinking).toBe('xhigh');
63
+ });
64
+ it('deletes profiles with their bindings', () => {
65
+ upsertAgentProfile({
66
+ alias: 'reviewer',
67
+ agentId: 'main',
68
+ createdBy: 'user-1',
69
+ });
70
+ bindThreadAgent({ threadId: 'thread-1', alias: 'reviewer', createdBy: 'user-1' });
71
+ expect(removeAgentProfile('reviewer')).toBe(true);
72
+ expect(getAgentProfileByAlias('reviewer')).toBeNull();
73
+ expect(getThreadAgentByThreadId('thread-1')).toBeNull();
74
+ });
75
+ it('migrates legacy thread-agent array stores', () => {
76
+ _setThreadAgentStorePathForTesting(null);
77
+ writeFileSync(storePath, JSON.stringify([
78
+ {
79
+ threadId: 'thread-1',
80
+ alias: 'Reviewer',
81
+ agentId: 'main',
82
+ model: 'anthropic/claude-sonnet-4-5',
83
+ thinking: 'high',
84
+ promptOverlay: 'Review carefully.',
85
+ createdBy: 'user-1',
86
+ createdAt: '2026-04-27T00:00:00.000Z',
87
+ updatedAt: '2026-04-27T00:00:00.000Z',
88
+ guildId: 'guild-1',
89
+ channelId: 'channel-1',
90
+ },
91
+ ]), 'utf-8');
92
+ _setThreadAgentStorePathForTesting(storePath);
93
+ const migrated = getThreadAgentByThreadId('thread-1');
94
+ expect(migrated?.alias).toBe('reviewer');
95
+ expect(migrated?.model).toBe('anthropic/claude-sonnet-4-5');
96
+ expect(migrated?.thinking).toBe('high');
97
+ setAgentProfileModel('reviewer', 'anthropic/claude-opus-4-6');
98
+ const persisted = JSON.parse(readFileSync(storePath, 'utf-8'));
99
+ expect(persisted.version).toBe(2);
100
+ expect(persisted.profiles[0].alias).toBe('reviewer');
101
+ expect(persisted.bindings[0].profileAlias).toBe('reviewer');
102
+ });
103
+ it('parses leading agent mentions', () => {
104
+ expect(parseDiscordAgentMention('@Claude-Coder review this PR')).toEqual({
105
+ alias: 'claude-coder',
106
+ prompt: 'review this PR',
107
+ });
108
+ expect(parseDiscordAgentMention('@codex-reviewer')).toEqual({
109
+ alias: 'codex-reviewer',
110
+ prompt: '',
111
+ });
112
+ expect(parseDiscordAgentMention('please ask @codex-reviewer')).toBeNull();
113
+ expect(parseDiscordAgentMention('<@1234567890> review this')).toBeNull();
114
+ });
115
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildCodeAgentThreadContext } from '../channels/discord/utils.js';
3
+ function makeMessage(channel) {
4
+ return { channel };
5
+ }
6
+ function makeTask(overrides = {}) {
7
+ return {
8
+ id: 'ca-12',
9
+ agent: 'codex',
10
+ task: 'Fix the Discord thread context bug',
11
+ status: 'completed',
12
+ discordThreadId: 'thread-1',
13
+ startedAt: '2026-04-27T14:00:00.000Z',
14
+ endedAt: '2026-04-27T14:05:00.000Z',
15
+ workdir: '/Users/katre/Sites/skimpyclaw',
16
+ outputPreview: 'Implemented a context bridge.',
17
+ validationPassed: true,
18
+ ...overrides,
19
+ };
20
+ }
21
+ describe('Discord coding-agent thread context', () => {
22
+ it('injects task metadata for messages inside a coding-agent thread', () => {
23
+ const message = makeMessage({
24
+ id: 'thread-1',
25
+ isDMBased: () => false,
26
+ isThread: () => true,
27
+ });
28
+ const context = buildCodeAgentThreadContext(message, [makeTask()]);
29
+ expect(context).toContain('Task ID: ca-12');
30
+ expect(context).toContain('Task status: completed');
31
+ expect(context).toContain('Fix the Discord thread context bug');
32
+ expect(context).toContain('check_code_agent with id "ca-12"');
33
+ });
34
+ it('returns null outside task threads', () => {
35
+ const message = makeMessage({
36
+ id: 'channel-1',
37
+ isDMBased: () => false,
38
+ isThread: () => false,
39
+ });
40
+ expect(buildCodeAgentThreadContext(message, [makeTask()])).toBeNull();
41
+ });
42
+ });
@@ -21,11 +21,11 @@ describe('doctor formatters', () => {
21
21
  remedy: 'Fix ~/.skimpyclaw/config.json to valid JSON and rerun doctor.',
22
22
  },
23
23
  {
24
- name: 'provider_openai_auth',
24
+ name: 'provider_anthropic_auth',
25
25
  category: 'provider_auth',
26
26
  ok: false,
27
27
  detail: '401 Unauthorized',
28
- remedy: 'Check OPENAI_API_KEY and provider base URL.',
28
+ remedy: 'Check ANTHROPIC_API_KEY and provider base URL.',
29
29
  },
30
30
  ],
31
31
  };
@@ -36,11 +36,11 @@ describe('doctor formatters', () => {
36
36
  expect(output).toContain('provider_auth');
37
37
  expect(output).toContain('✓ node_version');
38
38
  expect(output).toContain('✗ config_json_valid');
39
- expect(output).toContain('✗ provider_openai_auth');
39
+ expect(output).toContain('✗ provider_anthropic_auth');
40
40
  expect(output).toContain('Unexpected token } in JSON at position 10');
41
41
  expect(output).toContain('401 Unauthorized');
42
42
  expect(output).toContain('Fix ~/.skimpyclaw/config.json to valid JSON and rerun doctor.');
43
- expect(output).toContain('Check OPENAI_API_KEY and provider base URL.');
43
+ expect(output).toContain('Check ANTHROPIC_API_KEY and provider base URL.');
44
44
  });
45
45
  it('does not print undefined when a failed check has no remedy', () => {
46
46
  const output = formatDoctorHuman({
@@ -20,7 +20,7 @@ describe('doctor index integration', () => {
20
20
  finishedAt: '2026-02-12T10:00:02.000Z',
21
21
  checks: [
22
22
  { name: 'node_version', category: 'environment', ok: true, detail: 'v20.11.0' },
23
- { name: 'provider_openai_auth', category: 'provider_auth', ok: false, detail: '401 Unauthorized' },
23
+ { name: 'provider_anthropic_auth', category: 'provider_auth', ok: false, detail: '401 Unauthorized' },
24
24
  ],
25
25
  };
26
26
  beforeEach(() => {
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckPlaywrightIfBrowserEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
2
+ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, } = vi.hoisted(() => ({
3
3
  mockLoadConfig: vi.fn(),
4
4
  mockCheckNodeVersion: vi.fn(),
5
5
  mockCheckPackageManagerAvailable: vi.fn(),
@@ -11,14 +11,11 @@ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable,
11
11
  mockCheckProviderAuth: vi.fn(),
12
12
  mockCheckTelegramToken: vi.fn(),
13
13
  mockCheckDiscordToken: vi.fn(),
14
- mockCheckBrowserBinaryIfEnabled: vi.fn(),
15
- mockCheckPlaywrightIfBrowserEnabled: vi.fn(),
16
14
  mockCheckVoiceDependencies: vi.fn(),
17
15
  mockCheckMcpConfig: vi.fn(),
18
16
  mockCheckGatewayHostBindable: vi.fn(),
19
17
  mockCheckSkimpyclawDirWritable: vi.fn(),
20
18
  mockCheckPortAvailability: vi.fn(),
21
- mockCheckSandboxAvailable: vi.fn(),
22
19
  }));
23
20
  vi.mock('../config.js', () => ({
24
21
  loadConfig: mockLoadConfig,
@@ -34,14 +31,11 @@ vi.mock('../doctor/checks.js', () => ({
34
31
  checkProviderAuth: mockCheckProviderAuth,
35
32
  checkTelegramToken: mockCheckTelegramToken,
36
33
  checkDiscordToken: mockCheckDiscordToken,
37
- checkBrowserBinaryIfEnabled: mockCheckBrowserBinaryIfEnabled,
38
- checkPlaywrightIfBrowserEnabled: mockCheckPlaywrightIfBrowserEnabled,
39
34
  checkVoiceDependencies: mockCheckVoiceDependencies,
40
35
  checkMcpConfig: mockCheckMcpConfig,
41
36
  checkGatewayHostBindable: mockCheckGatewayHostBindable,
42
37
  checkSkimpyclawDirWritable: mockCheckSkimpyclawDirWritable,
43
38
  checkPortAvailability: mockCheckPortAvailability,
44
- checkSandboxAvailable: mockCheckSandboxAvailable,
45
39
  }));
46
40
  import { computeExitCode, runDoctor } from '../doctor/runner.js';
47
41
  function okCheck(name, category, detail = 'ok') {
@@ -53,7 +47,7 @@ describe('doctor runner', () => {
53
47
  gateway: { port: 18790 },
54
48
  models: {
55
49
  providers: {
56
- openai: { apiKey: '${OPENAI_API_KEY}' },
50
+ anthropic: { apiKey: '${ANTHROPIC_API_KEY}' },
57
51
  },
58
52
  },
59
53
  channels: {
@@ -76,14 +70,11 @@ describe('doctor runner', () => {
76
70
  mockCheckProviderAuth.mockReset();
77
71
  mockCheckTelegramToken.mockReset();
78
72
  mockCheckDiscordToken.mockReset();
79
- mockCheckBrowserBinaryIfEnabled.mockReset();
80
- mockCheckPlaywrightIfBrowserEnabled.mockReset();
81
73
  mockCheckVoiceDependencies.mockReset();
82
74
  mockCheckMcpConfig.mockReset();
83
75
  mockCheckGatewayHostBindable.mockReset();
84
76
  mockCheckSkimpyclawDirWritable.mockReset();
85
77
  mockCheckPortAvailability.mockReset();
86
- mockCheckSandboxAvailable.mockReset();
87
78
  mockCheckNodeVersion.mockResolvedValue(okCheck('node_version', 'environment', 'v20.11.0'));
88
79
  mockCheckPackageManagerAvailable.mockResolvedValue(okCheck('package_manager_available', 'environment', 'pnpm'));
89
80
  mockCheckTypeScriptCompile.mockResolvedValue(okCheck('typescript_compile', 'environment'));
@@ -91,17 +82,14 @@ describe('doctor runner', () => {
91
82
  mockCheckRequiredEnvVars.mockResolvedValue(okCheck('required_env_vars', 'configuration'));
92
83
  mockCheckEnvVarPatterns.mockResolvedValue(okCheck('env_var_patterns', 'configuration'));
93
84
  mockCheckAllowedPathsWritable.mockResolvedValue(okCheck('allowed_paths_writable', 'configuration'));
94
- mockCheckProviderAuth.mockResolvedValue(okCheck('provider_openai_auth', 'provider_auth'));
85
+ mockCheckProviderAuth.mockResolvedValue(okCheck('provider_anthropic_auth', 'provider_auth'));
95
86
  mockCheckTelegramToken.mockResolvedValue(okCheck('telegram_token_valid', 'channels'));
96
87
  mockCheckDiscordToken.mockResolvedValue(okCheck('discord_token_valid', 'channels'));
97
- mockCheckBrowserBinaryIfEnabled.mockResolvedValue(okCheck('browser_binary_available', 'runtime'));
98
- mockCheckPlaywrightIfBrowserEnabled.mockResolvedValue(okCheck('playwright_installed', 'runtime', 'Browser tools disabled'));
99
88
  mockCheckVoiceDependencies.mockResolvedValue(okCheck('voice_dependencies', 'runtime', 'Voice disabled'));
100
89
  mockCheckMcpConfig.mockResolvedValue(okCheck('mcp_config', 'runtime', 'MCP tools not configured'));
101
90
  mockCheckGatewayHostBindable.mockResolvedValue(okCheck('gateway_host_bindable', 'runtime', '127.0.0.1 (always available)'));
102
91
  mockCheckSkimpyclawDirWritable.mockResolvedValue(okCheck('skimpyclaw_dirs_writable', 'runtime'));
103
92
  mockCheckPortAvailability.mockResolvedValue(okCheck('gateway_port_available', 'runtime'));
104
- mockCheckSandboxAvailable.mockResolvedValue(okCheck('sandbox_available', 'runtime', 'Sandbox disabled'));
105
93
  });
106
94
  it('computes exit code 0 when all checks pass', () => {
107
95
  const code = computeExitCode({ checks: [okCheck('node_version', 'environment')] });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeExecEnv, sanitizeCronEnv } from '../env-sanitizer.js';
3
+ describe('sanitizeExecEnv', () => {
4
+ it('strips sensitive env vars while preserving allowlisted values', () => {
5
+ process.env.OPENAI_API_KEY = 'secret-openai';
6
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'secret-claude';
7
+ process.env.GH_TOKEN = 'allowed-gh';
8
+ process.env.NORMAL_VAR = 'ok';
9
+ const env = sanitizeExecEnv();
10
+ expect(env.OPENAI_API_KEY).toBeUndefined();
11
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
12
+ expect(env.GH_TOKEN).toBe('allowed-gh');
13
+ expect(env.NORMAL_VAR).toBe('ok');
14
+ delete process.env.OPENAI_API_KEY;
15
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
16
+ delete process.env.GH_TOKEN;
17
+ delete process.env.NORMAL_VAR;
18
+ });
19
+ it('ensures common tool paths are present in PATH', () => {
20
+ process.env.PATH = '/usr/bin:/bin';
21
+ const env = sanitizeExecEnv();
22
+ expect(env.PATH).toContain('/opt/homebrew/bin');
23
+ expect(env.PATH).toContain('/usr/local/bin');
24
+ });
25
+ it('applies strict allowlist for cron environments', () => {
26
+ process.env.OPENAI_API_KEY = 'secret';
27
+ process.env.GH_TOKEN = 'allowed-gh';
28
+ process.env.SKIMPYCLAW_MODE = 'prod';
29
+ process.env.CUSTOM_RANDOM_VAR = 'do-not-include';
30
+ process.env.PATH = '/usr/bin:/bin';
31
+ process.env.HOME = '/tmp/home';
32
+ const env = sanitizeCronEnv();
33
+ expect(env.OPENAI_API_KEY).toBeUndefined();
34
+ expect(env.CUSTOM_RANDOM_VAR).toBeUndefined();
35
+ expect(env.GH_TOKEN).toBe('allowed-gh');
36
+ expect(env.SKIMPYCLAW_MODE).toBe('prod');
37
+ expect(env.HOME).toBe('/tmp/home');
38
+ expect(env.PATH).toContain('/opt/homebrew/bin');
39
+ delete process.env.OPENAI_API_KEY;
40
+ delete process.env.GH_TOKEN;
41
+ delete process.env.SKIMPYCLAW_MODE;
42
+ delete process.env.CUSTOM_RANDOM_VAR;
43
+ delete process.env.HOME;
44
+ });
45
+ });
@@ -205,6 +205,14 @@ describe('approval registry', () => {
205
205
  const found = findApprovedRequest('sudo apt update');
206
206
  expect(found).toBeUndefined();
207
207
  });
208
+ it('consumed approval is still retrievable by ID', () => {
209
+ const approval = createApprovalRequest('sudo apt update', undefined, { tier: 2, reason: 'test' });
210
+ approveRequest(approval.id);
211
+ consumeApproval(approval.id);
212
+ const fetched = getApproval(approval.id);
213
+ expect(fetched).toBeDefined();
214
+ expect(fetched?.status).toBe('consumed');
215
+ });
208
216
  it('expires pending approvals past TTL', () => {
209
217
  const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' }, { ttlMs: 1 });
210
218
  // Wait briefly for TTL to expire
@@ -321,3 +329,56 @@ describe('approval events', () => {
321
329
  expect(events).toHaveLength(1); // No new event
322
330
  });
323
331
  });
332
+ describe('approval history', () => {
333
+ it('new approval starts with empty history', () => {
334
+ const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' });
335
+ expect(approval.history).toEqual([]);
336
+ });
337
+ it('records a single transition on approve', () => {
338
+ const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' });
339
+ approveRequest(approval.id, 'admin');
340
+ const fetched = getApproval(approval.id);
341
+ expect(fetched.history).toHaveLength(1);
342
+ expect(fetched.history[0].from).toBe('pending');
343
+ expect(fetched.history[0].to).toBe('approved');
344
+ expect(fetched.history[0].by).toBe('admin');
345
+ expect(fetched.history[0].at).toBeInstanceOf(Date);
346
+ });
347
+ it('records a single transition on deny', () => {
348
+ const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' });
349
+ denyRequest(approval.id, 'security');
350
+ const fetched = getApproval(approval.id);
351
+ expect(fetched.history).toHaveLength(1);
352
+ expect(fetched.history[0].from).toBe('pending');
353
+ expect(fetched.history[0].to).toBe('denied');
354
+ expect(fetched.history[0].by).toBe('security');
355
+ });
356
+ it('records multiple transitions through full lifecycle', () => {
357
+ const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' });
358
+ approveRequest(approval.id, 'admin');
359
+ consumeApproval(approval.id);
360
+ const fetched = getApproval(approval.id);
361
+ expect(fetched.history).toHaveLength(2);
362
+ expect(fetched.history[0]).toMatchObject({ from: 'pending', to: 'approved', by: 'admin' });
363
+ expect(fetched.history[1]).toMatchObject({ from: 'approved', to: 'consumed' });
364
+ expect(fetched.status).toBe('consumed');
365
+ });
366
+ it('no-op status update does not add history entries', () => {
367
+ const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' });
368
+ approveRequest(approval.id, 'admin');
369
+ // Try to approve again — should fail, no history added
370
+ const result = approveRequest(approval.id, 'other');
371
+ expect(result).toBe(false);
372
+ const fetched = getApproval(approval.id);
373
+ expect(fetched.history).toHaveLength(1); // Only the first approve
374
+ });
375
+ it('records expiration transition in history', () => {
376
+ const approval = createApprovalRequest('sudo cmd', undefined, { tier: 2, reason: 'test' }, { ttlMs: 1 });
377
+ const start = Date.now();
378
+ while (Date.now() - start < 5) { /* spin for TTL */ }
379
+ cleanupExpired();
380
+ const fetched = getApproval(approval.id);
381
+ expect(fetched.history).toHaveLength(1);
382
+ expect(fetched.history[0]).toMatchObject({ from: 'pending', to: 'expired' });
383
+ });
384
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const mockLookup = vi.hoisted(() => vi.fn());
3
+ vi.mock('dns/promises', () => ({
4
+ lookup: (...args) => mockLookup(...args),
5
+ }));
6
+ const mockFetch = vi.fn();
7
+ global.fetch = mockFetch;
8
+ const { executeFetch } = await import('../tools/fetch-tool.js');
9
+ describe('fetch-tool SSRF protections', () => {
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ });
13
+ it('blocks localhost targets', async () => {
14
+ const out = await executeFetch({ url: 'http://localhost:8080/secret' }, {});
15
+ expect(out).toContain('Error:');
16
+ expect(out).toContain('Blocked host');
17
+ expect(mockFetch).not.toHaveBeenCalled();
18
+ });
19
+ it('blocks non-http protocols', async () => {
20
+ const out = await executeFetch({ url: 'file:///etc/passwd' }, {});
21
+ expect(out).toContain('Error:');
22
+ expect(out).toContain('Unsupported protocol');
23
+ expect(mockFetch).not.toHaveBeenCalled();
24
+ });
25
+ it('blocks hostnames resolving to private IPs', async () => {
26
+ mockLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }]);
27
+ const out = await executeFetch({ url: 'https://example.test/data' }, {});
28
+ expect(out).toContain('Error:');
29
+ expect(out).toContain('Blocked resolved IP');
30
+ expect(mockFetch).not.toHaveBeenCalled();
31
+ });
32
+ it('blocks redirects to internal targets', async () => {
33
+ mockLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
34
+ mockFetch.mockResolvedValueOnce({
35
+ status: 302,
36
+ statusText: 'Found',
37
+ headers: new Headers({ location: 'http://127.0.0.1/internal' }),
38
+ text: async () => '',
39
+ });
40
+ const out = await executeFetch({ url: 'https://public.example/path' }, {});
41
+ expect(out).toContain('Error:');
42
+ expect(out).toContain('Blocked target IP');
43
+ expect(mockFetch).toHaveBeenCalledTimes(1);
44
+ });
45
+ it('re-validates DNS on each redirect hop', async () => {
46
+ mockLookup
47
+ .mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
48
+ .mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
49
+ mockFetch
50
+ .mockResolvedValueOnce({
51
+ status: 302,
52
+ statusText: 'Found',
53
+ headers: new Headers({ location: 'https://example.com/next' }),
54
+ text: async () => '',
55
+ })
56
+ .mockResolvedValueOnce({
57
+ status: 200,
58
+ statusText: 'OK',
59
+ headers: new Headers({ 'content-type': 'text/plain' }),
60
+ text: async () => 'ok',
61
+ });
62
+ const out = await executeFetch({ url: 'https://example.com/start' }, {});
63
+ expect(out).toContain('HTTP 200 OK');
64
+ expect(mockLookup).toHaveBeenCalledTimes(2);
65
+ });
66
+ it('returns response body for valid public targets', async () => {
67
+ mockLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
68
+ mockFetch.mockResolvedValueOnce({
69
+ status: 200,
70
+ statusText: 'OK',
71
+ headers: new Headers({ 'content-type': 'text/plain' }),
72
+ text: async () => 'ok',
73
+ });
74
+ const out = await executeFetch({ url: 'https://example.com' }, {});
75
+ expect(out).toContain('HTTP 200 OK');
76
+ expect(out).toContain('ok');
77
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ redirect: 'manual' }));
78
+ });
79
+ it('blocks internal-style host suffixes', async () => {
80
+ const out = await executeFetch({ url: 'https://service.internal/api' }, {});
81
+ expect(out).toContain('Error:');
82
+ expect(out).toContain('Blocked internal host');
83
+ expect(mockFetch).not.toHaveBeenCalled();
84
+ });
85
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('../api.js', () => ({
3
+ registerDashboardAPI: vi.fn(),
4
+ }));
5
+ vi.mock('../dashboard-frontend.js', () => ({
6
+ registerDashboard: vi.fn(),
7
+ }));
8
+ vi.mock('../cron.js', () => ({
9
+ getCronJobs: () => [{ id: 'job-1', name: 'Job 1', nextRun: undefined }],
10
+ runCronJob: vi.fn(),
11
+ }));
12
+ vi.mock('../agent.js', () => ({
13
+ runAgentTurn: vi.fn().mockResolvedValue('ok'),
14
+ }));
15
+ vi.mock('../config.js', () => ({
16
+ ensureDashboardToken: () => 'test-token',
17
+ getLogsDir: () => '/tmp/test-skimpyclaw/logs',
18
+ }));
19
+ const { createGateway } = await import('../gateway.js');
20
+ const cfg = {
21
+ gateway: { port: 18790 },
22
+ agents: {
23
+ default: 'main',
24
+ list: {
25
+ main: {
26
+ model: 'anthropic/claude-haiku-4-5',
27
+ identity: { name: 'Bot', emoji: 'x' },
28
+ },
29
+ },
30
+ },
31
+ cron: { jobs: [] },
32
+ };
33
+ describe('gateway /status auth', () => {
34
+ afterEach(async () => {
35
+ vi.clearAllMocks();
36
+ });
37
+ it('keeps /health unauthenticated', async () => {
38
+ const app = await createGateway(cfg);
39
+ try {
40
+ const res = await app.inject({ method: 'GET', url: '/health' });
41
+ expect(res.statusCode).toBe(200);
42
+ }
43
+ finally {
44
+ await app.close();
45
+ }
46
+ });
47
+ it('requires auth for /status', async () => {
48
+ const app = await createGateway(cfg);
49
+ try {
50
+ const res = await app.inject({ method: 'GET', url: '/status' });
51
+ expect(res.statusCode).toBe(401);
52
+ }
53
+ finally {
54
+ await app.close();
55
+ }
56
+ });
57
+ it('allows /status with valid bearer token', async () => {
58
+ const app = await createGateway(cfg);
59
+ try {
60
+ const res = await app.inject({
61
+ method: 'GET',
62
+ url: '/status',
63
+ headers: { authorization: 'Bearer test-token' },
64
+ });
65
+ expect(res.statusCode).toBe(200);
66
+ expect(res.json().status).toBe('ok');
67
+ }
68
+ finally {
69
+ await app.close();
70
+ }
71
+ });
72
+ });
@@ -22,7 +22,7 @@ describe('heartbeat prompt path normalization', () => {
22
22
  heartbeat: {
23
23
  intervalMs: 60000,
24
24
  prompt: 'Read /Users/example/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
25
- model: 'claude-fast',
25
+ model: 'anthropic/claude-haiku-4-5',
26
26
  tools: {
27
27
  enabled: true,
28
28
  allowedPaths: ['/Users/example/.skimpyclaw'],
@@ -39,7 +39,7 @@ describe('heartbeat prompt path normalization', () => {
39
39
  };
40
40
  await runHeartbeatCheck(config);
41
41
  expect(mockRunAgentTurn).toHaveBeenCalledTimes(1);
42
- expect(mockRunAgentTurn).toHaveBeenCalledWith('main', expect.stringContaining('/.skimpyclaw/agents/main/HEARTBEAT.md'), config, 'claude-fast', expect.any(Object), undefined, expect.any(Object));
42
+ expect(mockRunAgentTurn).toHaveBeenCalledWith('main', expect.stringContaining('/.skimpyclaw/agents/main/HEARTBEAT.md'), config, 'anthropic/claude-haiku-4-5', expect.any(Object), undefined, expect.any(Object));
43
43
  });
44
44
  it('normalizes /workspace heartbeat path to agents/main/HEARTBEAT.md', async () => {
45
45
  const config = {
@@ -47,7 +47,7 @@ describe('heartbeat prompt path normalization', () => {
47
47
  heartbeat: {
48
48
  intervalMs: 60000,
49
49
  prompt: 'Read /workspace/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
50
- model: 'claude-fast',
50
+ model: 'anthropic/claude-haiku-4-5',
51
51
  tools: {
52
52
  enabled: true,
53
53
  allowedPaths: ['/Users/example/.skimpyclaw'],
@@ -0,0 +1 @@
1
+ export {};