skimpyclaw 0.1.9 → 0.3.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 (61) hide show
  1. package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
  2. package/dist/__tests__/bash-path-validation.test.js +164 -0
  3. package/dist/__tests__/cron.test.js +51 -1
  4. package/dist/__tests__/doctor.runner.test.js +5 -1
  5. package/dist/__tests__/heartbeat.test.js +5 -5
  6. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  7. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  8. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  9. package/dist/__tests__/sandbox-manager.test.js +119 -0
  10. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  11. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  12. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  13. package/dist/__tests__/sandbox-runtime.test.js +176 -0
  14. package/dist/__tests__/setup.test.js +28 -2
  15. package/dist/__tests__/skills.test.js +2 -11
  16. package/dist/__tests__/tools.test.js +6 -1
  17. package/dist/agent.js +3 -0
  18. package/dist/api.js +5 -1
  19. package/dist/channels/telegram/utils.js +2 -2
  20. package/dist/cli.js +212 -0
  21. package/dist/code-agents/executor.js +17 -4
  22. package/dist/code-agents/types.d.ts +5 -0
  23. package/dist/cron.d.ts +6 -0
  24. package/dist/cron.js +59 -3
  25. package/dist/discord.js +2 -2
  26. package/dist/doctor/checks.d.ts +1 -0
  27. package/dist/doctor/checks.js +47 -0
  28. package/dist/doctor/runner.js +2 -1
  29. package/dist/exec-approval.d.ts +4 -0
  30. package/dist/exec-approval.js +4 -4
  31. package/dist/gateway.js +33 -2
  32. package/dist/heartbeat.js +3 -0
  33. package/dist/providers/anthropic.js +1 -1
  34. package/dist/providers/codex.js +1 -1
  35. package/dist/providers/openai.js +2 -2
  36. package/dist/sandbox/bridge.d.ts +5 -0
  37. package/dist/sandbox/bridge.js +63 -0
  38. package/dist/sandbox/index.d.ts +5 -0
  39. package/dist/sandbox/index.js +4 -0
  40. package/dist/sandbox/manager.d.ts +7 -0
  41. package/dist/sandbox/manager.js +89 -0
  42. package/dist/sandbox/mount-security.d.ts +12 -0
  43. package/dist/sandbox/mount-security.js +118 -0
  44. package/dist/sandbox/runtime.d.ts +38 -0
  45. package/dist/sandbox/runtime.js +187 -0
  46. package/dist/service.js +25 -0
  47. package/dist/setup.d.ts +11 -0
  48. package/dist/setup.js +335 -13
  49. package/dist/skills.d.ts +1 -2
  50. package/dist/skills.js +1 -13
  51. package/dist/tools/bash-path-validation.d.ts +22 -0
  52. package/dist/tools/bash-path-validation.js +130 -0
  53. package/dist/tools/bash-tool.js +23 -1
  54. package/dist/tools/definitions.d.ts +0 -7
  55. package/dist/tools/definitions.js +0 -5
  56. package/dist/tools/execute-context.d.ts +6 -0
  57. package/dist/tools/path-utils.js +16 -2
  58. package/dist/tools.js +84 -2
  59. package/dist/types.d.ts +10 -0
  60. package/dist/voice.js +9 -2
  61. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isPathLikeToken, extractScriptTarget, extractPathsFromCommand, validateBashPaths, } from '../tools/bash-path-validation.js';
3
+ describe('isPathLikeToken', () => {
4
+ it('detects absolute paths', () => {
5
+ expect(isPathLikeToken('/etc/passwd')).toBe(true);
6
+ expect(isPathLikeToken('/home/user/file.txt')).toBe(true);
7
+ });
8
+ it('detects relative paths', () => {
9
+ expect(isPathLikeToken('./foo')).toBe(true);
10
+ expect(isPathLikeToken('../bar')).toBe(true);
11
+ expect(isPathLikeToken('.')).toBe(true);
12
+ expect(isPathLikeToken('..')).toBe(true);
13
+ });
14
+ it('detects home-relative paths', () => {
15
+ expect(isPathLikeToken('~/Documents')).toBe(true);
16
+ expect(isPathLikeToken('~')).toBe(true);
17
+ });
18
+ it('rejects flags', () => {
19
+ expect(isPathLikeToken('-f')).toBe(false);
20
+ expect(isPathLikeToken('--file')).toBe(false);
21
+ expect(isPathLikeToken('-')).toBe(false);
22
+ expect(isPathLikeToken('--')).toBe(false);
23
+ });
24
+ it('rejects bare words', () => {
25
+ expect(isPathLikeToken('foo')).toBe(false);
26
+ expect(isPathLikeToken('bar.txt')).toBe(false);
27
+ expect(isPathLikeToken('some-command')).toBe(false);
28
+ });
29
+ it('rejects empty/whitespace', () => {
30
+ expect(isPathLikeToken('')).toBe(false);
31
+ expect(isPathLikeToken(' ')).toBe(false);
32
+ });
33
+ });
34
+ describe('extractScriptTarget', () => {
35
+ it('extracts script path from python command', () => {
36
+ expect(extractScriptTarget(['python3', 'script.py'])).toBe('script.py');
37
+ expect(extractScriptTarget(['python3', '-u', 'script.py'])).toBe('script.py');
38
+ expect(extractScriptTarget(['python', '/home/user/run.py'])).toBe('/home/user/run.py');
39
+ });
40
+ it('extracts script path from node command', () => {
41
+ expect(extractScriptTarget(['node', 'app.js'])).toBe('app.js');
42
+ expect(extractScriptTarget(['node', '--experimental-modules', 'app.js'])).toBe('app.js');
43
+ });
44
+ it('extracts script path from ruby/perl', () => {
45
+ expect(extractScriptTarget(['ruby', 'script.rb'])).toBe('script.rb');
46
+ expect(extractScriptTarget(['perl', 'script.pl'])).toBe('script.pl');
47
+ });
48
+ it('extracts script path from shell interpreters', () => {
49
+ expect(extractScriptTarget(['bash', 'script.sh'])).toBe('script.sh');
50
+ expect(extractScriptTarget(['sh', './run.sh'])).toBe('./run.sh');
51
+ });
52
+ it('returns null for inline execution (-c, -e)', () => {
53
+ expect(extractScriptTarget(['python3', '-c', 'print("hi")'])).toBeNull();
54
+ expect(extractScriptTarget(['node', '-e', 'console.log(1)'])).toBeNull();
55
+ expect(extractScriptTarget(['perl', '-e', 'print 1'])).toBeNull();
56
+ expect(extractScriptTarget(['bash', '-c', 'echo hello'])).toBeNull();
57
+ });
58
+ it('returns null for module execution (-m)', () => {
59
+ expect(extractScriptTarget(['python3', '-m', 'http.server'])).toBeNull();
60
+ });
61
+ it('returns null for non-interpreter commands', () => {
62
+ expect(extractScriptTarget(['ls', '-la'])).toBeNull();
63
+ expect(extractScriptTarget(['grep', 'pattern', 'file.txt'])).toBeNull();
64
+ });
65
+ it('handles env var prefix in segment', () => {
66
+ expect(extractScriptTarget(['NODE_ENV=prod', 'node', 'app.js'])).toBe('app.js');
67
+ });
68
+ it('skips flags with values', () => {
69
+ expect(extractScriptTarget(['python3', '-W', 'ignore', 'script.py'])).toBe('script.py');
70
+ });
71
+ it('returns null for no arguments', () => {
72
+ expect(extractScriptTarget(['python3'])).toBeNull();
73
+ expect(extractScriptTarget([])).toBeNull();
74
+ });
75
+ });
76
+ describe('extractPathsFromCommand', () => {
77
+ const cwd = '/home/user/project';
78
+ it('extracts absolute paths from simple commands', () => {
79
+ const paths = extractPathsFromCommand('cat /etc/passwd', cwd);
80
+ expect(paths).toContain('/etc/passwd');
81
+ });
82
+ it('extracts relative paths and resolves them', () => {
83
+ const paths = extractPathsFromCommand('cat ./data/file.txt', cwd);
84
+ expect(paths).toContain('/home/user/project/data/file.txt');
85
+ });
86
+ it('extracts home-relative paths', () => {
87
+ const paths = extractPathsFromCommand('cat ~/secret.txt', cwd);
88
+ expect(paths.length).toBe(1);
89
+ expect(paths[0]).toMatch(/secret\.txt$/);
90
+ });
91
+ it('extracts paths from piped commands', () => {
92
+ const paths = extractPathsFromCommand('cat /etc/hosts | grep /var/log/syslog', cwd);
93
+ expect(paths).toContain('/etc/hosts');
94
+ expect(paths).toContain('/var/log/syslog');
95
+ });
96
+ it('extracts paths from chained commands', () => {
97
+ const paths = extractPathsFromCommand('ls /tmp && cat /etc/passwd', cwd);
98
+ expect(paths).toContain('/tmp');
99
+ expect(paths).toContain('/etc/passwd');
100
+ });
101
+ it('extracts interpreter script targets', () => {
102
+ const paths = extractPathsFromCommand('python3 /opt/scripts/exploit.py', cwd);
103
+ expect(paths).toContain('/opt/scripts/exploit.py');
104
+ });
105
+ it('extracts both script target and path args', () => {
106
+ const paths = extractPathsFromCommand('python3 ./script.py /data/input.csv', cwd);
107
+ expect(paths).toContain('/home/user/project/script.py');
108
+ expect(paths).toContain('/data/input.csv');
109
+ });
110
+ it('deduplicates paths', () => {
111
+ const paths = extractPathsFromCommand('cat /etc/passwd /etc/passwd', cwd);
112
+ expect(paths.length).toBe(1);
113
+ });
114
+ it('returns empty for commands with no path args', () => {
115
+ const paths = extractPathsFromCommand('echo hello world', cwd);
116
+ expect(paths).toEqual([]);
117
+ });
118
+ it('returns empty for inline interpreter execution', () => {
119
+ const paths = extractPathsFromCommand('python3 -c "print(1)"', cwd);
120
+ expect(paths).toEqual([]);
121
+ });
122
+ it('handles bare words that are not paths', () => {
123
+ const paths = extractPathsFromCommand('git status', cwd);
124
+ expect(paths).toEqual([]);
125
+ });
126
+ });
127
+ describe('validateBashPaths', () => {
128
+ // Use /home/user paths to avoid macOS /tmp → /private/tmp symlink issues
129
+ const allowedPaths = ['/home/user/project', '/home/user/data'];
130
+ it('returns null when all paths are allowed', () => {
131
+ expect(validateBashPaths('cat /home/user/project/file.txt', undefined, allowedPaths)).toBeNull();
132
+ expect(validateBashPaths('ls /home/user/data/stuff', undefined, allowedPaths)).toBeNull();
133
+ });
134
+ it('returns error when path is outside allowed dirs', () => {
135
+ const result = validateBashPaths('cat /etc/passwd', undefined, allowedPaths);
136
+ expect(result).toContain('Error');
137
+ expect(result).toContain('/etc/passwd');
138
+ });
139
+ it('blocks interpreter commands targeting outside paths', () => {
140
+ const result = validateBashPaths('python3 /opt/evil.py', undefined, allowedPaths);
141
+ expect(result).toContain('Error');
142
+ expect(result).toContain('/opt/evil.py');
143
+ });
144
+ it('allows interpreter commands targeting allowed paths', () => {
145
+ expect(validateBashPaths('python3 /home/user/project/run.py', undefined, allowedPaths)).toBeNull();
146
+ });
147
+ it('returns null when no allowedPaths configured (permissive)', () => {
148
+ expect(validateBashPaths('cat /etc/passwd', undefined, [])).toBeNull();
149
+ });
150
+ it('blocks paths in chained commands', () => {
151
+ const result = validateBashPaths('ls /home/user/data && cat /etc/shadow', undefined, allowedPaths);
152
+ expect(result).toContain('Error');
153
+ expect(result).toContain('/etc/shadow');
154
+ });
155
+ it('lists all blocked paths in error', () => {
156
+ const result = validateBashPaths('cat /etc/passwd /var/secret', undefined, allowedPaths);
157
+ expect(result).toContain('/etc/passwd');
158
+ expect(result).toContain('/var/secret');
159
+ });
160
+ it('returns null for commands with no path arguments', () => {
161
+ expect(validateBashPaths('echo hello', undefined, allowedPaths)).toBeNull();
162
+ expect(validateBashPaths('git status', undefined, allowedPaths)).toBeNull();
163
+ });
164
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseDualOutput } from '../cron.js';
2
+ import { parseDualOutput, validatePrReviewOutput } from '../cron.js';
3
3
  describe('parseDualOutput', () => {
4
4
  it('returns full response as text when no delimiters present', () => {
5
5
  const response = 'Hello, this is a regular response with no delimiters.';
@@ -64,3 +64,53 @@ Voice content here
64
64
  expect(result.text).toBe(fullResponse);
65
65
  });
66
66
  });
67
+ describe('validatePrReviewOutput', () => {
68
+ it('returns null for NO_CANDIDATES result', () => {
69
+ const output = 'No PRs found.\n[PR_REVIEW_RESULT: NO_CANDIDATES]';
70
+ expect(validatePrReviewOutput(output)).toBeNull();
71
+ });
72
+ it('returns null when candidates were reviewed with code_with_agent', () => {
73
+ const output = 'Reviewed 3 PRs.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=3 BLOCKED=0]';
74
+ expect(validatePrReviewOutput(output)).toBeNull();
75
+ });
76
+ it('returns null when all candidates are blocked', () => {
77
+ const output = 'All blocked.\n[PR_REVIEW_RESULT: CANDIDATES=2 CODE_AGENT_CALLS=0 BLOCKED=2]';
78
+ expect(validatePrReviewOutput(output)).toBeNull();
79
+ });
80
+ it('returns alert when candidates exist but no code_with_agent calls', () => {
81
+ const output = 'Inline review.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=0]';
82
+ const result = validatePrReviewOutput(output);
83
+ expect(result).not.toBeNull();
84
+ expect(result).toContain('code_with_agent was never called');
85
+ expect(result).toContain('3 PR candidate');
86
+ });
87
+ it('returns alert when result line is missing entirely', () => {
88
+ const output = 'The agent just rambled about PRs without following the prompt.';
89
+ const result = validatePrReviewOutput(output);
90
+ expect(result).not.toBeNull();
91
+ expect(result).toContain('Missing [PR_REVIEW_RESULT]');
92
+ });
93
+ it('returns null when some candidates reviewed and some blocked', () => {
94
+ const output = '[PR_REVIEW_RESULT: CANDIDATES=4 CODE_AGENT_CALLS=2 BLOCKED=2]';
95
+ expect(validatePrReviewOutput(output)).toBeNull();
96
+ });
97
+ it('returns alert when partially blocked but zero calls', () => {
98
+ const output = '[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=1]';
99
+ const result = validatePrReviewOutput(output);
100
+ expect(result).not.toBeNull();
101
+ expect(result).toContain('code_with_agent was never called');
102
+ });
103
+ });
104
+ describe('cron job tool injection', () => {
105
+ it('isCronJob field exists on ExecuteToolContext', () => {
106
+ // Verify the field is part of the type (compile-time check via assignment)
107
+ const ctx = {
108
+ isCronJob: true,
109
+ };
110
+ expect(ctx.isCronJob).toBe(true);
111
+ });
112
+ it('isCronJob defaults to undefined when not set', () => {
113
+ const ctx = {};
114
+ expect(ctx.isCronJob).toBeUndefined();
115
+ });
116
+ });
@@ -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, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, } = vi.hoisted(() => ({
2
+ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
3
3
  mockLoadConfig: vi.fn(),
4
4
  mockCheckNodeVersion: vi.fn(),
5
5
  mockCheckPackageManagerAvailable: vi.fn(),
@@ -17,6 +17,7 @@ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable,
17
17
  mockCheckGatewayHostBindable: vi.fn(),
18
18
  mockCheckSkimpyclawDirWritable: vi.fn(),
19
19
  mockCheckPortAvailability: vi.fn(),
20
+ mockCheckSandboxAvailable: vi.fn(),
20
21
  }));
21
22
  vi.mock('../config.js', () => ({
22
23
  loadConfig: mockLoadConfig,
@@ -38,6 +39,7 @@ vi.mock('../doctor/checks.js', () => ({
38
39
  checkGatewayHostBindable: mockCheckGatewayHostBindable,
39
40
  checkSkimpyclawDirWritable: mockCheckSkimpyclawDirWritable,
40
41
  checkPortAvailability: mockCheckPortAvailability,
42
+ checkSandboxAvailable: mockCheckSandboxAvailable,
41
43
  }));
42
44
  import { computeExitCode, runDoctor } from '../doctor/runner.js';
43
45
  function okCheck(name, category, detail = 'ok') {
@@ -78,6 +80,7 @@ describe('doctor runner', () => {
78
80
  mockCheckGatewayHostBindable.mockReset();
79
81
  mockCheckSkimpyclawDirWritable.mockReset();
80
82
  mockCheckPortAvailability.mockReset();
83
+ mockCheckSandboxAvailable.mockReset();
81
84
  mockCheckNodeVersion.mockResolvedValue(okCheck('node_version', 'environment', 'v20.11.0'));
82
85
  mockCheckPackageManagerAvailable.mockResolvedValue(okCheck('package_manager_available', 'environment', 'pnpm'));
83
86
  mockCheckTypeScriptCompile.mockResolvedValue(okCheck('typescript_compile', 'environment'));
@@ -94,6 +97,7 @@ describe('doctor runner', () => {
94
97
  mockCheckGatewayHostBindable.mockResolvedValue(okCheck('gateway_host_bindable', 'runtime', '127.0.0.1 (always available)'));
95
98
  mockCheckSkimpyclawDirWritable.mockResolvedValue(okCheck('skimpyclaw_dirs_writable', 'runtime'));
96
99
  mockCheckPortAvailability.mockResolvedValue(okCheck('gateway_port_available', 'runtime'));
100
+ mockCheckSandboxAvailable.mockResolvedValue(okCheck('sandbox_available', 'runtime', 'Sandbox disabled'));
97
101
  });
98
102
  it('computes exit code 0 when all checks pass', () => {
99
103
  const code = computeExitCode({ checks: [okCheck('node_version', 'environment')] });
@@ -21,11 +21,11 @@ describe('heartbeat prompt path normalization', () => {
21
21
  agents: { default: 'main' },
22
22
  heartbeat: {
23
23
  intervalMs: 60000,
24
- prompt: 'Read /Users/katre/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
24
+ prompt: 'Read /Users/example/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
25
25
  model: 'claude-fast',
26
26
  tools: {
27
27
  enabled: true,
28
- allowedPaths: ['/Users/katre/.skimpyclaw'],
28
+ allowedPaths: ['/Users/example/.skimpyclaw'],
29
29
  maxIterations: 10,
30
30
  bashTimeout: 15000,
31
31
  },
@@ -33,7 +33,7 @@ describe('heartbeat prompt path normalization', () => {
33
33
  channels: {
34
34
  active: 'telegram',
35
35
  telegram: {
36
- defaultAllowedPaths: ['/Users/katre/.skimpyclaw'],
36
+ defaultAllowedPaths: ['/Users/example/.skimpyclaw'],
37
37
  },
38
38
  },
39
39
  };
@@ -50,7 +50,7 @@ describe('heartbeat prompt path normalization', () => {
50
50
  model: 'claude-fast',
51
51
  tools: {
52
52
  enabled: true,
53
- allowedPaths: ['/Users/katre/.skimpyclaw'],
53
+ allowedPaths: ['/Users/example/.skimpyclaw'],
54
54
  maxIterations: 10,
55
55
  bashTimeout: 15000,
56
56
  },
@@ -58,7 +58,7 @@ describe('heartbeat prompt path normalization', () => {
58
58
  channels: {
59
59
  active: 'telegram',
60
60
  telegram: {
61
- defaultAllowedPaths: ['/Users/katre/.skimpyclaw'],
61
+ defaultAllowedPaths: ['/Users/example/.skimpyclaw'],
62
62
  },
63
63
  },
64
64
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ const { mockExecInContainer } = vi.hoisted(() => ({
3
+ mockExecInContainer: vi.fn(),
4
+ }));
5
+ vi.mock('../sandbox/runtime.js', () => ({
6
+ execInContainer: mockExecInContainer,
7
+ }));
8
+ import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob, } from '../sandbox/bridge.js';
9
+ describe('sandbox/bridge', () => {
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ });
13
+ describe('sandboxBash', () => {
14
+ it('passes command through', async () => {
15
+ mockExecInContainer.mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0 });
16
+ const result = await sandboxBash('ctr', 'echo hi');
17
+ expect(result).toBe('ok');
18
+ expect(mockExecInContainer).toHaveBeenCalledWith('ctr', ['echo hi'], expect.any(Object));
19
+ });
20
+ it('handles cwd', async () => {
21
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
22
+ await sandboxBash('ctr', 'ls', '/workspace');
23
+ const args = mockExecInContainer.mock.calls[0][1][0];
24
+ expect(args).toContain('cd');
25
+ expect(args).toContain('/workspace');
26
+ });
27
+ it('handles timeout', async () => {
28
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
29
+ await sandboxBash('ctr', 'sleep 1', undefined, 5000);
30
+ const opts = mockExecInContainer.mock.calls[0][2];
31
+ expect(opts.timeout).toBe(5000);
32
+ });
33
+ it('truncates long output', async () => {
34
+ const longOutput = 'x'.repeat(60 * 1024);
35
+ mockExecInContainer.mockResolvedValue({ stdout: longOutput, stderr: '', exitCode: 0 });
36
+ const result = await sandboxBash('ctr', 'cmd');
37
+ expect(result.length).toBeLessThan(longOutput.length);
38
+ expect(result).toContain('truncated');
39
+ });
40
+ it('includes exit code on failure', async () => {
41
+ mockExecInContainer.mockResolvedValue({ stdout: 'out', stderr: 'err', exitCode: 42 });
42
+ const result = await sandboxBash('ctr', 'bad');
43
+ expect(result).toContain('[exit code: 42]');
44
+ });
45
+ it('returns (no output) when empty', async () => {
46
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
47
+ const result = await sandboxBash('ctr', 'true');
48
+ expect(result).toBe('(no output)');
49
+ });
50
+ });
51
+ describe('sandboxReadFile', () => {
52
+ it('calls cat with path', async () => {
53
+ mockExecInContainer.mockResolvedValue({ stdout: 'content', stderr: '', exitCode: 0 });
54
+ const result = await sandboxReadFile('ctr', '/workspace/file.txt');
55
+ expect(result).toBe('content');
56
+ expect(mockExecInContainer.mock.calls[0][1][0]).toContain('cat');
57
+ });
58
+ it('truncates large files', async () => {
59
+ const big = 'y'.repeat(120 * 1024);
60
+ mockExecInContainer.mockResolvedValue({ stdout: big, stderr: '', exitCode: 0 });
61
+ const result = await sandboxReadFile('ctr', '/f');
62
+ expect(result.length).toBeLessThan(big.length);
63
+ expect(result).toContain('truncated');
64
+ });
65
+ it('throws on failure', async () => {
66
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'not found', exitCode: 1 });
67
+ await expect(sandboxReadFile('ctr', '/missing')).rejects.toThrow('Failed to read');
68
+ });
69
+ });
70
+ describe('sandboxWriteFile', () => {
71
+ it('sends content via stdin', async () => {
72
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
73
+ const result = await sandboxWriteFile('ctr', '/workspace/f.txt', 'hello');
74
+ expect(result).toContain('Written');
75
+ expect(result).toContain('5 bytes');
76
+ const opts = mockExecInContainer.mock.calls[0][2];
77
+ expect(opts.stdin).toBe('hello');
78
+ });
79
+ it('creates parent dirs', async () => {
80
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
81
+ await sandboxWriteFile('ctr', '/workspace/a/b/c.txt', 'data');
82
+ expect(mockExecInContainer.mock.calls[0][1][0]).toContain('mkdir -p');
83
+ });
84
+ it('throws on failure', async () => {
85
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'perm denied', exitCode: 1 });
86
+ await expect(sandboxWriteFile('ctr', '/f', 'x')).rejects.toThrow('Failed to write');
87
+ });
88
+ });
89
+ describe('sandboxListDir', () => {
90
+ it('calls ls -la', async () => {
91
+ mockExecInContainer.mockResolvedValue({ stdout: 'drwxr-xr-x ...', stderr: '', exitCode: 0 });
92
+ const result = await sandboxListDir('ctr', '/workspace');
93
+ expect(result).toBe('drwxr-xr-x ...');
94
+ expect(mockExecInContainer.mock.calls[0][1][0]).toContain('ls -la');
95
+ });
96
+ it('throws on failure', async () => {
97
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'no such dir', exitCode: 1 });
98
+ await expect(sandboxListDir('ctr', '/bad')).rejects.toThrow('Failed to list');
99
+ });
100
+ });
101
+ describe('sandboxGlob', () => {
102
+ it('calls find with correct args', async () => {
103
+ mockExecInContainer.mockResolvedValue({ stdout: '/workspace/a.ts\n', stderr: '', exitCode: 0 });
104
+ const result = await sandboxGlob('ctr', '/workspace', '*.ts');
105
+ expect(result).toContain('/workspace/a.ts');
106
+ const cmd = mockExecInContainer.mock.calls[0][1][0];
107
+ expect(cmd).toContain('find');
108
+ expect(cmd).toContain('-name');
109
+ expect(cmd).toContain('-maxdepth');
110
+ });
111
+ it('throws on failure', async () => {
112
+ mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'err', exitCode: 1 });
113
+ await expect(sandboxGlob('ctr', '/x', '*.js')).rejects.toThrow('Failed to glob');
114
+ });
115
+ });
116
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
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
+ });
65
+ describe('releaseContainer', () => {
66
+ it('removes container and clears from map', async () => {
67
+ await ensureContainer('sess1', testConfig, ['/p']);
68
+ await releaseContainer('sess1');
69
+ expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-sess1');
70
+ // Next ensureContainer should create fresh
71
+ await ensureContainer('sess1', testConfig, ['/p']);
72
+ expect(mockCreateContainer).toHaveBeenCalledTimes(2);
73
+ });
74
+ it('no-ops for unknown session', async () => {
75
+ await expect(releaseContainer('unknown')).resolves.toBeUndefined();
76
+ expect(mockRemoveContainer).not.toHaveBeenCalled();
77
+ });
78
+ });
79
+ describe('pruneIdle', () => {
80
+ it('removes containers older than threshold', async () => {
81
+ vi.useFakeTimers();
82
+ try {
83
+ await ensureContainer('old', testConfig, ['/p']);
84
+ // Advance time by 10 seconds so the container is idle
85
+ vi.advanceTimersByTime(10_000);
86
+ const pruned = await pruneIdle(5_000);
87
+ expect(pruned).toBe(1);
88
+ expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-old');
89
+ }
90
+ finally {
91
+ vi.useRealTimers();
92
+ }
93
+ });
94
+ it('keeps recent containers', async () => {
95
+ await ensureContainer('new', testConfig, ['/p']);
96
+ const pruned = await pruneIdle(60_000);
97
+ expect(pruned).toBe(0);
98
+ });
99
+ });
100
+ describe('releaseAll', () => {
101
+ it('removes all containers', async () => {
102
+ mockIsContainerRunning.mockResolvedValue(true);
103
+ await ensureContainer('a', testConfig, ['/p']);
104
+ await ensureContainer('b', testConfig, ['/p']);
105
+ await releaseAll();
106
+ expect(mockRemoveContainer).toHaveBeenCalledTimes(2);
107
+ });
108
+ });
109
+ describe('resetForTesting', () => {
110
+ it('clears state without removing containers', async () => {
111
+ mockIsContainerRunning.mockResolvedValue(false);
112
+ await ensureContainer('x', testConfig, ['/p']);
113
+ resetForTesting();
114
+ // Next call should create fresh (map is empty)
115
+ await ensureContainer('x', testConfig, ['/p']);
116
+ expect(mockCreateContainer).toHaveBeenCalledTimes(2);
117
+ });
118
+ });
119
+ });
@@ -0,0 +1 @@
1
+ export {};