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,66 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir, tmpdir } from 'os';
5
+ import { loadCodexAuth, resolveCodexAuthPath } from '../providers/codex.js';
6
+ const tempDirs = [];
7
+ function base64UrlJson(value) {
8
+ return Buffer.from(JSON.stringify(value))
9
+ .toString('base64')
10
+ .replace(/=/g, '')
11
+ .replace(/\+/g, '-')
12
+ .replace(/\//g, '_');
13
+ }
14
+ function fakeJwt(payload) {
15
+ return `${base64UrlJson({ alg: 'none' })}.${base64UrlJson(payload)}.signature`;
16
+ }
17
+ function writeAuthFile(raw) {
18
+ const dir = mkdtempSync(join(tmpdir(), 'skimpyclaw-codex-auth-'));
19
+ tempDirs.push(dir);
20
+ const path = join(dir, 'auth.json');
21
+ writeFileSync(path, JSON.stringify(raw), 'utf-8');
22
+ return path;
23
+ }
24
+ afterEach(() => {
25
+ for (const dir of tempDirs.splice(0)) {
26
+ rmSync(dir, { recursive: true, force: true });
27
+ }
28
+ });
29
+ describe('Codex auth loading', () => {
30
+ it('uses ~/.codex/auth.json as the default auth path', () => {
31
+ expect(resolveCodexAuthPath()).toBe(join(homedir(), '.codex', 'auth.json'));
32
+ expect(resolveCodexAuthPath('~/.codex/auth.json')).toBe(join(homedir(), '.codex', 'auth.json'));
33
+ });
34
+ it('reads the current Codex CLI account id field from tokens.account_id', () => {
35
+ const token = fakeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
36
+ const path = writeAuthFile({
37
+ auth_mode: 'chatgpt',
38
+ tokens: {
39
+ access_token: token,
40
+ account_id: 'acct-from-file',
41
+ },
42
+ });
43
+ expect(loadCodexAuth(path)).toEqual({
44
+ accessToken: token,
45
+ accountId: 'acct-from-file',
46
+ });
47
+ });
48
+ it('falls back to the ChatGPT account id embedded in the access token', () => {
49
+ const token = fakeJwt({
50
+ exp: Math.floor(Date.now() / 1000) + 3600,
51
+ 'https://api.openai.com/auth': {
52
+ chatgpt_account_id: 'acct-from-jwt',
53
+ },
54
+ });
55
+ const path = writeAuthFile({
56
+ auth_mode: 'chatgpt',
57
+ tokens: {
58
+ access_token: token,
59
+ },
60
+ });
61
+ expect(loadCodexAuth(path)).toEqual({
62
+ accessToken: token,
63
+ accountId: 'acct-from-jwt',
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { chatWithToolsCodex } from '../providers/codex.js';
3
+ const { mockRunToolLoop } = vi.hoisted(() => ({
4
+ mockRunToolLoop: vi.fn(),
5
+ }));
6
+ vi.mock('../providers/tool-loop.js', () => ({
7
+ runToolLoop: mockRunToolLoop,
8
+ }));
9
+ describe('chatWithToolsCodex unified loop gating', () => {
10
+ const baseConfig = {
11
+ gateway: { port: 18790, mode: 'local' },
12
+ agents: { default: 'main', list: {} },
13
+ models: { providers: {}, aliases: {} },
14
+ channels: { telegram: { enabled: false, token: 't', allowFrom: [] } },
15
+ cron: { jobs: [] },
16
+ heartbeat: { intervalMs: 300000, prompt: 'HEARTBEAT' },
17
+ };
18
+ const baseToolConfig = {
19
+ enabled: true,
20
+ allowedPaths: ['/tmp'],
21
+ maxIterations: 0,
22
+ };
23
+ it('uses unified runToolLoop with maxIterations default of 100', async () => {
24
+ mockRunToolLoop.mockResolvedValueOnce({ response: 'unified', toolCalls: [] });
25
+ const result = await chatWithToolsCodex({
26
+ messages: [{ role: 'user', content: 'hi' }],
27
+ options: { model: 'codex/gpt-5.3-codex' },
28
+ config: baseConfig,
29
+ toolConfig: baseToolConfig,
30
+ });
31
+ expect(mockRunToolLoop).toHaveBeenCalledOnce();
32
+ expect(mockRunToolLoop.mock.calls[0][4].maxIterations).toBe(100);
33
+ expect(result.response).toBe('unified');
34
+ });
35
+ });
@@ -0,0 +1,111 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { runToolLoop } from '../providers/tool-loop.js';
3
+ import { CodexAdapter } from '../providers/adapters/codex-adapter.js';
4
+ const { mockCodexFetch, mockParseCodexSSE, mockExecuteTool, mockGetToolDefinitions } = vi.hoisted(() => ({
5
+ mockCodexFetch: vi.fn(),
6
+ mockParseCodexSSE: vi.fn(),
7
+ mockExecuteTool: vi.fn(),
8
+ mockGetToolDefinitions: vi.fn(),
9
+ }));
10
+ vi.mock('../providers/codex.js', () => ({
11
+ codexFetch: mockCodexFetch,
12
+ parseCodexSSE: mockParseCodexSSE,
13
+ recordCodexUsage: vi.fn(),
14
+ }));
15
+ vi.mock('../tools.js', () => ({
16
+ getToolDefinitions: mockGetToolDefinitions,
17
+ executeTool: mockExecuteTool,
18
+ }));
19
+ vi.mock('../audit.js', () => ({
20
+ startTrace: vi.fn().mockReturnValue('trace-1'),
21
+ addEvent: vi.fn(),
22
+ endTrace: vi.fn().mockResolvedValue(undefined),
23
+ }));
24
+ describe('Codex unified tool loop', () => {
25
+ const adapter = new CodexAdapter();
26
+ let messages;
27
+ let options;
28
+ let config;
29
+ let toolConfig;
30
+ beforeEach(() => {
31
+ messages = [
32
+ { role: 'system', content: 'You are helpful' },
33
+ { role: 'user', content: 'Do the thing' },
34
+ ];
35
+ options = { model: 'codex/gpt-5.3-codex' };
36
+ config = {
37
+ gateway: { port: 18790, mode: 'local' },
38
+ agents: { default: 'main', list: {} },
39
+ models: { providers: {}, aliases: {} },
40
+ channels: { telegram: { enabled: false, token: 't', allowFrom: [] } },
41
+ cron: { jobs: [] },
42
+ heartbeat: { intervalMs: 300000, prompt: 'HEARTBEAT' },
43
+ };
44
+ toolConfig = { enabled: true, allowedPaths: ['/tmp'], maxIterations: 4 };
45
+ mockCodexFetch.mockReset();
46
+ mockParseCodexSSE.mockReset();
47
+ mockExecuteTool.mockReset();
48
+ mockGetToolDefinitions.mockReset();
49
+ mockGetToolDefinitions.mockResolvedValue([{ name: 'Read', description: 'Read', input_schema: { type: 'object' } }]);
50
+ mockExecuteTool.mockResolvedValue('tool output');
51
+ });
52
+ it('runs a normal tool-call cycle and returns final assistant response', async () => {
53
+ mockCodexFetch.mockResolvedValueOnce('sse-1').mockResolvedValueOnce('sse-2');
54
+ mockParseCodexSSE
55
+ .mockReturnValueOnce({
56
+ outputText: '',
57
+ functionCalls: [{ callId: 'fc_1', name: 'Read', arguments: '{"path":"a.txt"}' }],
58
+ response: {
59
+ usage: { input_tokens: 10, output_tokens: 5 },
60
+ output: [{ type: 'function_call', call_id: 'fc_1', name: 'Read', arguments: '{"path":"a.txt"}' }],
61
+ },
62
+ })
63
+ .mockReturnValueOnce({
64
+ outputText: 'Final answer',
65
+ functionCalls: [],
66
+ response: { usage: { input_tokens: 8, output_tokens: 4 }, output: [] },
67
+ });
68
+ const result = await runToolLoop(adapter, messages, options, config, toolConfig);
69
+ expect(result.response).toBe('Final answer');
70
+ expect(result.toolCalls).toHaveLength(1);
71
+ expect(mockExecuteTool).toHaveBeenCalledWith('Read', { path: 'a.txt' }, toolConfig, undefined);
72
+ });
73
+ it('terminates immediately when first response has no tool calls', async () => {
74
+ mockCodexFetch.mockResolvedValueOnce('sse-1');
75
+ mockParseCodexSSE.mockReturnValueOnce({
76
+ outputText: 'Done',
77
+ functionCalls: [],
78
+ response: { usage: { input_tokens: 3, output_tokens: 2 }, output: [] },
79
+ });
80
+ const result = await runToolLoop(adapter, messages, options, config, toolConfig);
81
+ expect(result.response).toBe('Done');
82
+ expect(result.toolCalls).toEqual([]);
83
+ expect(mockCodexFetch).toHaveBeenCalledTimes(1);
84
+ });
85
+ it('stops at max iterations when tool calls continue', async () => {
86
+ toolConfig.maxIterations = 2;
87
+ mockCodexFetch.mockResolvedValue('sse-loop');
88
+ mockParseCodexSSE.mockReturnValue({
89
+ outputText: '',
90
+ functionCalls: [{ callId: 'fc_loop', name: 'Read', arguments: '{"path":"a.txt"}' }],
91
+ response: {
92
+ usage: { input_tokens: 10, output_tokens: 5 },
93
+ output: [{ type: 'function_call', call_id: 'fc_loop', name: 'Read', arguments: '{"path":"a.txt"}' }],
94
+ },
95
+ });
96
+ const result = await runToolLoop(adapter, messages, options, config, toolConfig);
97
+ expect(result.response).toContain('maximum iterations');
98
+ expect(mockCodexFetch).toHaveBeenCalledTimes(3);
99
+ expect(mockCodexFetch.mock.calls[2][0].tools).toBeUndefined();
100
+ });
101
+ it('requests tool definitions with MCP enabled for Codex', async () => {
102
+ mockCodexFetch.mockResolvedValueOnce('sse-1');
103
+ mockParseCodexSSE.mockReturnValueOnce({
104
+ outputText: 'Done',
105
+ functionCalls: [],
106
+ response: { usage: { input_tokens: 1, output_tokens: 1 }, output: [] },
107
+ });
108
+ await runToolLoop(adapter, messages, options, config, toolConfig);
109
+ expect(mockGetToolDefinitions).toHaveBeenCalledWith(toolConfig, expect.objectContaining({ includeMcp: true }));
110
+ });
111
+ });
@@ -0,0 +1,127 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const mockFs = vi.hoisted(() => ({
3
+ existsSync: vi.fn(),
4
+ readFileSync: vi.fn(),
5
+ writeFileSync: vi.fn(),
6
+ chmodSync: vi.fn(),
7
+ mkdirSync: vi.fn(),
8
+ readdirSync: vi.fn(() => []),
9
+ statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
10
+ }));
11
+ const mockDotenvConfig = vi.hoisted(() => vi.fn());
12
+ const mockSecureStore = vi.hoisted(() => ({
13
+ getSecureValue: vi.fn(),
14
+ setSecureValue: vi.fn(),
15
+ requireSecureStore: vi.fn(),
16
+ }));
17
+ vi.mock('fs', () => ({
18
+ existsSync: mockFs.existsSync,
19
+ readFileSync: mockFs.readFileSync,
20
+ writeFileSync: mockFs.writeFileSync,
21
+ chmodSync: mockFs.chmodSync,
22
+ mkdirSync: mockFs.mkdirSync,
23
+ readdirSync: mockFs.readdirSync,
24
+ statSync: mockFs.statSync,
25
+ }));
26
+ vi.mock('dotenv', () => ({
27
+ default: {
28
+ config: mockDotenvConfig,
29
+ },
30
+ }));
31
+ vi.mock('../secure-store.js', () => ({
32
+ getSecureValue: mockSecureStore.getSecureValue,
33
+ setSecureValue: mockSecureStore.setSecureValue,
34
+ requireSecureStore: mockSecureStore.requireSecureStore,
35
+ }));
36
+ vi.mock('os', () => ({
37
+ homedir: () => '/mock-home',
38
+ }));
39
+ describe('config security hardening', () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ vi.resetModules();
43
+ mockFs.existsSync.mockReturnValue(true);
44
+ mockFs.readFileSync.mockReturnValue('{}');
45
+ mockSecureStore.getSecureValue.mockReturnValue('resolved-secret');
46
+ });
47
+ it('enforces mode 0600 when saving config', async () => {
48
+ const { saveConfig } = await import('../config.js');
49
+ saveConfig({ gateway: { port: 18790 } });
50
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/mock-home/.skimpyclaw', { recursive: true });
51
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/mock-home/.skimpyclaw/config.json', expect.any(String), { encoding: 'utf-8', mode: 0o600 });
52
+ expect(mockFs.chmodSync).toHaveBeenCalledWith('/mock-home/.skimpyclaw/config.json', 0o600);
53
+ });
54
+ it('migrates plaintext secrets to keychain references when saving config', async () => {
55
+ const originalPlatform = process.platform;
56
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
57
+ try {
58
+ const { saveConfig } = await import('../config.js');
59
+ saveConfig({
60
+ gateway: { port: 18790 },
61
+ channels: { telegram: { token: 'tg-plain-secret' } },
62
+ models: { providers: { openai: { apiKey: 'sk-plain-secret' } } },
63
+ });
64
+ expect(mockSecureStore.requireSecureStore).toHaveBeenCalled();
65
+ expect(mockSecureStore.setSecureValue).toHaveBeenCalledWith('skimpyclaw-config', 'channels.telegram.token', 'tg-plain-secret');
66
+ expect(mockSecureStore.setSecureValue).toHaveBeenCalledWith('skimpyclaw-config', 'models.providers.openai.apiKey', 'sk-plain-secret');
67
+ const written = mockFs.writeFileSync.mock.calls[0][1];
68
+ expect(written).toContain('${KEYCHAIN:skimpyclaw-config/channels.telegram.token}');
69
+ expect(written).toContain('${KEYCHAIN:skimpyclaw-config/models.providers.openai.apiKey}');
70
+ }
71
+ finally {
72
+ Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
73
+ }
74
+ });
75
+ it('enforces mode 0600 when generating dashboard token', async () => {
76
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({ gateway: { port: 18790 } }));
77
+ const { ensureDashboardToken } = await import('../config.js');
78
+ const cfg = { gateway: { port: 18790 } };
79
+ const token = ensureDashboardToken(cfg);
80
+ expect(typeof token).toBe('string');
81
+ expect(cfg.dashboard.token).toBe(token);
82
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/mock-home/.skimpyclaw/config.json', expect.any(String), { encoding: 'utf-8', mode: 0o600 });
83
+ expect(mockFs.chmodSync).toHaveBeenCalledWith('/mock-home/.skimpyclaw/config.json', 0o600);
84
+ });
85
+ it('resolves ${KEYCHAIN:service/account} references', async () => {
86
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({
87
+ models: { providers: { openai: { apiKey: '${KEYCHAIN:skimpy/service-account}' } } },
88
+ }));
89
+ const { loadConfig } = await import('../config.js');
90
+ const cfg = loadConfig();
91
+ expect(cfg.models.providers.openai.apiKey).toBe('resolved-secret');
92
+ expect(mockSecureStore.getSecureValue).toHaveBeenCalledWith('skimpy', 'service-account');
93
+ });
94
+ it('migrates legacy plaintext secrets on load for backward compatibility', async () => {
95
+ const originalPlatform = process.platform;
96
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
97
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({
98
+ channels: { telegram: { token: 'legacy-token' } },
99
+ models: { providers: { openai: { apiKey: 'legacy-api-key' } } },
100
+ }));
101
+ try {
102
+ const { loadConfig } = await import('../config.js');
103
+ const cfg = loadConfig();
104
+ expect(cfg.channels.telegram.token).toBe('resolved-secret');
105
+ expect(cfg.models.providers.openai.apiKey).toBe('resolved-secret');
106
+ expect(mockSecureStore.setSecureValue).toHaveBeenCalledTimes(2);
107
+ expect(mockFs.writeFileSync).toHaveBeenCalled();
108
+ }
109
+ finally {
110
+ Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
111
+ }
112
+ });
113
+ it('throws a clear error when secure store is unavailable during migration', async () => {
114
+ const originalPlatform = process.platform;
115
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
116
+ mockSecureStore.requireSecureStore.mockImplementation(() => {
117
+ throw new Error('[secure-store] Secret storage requires macOS Keychain, but it is unavailable');
118
+ });
119
+ try {
120
+ const { saveConfig } = await import('../config.js');
121
+ expect(() => saveConfig({ channels: { telegram: { token: 'tg-secret' } } })).toThrow('requires macOS Keychain');
122
+ }
123
+ finally {
124
+ Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
125
+ }
126
+ });
127
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { resolveAllowedPaths } from '../config.js';
2
3
  describe('config env var expansion', () => {
3
4
  it('warns on console when expanding undefined env vars', async () => {
4
5
  // Ensure the test var doesn't exist
@@ -44,3 +45,25 @@ describe('config env var expansion', () => {
44
45
  delete process.env.SKIMPYCLAW_TEST_PRESENT_VAR;
45
46
  });
46
47
  });
48
+ describe('resolveAllowedPaths', () => {
49
+ it('appends project paths to channel overrides', () => {
50
+ const config = {
51
+ projects: {
52
+ wpcom: '/Users/example/Sites/wpcom',
53
+ },
54
+ };
55
+ expect(resolveAllowedPaths(config, ['/Users/example/.skimpyclaw'])).toEqual([
56
+ '/Users/example/.skimpyclaw',
57
+ '/Users/example/Sites/wpcom',
58
+ ]);
59
+ });
60
+ it('deduplicates project paths already present in allowed paths', () => {
61
+ const config = {
62
+ allowedPaths: ['/Users/example/Sites/wpcom'],
63
+ projects: {
64
+ wpcom: '/Users/example/Sites/wpcom',
65
+ },
66
+ };
67
+ expect(resolveAllowedPaths(config)).toEqual(['/Users/example/Sites/wpcom']);
68
+ });
69
+ });