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.
- package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
- package/dist/__tests__/bash-path-validation.test.js +164 -0
- package/dist/__tests__/cron.test.js +51 -1
- package/dist/__tests__/doctor.runner.test.js +5 -1
- package/dist/__tests__/heartbeat.test.js +5 -5
- package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
- package/dist/__tests__/sandbox-bridge.test.js +116 -0
- package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
- package/dist/__tests__/sandbox-manager.test.js +119 -0
- package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
- package/dist/__tests__/sandbox-mount-security.test.js +131 -0
- package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
- package/dist/__tests__/sandbox-runtime.test.js +176 -0
- package/dist/__tests__/setup.test.js +28 -2
- package/dist/__tests__/skills.test.js +2 -11
- package/dist/__tests__/tools.test.js +6 -1
- package/dist/agent.js +3 -0
- package/dist/api.js +5 -1
- package/dist/channels/telegram/utils.js +2 -2
- package/dist/cli.js +212 -0
- package/dist/code-agents/executor.js +17 -4
- package/dist/code-agents/types.d.ts +5 -0
- package/dist/cron.d.ts +6 -0
- package/dist/cron.js +59 -3
- package/dist/discord.js +2 -2
- package/dist/doctor/checks.d.ts +1 -0
- package/dist/doctor/checks.js +47 -0
- package/dist/doctor/runner.js +2 -1
- package/dist/exec-approval.d.ts +4 -0
- package/dist/exec-approval.js +4 -4
- package/dist/gateway.js +33 -2
- package/dist/heartbeat.js +3 -0
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/codex.js +1 -1
- package/dist/providers/openai.js +2 -2
- package/dist/sandbox/bridge.d.ts +5 -0
- package/dist/sandbox/bridge.js +63 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/manager.d.ts +7 -0
- package/dist/sandbox/manager.js +89 -0
- package/dist/sandbox/mount-security.d.ts +12 -0
- package/dist/sandbox/mount-security.js +118 -0
- package/dist/sandbox/runtime.d.ts +38 -0
- package/dist/sandbox/runtime.js +187 -0
- package/dist/service.js +25 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +335 -13
- package/dist/skills.d.ts +1 -2
- package/dist/skills.js +1 -13
- package/dist/tools/bash-path-validation.d.ts +22 -0
- package/dist/tools/bash-path-validation.js +130 -0
- package/dist/tools/bash-tool.js +23 -1
- package/dist/tools/definitions.d.ts +0 -7
- package/dist/tools/definitions.js +0 -5
- package/dist/tools/execute-context.d.ts +6 -0
- package/dist/tools/path-utils.js +16 -2
- package/dist/tools.js +84 -2
- package/dist/types.d.ts +10 -0
- package/dist/voice.js +9 -2
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('fs', async () => {
|
|
3
|
+
const actual = await vi.importActual('fs');
|
|
4
|
+
return {
|
|
5
|
+
...actual,
|
|
6
|
+
existsSync: vi.fn(),
|
|
7
|
+
realpathSync: vi.fn(),
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
import { existsSync, realpathSync } from 'fs';
|
|
11
|
+
import { isBlockedPath, validateMountPaths, translatePath } from '../sandbox/mount-security.js';
|
|
12
|
+
const mockExistsSync = existsSync;
|
|
13
|
+
const mockRealpathSync = realpathSync;
|
|
14
|
+
describe('sandbox/mount-security', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
describe('isBlockedPath', () => {
|
|
19
|
+
it('blocks .ssh', () => {
|
|
20
|
+
expect(isBlockedPath('/home/user/.ssh')).toBe(true);
|
|
21
|
+
expect(isBlockedPath('/home/user/.ssh/id_rsa')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('blocks .gnupg', () => {
|
|
24
|
+
expect(isBlockedPath('/home/user/.gnupg')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it('blocks .aws', () => {
|
|
27
|
+
expect(isBlockedPath('/home/user/.aws')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('blocks credentials', () => {
|
|
30
|
+
expect(isBlockedPath('/some/path/credentials')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it('blocks .env', () => {
|
|
33
|
+
expect(isBlockedPath('/project/.env')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('blocks .kube', () => {
|
|
36
|
+
expect(isBlockedPath('/home/user/.kube/config')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('blocks .docker/config.json compound pattern', () => {
|
|
39
|
+
expect(isBlockedPath('/home/user/.docker/config.json')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('allows normal paths', () => {
|
|
42
|
+
expect(isBlockedPath('/home/user/project')).toBe(false);
|
|
43
|
+
expect(isBlockedPath('/workspace/src/index.ts')).toBe(false);
|
|
44
|
+
expect(isBlockedPath('/tmp/test')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('validateMountPaths', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mockExistsSync.mockReturnValue(true);
|
|
50
|
+
mockRealpathSync.mockImplementation((p) => p);
|
|
51
|
+
});
|
|
52
|
+
it('maps to /workspace/<basename>', () => {
|
|
53
|
+
const mounts = validateMountPaths(['/home/user/myproject']);
|
|
54
|
+
const projectMount = mounts.find((m) => m.host === '/home/user/myproject');
|
|
55
|
+
expect(projectMount).toBeDefined();
|
|
56
|
+
expect(projectMount.container).toBe('/workspace/myproject');
|
|
57
|
+
expect(projectMount.readOnly).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('deduplicates basenames with suffix', () => {
|
|
60
|
+
const mounts = validateMountPaths(['/a/project', '/b/project']);
|
|
61
|
+
const containers = mounts.filter((m) => m.host.endsWith('/project')).map((m) => m.container);
|
|
62
|
+
expect(containers).toContain('/workspace/project');
|
|
63
|
+
expect(containers).toContain('/workspace/project_1');
|
|
64
|
+
});
|
|
65
|
+
it('adds ~/.skimpyclaw at /workspace/config', () => {
|
|
66
|
+
const mounts = validateMountPaths(['/home/user/project']);
|
|
67
|
+
const configMount = mounts.find((m) => m.container === '/workspace/config');
|
|
68
|
+
expect(configMount).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
it('throws on blocked paths', () => {
|
|
71
|
+
expect(() => validateMountPaths(['/home/user/.ssh'])).toThrow('Blocked path');
|
|
72
|
+
});
|
|
73
|
+
it('skips missing paths', () => {
|
|
74
|
+
mockExistsSync.mockImplementation((p) => {
|
|
75
|
+
// Only ~/.skimpyclaw exists for the auto-add
|
|
76
|
+
return typeof p === 'string' && p.includes('.skimpyclaw');
|
|
77
|
+
});
|
|
78
|
+
const mounts = validateMountPaths(['/nonexistent/path']);
|
|
79
|
+
// Should only have the auto-added config mount
|
|
80
|
+
const nonConfig = mounts.filter((m) => m.container !== '/workspace/config');
|
|
81
|
+
expect(nonConfig).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
it('does not duplicate ~/.skimpyclaw if already in list', () => {
|
|
84
|
+
// Simulate ~/.skimpyclaw being passed explicitly
|
|
85
|
+
const home = process.env.HOME || '/Users/katre';
|
|
86
|
+
const skDir = `${home}/.skimpyclaw`;
|
|
87
|
+
mockRealpathSync.mockImplementation((p) => p);
|
|
88
|
+
// .skimpyclaw is NOT blocked, so it should be mounted
|
|
89
|
+
// But it should not appear twice
|
|
90
|
+
const mounts = validateMountPaths([skDir]);
|
|
91
|
+
const configMounts = mounts.filter((m) => m.host === skDir);
|
|
92
|
+
expect(configMounts).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('translatePath', () => {
|
|
96
|
+
const mounts = [
|
|
97
|
+
{ host: '/Users/katre/Sites/skimpyclaw', container: '/workspace/skimpyclaw', readOnly: false },
|
|
98
|
+
{ host: '/Users/katre/.skimpyclaw', container: '/workspace/config', readOnly: false },
|
|
99
|
+
];
|
|
100
|
+
it('translates host path to container path', () => {
|
|
101
|
+
expect(translatePath('/Users/katre/Sites/skimpyclaw/src/index.ts', mounts))
|
|
102
|
+
.toBe('/workspace/skimpyclaw/src/index.ts');
|
|
103
|
+
});
|
|
104
|
+
it('translates exact mount root', () => {
|
|
105
|
+
expect(translatePath('/Users/katre/Sites/skimpyclaw', mounts))
|
|
106
|
+
.toBe('/workspace/skimpyclaw');
|
|
107
|
+
});
|
|
108
|
+
it('translates config path', () => {
|
|
109
|
+
expect(translatePath('/Users/katre/.skimpyclaw/config.json', mounts))
|
|
110
|
+
.toBe('/workspace/config/config.json');
|
|
111
|
+
});
|
|
112
|
+
it('translates /Users path to /System/Volumes/Data mount on macOS', () => {
|
|
113
|
+
const macMounts = [
|
|
114
|
+
{ host: '/System/Volumes/Data/Users/katre/.skimpyclaw', container: '/workspace/config', readOnly: false },
|
|
115
|
+
];
|
|
116
|
+
expect(translatePath('/Users/katre/.skimpyclaw/state/japan-flight-watch.json', macMounts))
|
|
117
|
+
.toBe('/workspace/config/state/japan-flight-watch.json');
|
|
118
|
+
});
|
|
119
|
+
it('returns original path if no mount matches', () => {
|
|
120
|
+
expect(translatePath('/tmp/random/file', mounts)).toBe('/tmp/random/file');
|
|
121
|
+
});
|
|
122
|
+
it('matches most specific mount first', () => {
|
|
123
|
+
const nestedMounts = [
|
|
124
|
+
{ host: '/Users/katre', container: '/workspace/home', readOnly: false },
|
|
125
|
+
{ host: '/Users/katre/Sites/skimpyclaw', container: '/workspace/skimpyclaw', readOnly: false },
|
|
126
|
+
];
|
|
127
|
+
expect(translatePath('/Users/katre/Sites/skimpyclaw/src/file.ts', nestedMounts))
|
|
128
|
+
.toBe('/workspace/skimpyclaw/src/file.ts');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
const { mockSpawn, mockSpawnSync } = vi.hoisted(() => ({
|
|
3
|
+
mockSpawn: vi.fn(),
|
|
4
|
+
mockSpawnSync: vi.fn().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('child_process', () => ({ spawn: mockSpawn, spawnSync: mockSpawnSync }));
|
|
7
|
+
import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, probeRuntime, } from '../sandbox/runtime.js';
|
|
8
|
+
function fakeChild(exitCode, stdout = '', stderr = '', opts) {
|
|
9
|
+
const stdoutCallbacks = [];
|
|
10
|
+
const stderrCallbacks = [];
|
|
11
|
+
const eventCallbacks = {};
|
|
12
|
+
const child = {
|
|
13
|
+
stdout: { on: (_e, cb) => stdoutCallbacks.push(cb) },
|
|
14
|
+
stderr: { on: (_e, cb) => stderrCallbacks.push(cb) },
|
|
15
|
+
stdin: opts?.stdinNeeded ? { write: vi.fn(), end: vi.fn() } : null,
|
|
16
|
+
on(event, cb) {
|
|
17
|
+
if (!eventCallbacks[event])
|
|
18
|
+
eventCallbacks[event] = [];
|
|
19
|
+
eventCallbacks[event].push(cb);
|
|
20
|
+
return child;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
// Emit data and close on next tick (setTimeout ensures listeners are attached first)
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
if (stdout)
|
|
26
|
+
for (const cb of stdoutCallbacks)
|
|
27
|
+
cb(Buffer.from(stdout));
|
|
28
|
+
if (stderr)
|
|
29
|
+
for (const cb of stderrCallbacks)
|
|
30
|
+
cb(Buffer.from(stderr));
|
|
31
|
+
for (const cb of eventCallbacks['close'] ?? [])
|
|
32
|
+
cb(exitCode);
|
|
33
|
+
}, 0);
|
|
34
|
+
return child;
|
|
35
|
+
}
|
|
36
|
+
describe('sandbox/runtime', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
resetRuntime();
|
|
40
|
+
setRuntime('container'); // skip auto-detection in tests
|
|
41
|
+
});
|
|
42
|
+
describe('createContainer', () => {
|
|
43
|
+
it('builds correct docker args', async () => {
|
|
44
|
+
mockSpawn.mockReturnValue(fakeChild(0));
|
|
45
|
+
await createContainer('test-ctr', {
|
|
46
|
+
image: 'myimg',
|
|
47
|
+
cpus: 2,
|
|
48
|
+
memory: '1G',
|
|
49
|
+
network: 'none',
|
|
50
|
+
user: '501:20',
|
|
51
|
+
mounts: [{ host: '/a', container: '/b', readOnly: true }],
|
|
52
|
+
});
|
|
53
|
+
const args = mockSpawn.mock.calls[0][1];
|
|
54
|
+
expect(args).toContain('--name');
|
|
55
|
+
expect(args).toContain('test-ctr');
|
|
56
|
+
expect(args).toContain('--cpus');
|
|
57
|
+
expect(args).toContain('2');
|
|
58
|
+
expect(args).toContain('--memory');
|
|
59
|
+
expect(args).toContain('1G');
|
|
60
|
+
expect(args).toContain('--network');
|
|
61
|
+
expect(args).toContain('none');
|
|
62
|
+
expect(args).toContain('--user');
|
|
63
|
+
expect(args).toContain('501:20');
|
|
64
|
+
expect(args.some((a) => a.includes('type=bind,src=/a,dst=/b,ro'))).toBe(true);
|
|
65
|
+
expect(args).toContain('myimg');
|
|
66
|
+
});
|
|
67
|
+
it('throws on non-zero exit', async () => {
|
|
68
|
+
mockSpawn.mockReturnValue(fakeChild(1, '', 'boom'));
|
|
69
|
+
await expect(createContainer('c1', { image: 'img' })).rejects.toThrow('Failed to create container');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('execInContainer', () => {
|
|
73
|
+
it('builds sh -c command', async () => {
|
|
74
|
+
mockSpawn.mockReturnValue(fakeChild(0, 'hello'));
|
|
75
|
+
const result = await execInContainer('ctr', ['echo hello']);
|
|
76
|
+
expect(result.stdout).toBe('hello');
|
|
77
|
+
const args = mockSpawn.mock.calls[0][1];
|
|
78
|
+
expect(args).toContain('sh');
|
|
79
|
+
expect(args).toContain('-c');
|
|
80
|
+
});
|
|
81
|
+
it('handles stdin', async () => {
|
|
82
|
+
mockSpawn.mockReturnValue(fakeChild(0, '', '', { stdinNeeded: true }));
|
|
83
|
+
await execInContainer('ctr', ['cat'], { stdin: 'data' });
|
|
84
|
+
const args = mockSpawn.mock.calls[0][1];
|
|
85
|
+
expect(args).toContain('-i');
|
|
86
|
+
});
|
|
87
|
+
it('handles env vars', async () => {
|
|
88
|
+
mockSpawn.mockReturnValue(fakeChild(0));
|
|
89
|
+
await execInContainer('ctr', ['cmd'], { env: { FOO: 'bar' } });
|
|
90
|
+
const args = mockSpawn.mock.calls[0][1];
|
|
91
|
+
expect(args).toContain('-e');
|
|
92
|
+
expect(args).toContain('FOO=bar');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('removeContainer', () => {
|
|
96
|
+
it('calls stop then rm', async () => {
|
|
97
|
+
mockSpawn.mockImplementation((_cmd, args) => {
|
|
98
|
+
return fakeChild(0);
|
|
99
|
+
});
|
|
100
|
+
await removeContainer('ctr');
|
|
101
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
102
|
+
expect(mockSpawn.mock.calls[0][1]).toContain('stop');
|
|
103
|
+
expect(mockSpawn.mock.calls[1][1]).toContain('rm');
|
|
104
|
+
}, 10_000);
|
|
105
|
+
it('does not throw on failure', async () => {
|
|
106
|
+
mockSpawn.mockImplementation(() => fakeChild(1, '', 'fail'));
|
|
107
|
+
await expect(removeContainer('ctr')).resolves.toBeUndefined();
|
|
108
|
+
}, 10_000);
|
|
109
|
+
});
|
|
110
|
+
describe('isContainerRunning', () => {
|
|
111
|
+
it('returns true when inspect shows running state', async () => {
|
|
112
|
+
mockSpawn.mockReturnValue(fakeChild(0, '{"State": {"Status": "running"}}'));
|
|
113
|
+
expect(await isContainerRunning('ctr')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
it('returns false otherwise', async () => {
|
|
116
|
+
mockSpawn.mockReturnValue(fakeChild(1));
|
|
117
|
+
expect(await isContainerRunning('ctr')).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('probeRuntime', () => {
|
|
121
|
+
it('returns preferred runtime when available', () => {
|
|
122
|
+
mockSpawnSync.mockReturnValue({ status: 0 });
|
|
123
|
+
expect(probeRuntime('docker')).toBe('docker');
|
|
124
|
+
expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], { stdio: 'ignore' });
|
|
125
|
+
});
|
|
126
|
+
it('falls back to auto-detect when preferred is unavailable', () => {
|
|
127
|
+
mockSpawnSync.mockImplementation((cmd) => {
|
|
128
|
+
// preferred 'docker' fails, but 'container' succeeds
|
|
129
|
+
if (cmd === 'docker')
|
|
130
|
+
return { status: 1 };
|
|
131
|
+
if (cmd === 'container')
|
|
132
|
+
return { status: 0 };
|
|
133
|
+
return { status: 1 };
|
|
134
|
+
});
|
|
135
|
+
expect(probeRuntime('docker')).toBe('container');
|
|
136
|
+
});
|
|
137
|
+
it('returns null when no runtime is available', () => {
|
|
138
|
+
mockSpawnSync.mockReturnValue({ status: 1 });
|
|
139
|
+
expect(probeRuntime('docker')).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
it('auto-detects without preferred runtime', () => {
|
|
142
|
+
mockSpawnSync.mockImplementation((cmd) => {
|
|
143
|
+
if (cmd === 'container')
|
|
144
|
+
return { status: 0 };
|
|
145
|
+
return { status: 1 };
|
|
146
|
+
});
|
|
147
|
+
expect(probeRuntime()).toBe('container');
|
|
148
|
+
});
|
|
149
|
+
it('prefers container over docker in auto-detect', () => {
|
|
150
|
+
mockSpawnSync.mockReturnValue({ status: 0 });
|
|
151
|
+
expect(probeRuntime()).toBe('container');
|
|
152
|
+
// First call should be to 'container'
|
|
153
|
+
expect(mockSpawnSync.mock.calls[0][0]).toBe('container');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('cleanupOrphans', () => {
|
|
157
|
+
it('lists containers, filters by prefix, removes matches', async () => {
|
|
158
|
+
let callCount = 0;
|
|
159
|
+
mockSpawn.mockImplementation(() => {
|
|
160
|
+
callCount++;
|
|
161
|
+
if (callCount === 1) {
|
|
162
|
+
// ps call
|
|
163
|
+
return fakeChild(0, 'skimpyclaw-sbx-abc\nother-ctr\nskimpyclaw-sbx-def\n');
|
|
164
|
+
}
|
|
165
|
+
// stop/rm calls
|
|
166
|
+
return fakeChild(0);
|
|
167
|
+
});
|
|
168
|
+
const count = await cleanupOrphans();
|
|
169
|
+
expect(count).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
it('returns 0 on ps failure', async () => {
|
|
172
|
+
mockSpawn.mockImplementation(() => fakeChild(1));
|
|
173
|
+
expect(await cleanupOrphans()).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -79,7 +79,7 @@ describe('setup config generation', () => {
|
|
|
79
79
|
agentName: 'Claw',
|
|
80
80
|
selectedProviders: new Set(['anthropic-api']),
|
|
81
81
|
providerSecrets: { anthropicKey: 'sk-ant-test' },
|
|
82
|
-
features: { browser: false, voice: false, mcp: false },
|
|
82
|
+
features: { browser: false, voice: false, mcp: false, sandbox: false },
|
|
83
83
|
});
|
|
84
84
|
expect(config.heartbeat.tools.browser.enabled).toBe(false);
|
|
85
85
|
expect(config.voice).toBeUndefined();
|
|
@@ -92,7 +92,7 @@ describe('setup config generation', () => {
|
|
|
92
92
|
agentName: 'Claw',
|
|
93
93
|
selectedProviders: new Set(['anthropic-api']),
|
|
94
94
|
providerSecrets: { anthropicKey: 'sk-ant-test' },
|
|
95
|
-
features: { browser: true, voice: true, mcp: false },
|
|
95
|
+
features: { browser: true, voice: true, mcp: false, sandbox: false },
|
|
96
96
|
});
|
|
97
97
|
expect(config.heartbeat.tools.browser.enabled).toBe(true);
|
|
98
98
|
expect(config.voice).toBeDefined();
|
|
@@ -115,4 +115,30 @@ describe('setup config generation', () => {
|
|
|
115
115
|
// parseInt('not-a-number') is NaN, so || falls through to string
|
|
116
116
|
expect(config.channels.telegram.allowFrom).toEqual(['not-a-number']);
|
|
117
117
|
});
|
|
118
|
+
it('includes starter cron jobs and skills when requested', () => {
|
|
119
|
+
const config = buildSetupConfig({
|
|
120
|
+
workspaceDir: '/tmp/workspace',
|
|
121
|
+
telegramId: '12345',
|
|
122
|
+
telegramToken: 'tg-token',
|
|
123
|
+
agentName: 'Claw',
|
|
124
|
+
selectedProviders: new Set(['anthropic-api']),
|
|
125
|
+
providerSecrets: { anthropicKey: 'sk-ant-test' },
|
|
126
|
+
starters: {
|
|
127
|
+
cronTechNews: true,
|
|
128
|
+
cronWeather: true,
|
|
129
|
+
timezone: 'America/New_York',
|
|
130
|
+
weatherLocation: 'Austin, TX',
|
|
131
|
+
skillCodeReview: true,
|
|
132
|
+
skillDailyNotes: true,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
expect(config.cron.jobs).toHaveLength(2);
|
|
136
|
+
expect(config.cron.jobs[0].id).toBe('tech-digest');
|
|
137
|
+
expect(config.cron.jobs[1].id).toBe('weather');
|
|
138
|
+
expect(config.cron.jobs[1].schedule.tz).toBe('America/New_York');
|
|
139
|
+
expect(config.cron.jobs[1].payload.message).toContain('Austin, TX');
|
|
140
|
+
expect(config.skills.enabled).toBe(true);
|
|
141
|
+
expect(config.skills.entries['code-review']).toBe(true);
|
|
142
|
+
expect(config.skills.entries['daily-notes']).toBe(true);
|
|
143
|
+
});
|
|
118
144
|
});
|
|
@@ -313,21 +313,12 @@ describe('formatSkillsPrompt', () => {
|
|
|
313
313
|
expect(result).toContain('### a');
|
|
314
314
|
expect(result).toContain('### b');
|
|
315
315
|
});
|
|
316
|
-
it('
|
|
317
|
-
// With maxTokens=10 (40 chars), only header + maybe first tiny skill fits
|
|
318
|
-
const result = formatSkillsPrompt([
|
|
319
|
-
makeSkill('big', 'x'.repeat(200)),
|
|
320
|
-
], 10);
|
|
321
|
-
// The header alone is ~18 chars, the skill section would be ~210 chars
|
|
322
|
-
// Total exceeds 40 chars budget, so skill gets skipped
|
|
323
|
-
expect(result).toBe('');
|
|
324
|
-
});
|
|
325
|
-
it('includes skills that fit within budget', () => {
|
|
316
|
+
it('does not skip skills based on prompt budget argument', () => {
|
|
326
317
|
const result = formatSkillsPrompt([
|
|
327
318
|
makeSkill('small', 'tiny'),
|
|
328
319
|
makeSkill('big', 'x'.repeat(50000)),
|
|
329
320
|
], 100);
|
|
330
321
|
expect(result).toContain('### small');
|
|
331
|
-
expect(result).
|
|
322
|
+
expect(result).toContain('### big');
|
|
332
323
|
});
|
|
333
324
|
});
|
|
@@ -180,9 +180,14 @@ describe('bash', () => {
|
|
|
180
180
|
expect(result).toContain('Error: Working directory not in allowed paths');
|
|
181
181
|
});
|
|
182
182
|
it('returns stderr on failure', async () => {
|
|
183
|
-
const
|
|
183
|
+
const nonexistent = join(TEST_DIR, 'nonexistent_file_xyz');
|
|
184
|
+
const result = await executeTool('Bash', { command: `cat "${nonexistent}"` }, toolConfig);
|
|
184
185
|
expect(result).toContain('No such file');
|
|
185
186
|
});
|
|
187
|
+
it('blocks commands referencing paths outside allowed dirs', async () => {
|
|
188
|
+
const result = await executeTool('Bash', { command: 'cat /etc/passwd' }, toolConfig);
|
|
189
|
+
expect(result).toContain('Error: Command references paths outside allowed directories');
|
|
190
|
+
});
|
|
186
191
|
it('handles unknown tools', async () => {
|
|
187
192
|
const result = await executeTool('delete_everything', {}, toolConfig);
|
|
188
193
|
expect(result).toContain('Error: Unknown tool');
|
package/dist/agent.js
CHANGED
|
@@ -153,6 +153,9 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
|
|
|
153
153
|
channelTargetId,
|
|
154
154
|
approverUserId: context?.userId,
|
|
155
155
|
approverUsername: context?.metadata?.username,
|
|
156
|
+
sandboxConfig: config.sandbox,
|
|
157
|
+
sessionId: context?.sessionId || String(chatIdNum ?? 'default'),
|
|
158
|
+
isCronJob: context?.metadata?.isCronJob === true,
|
|
156
159
|
};
|
|
157
160
|
const runTurn = async () => {
|
|
158
161
|
if (toolConfig?.enabled) {
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Dashboard API endpoints
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, rmSync } from 'fs';
|
|
3
|
+
import { timingSafeEqual } from 'crypto';
|
|
3
4
|
import { join, basename, resolve } from 'path';
|
|
4
5
|
import { homedir } from 'os';
|
|
5
6
|
import { loadConfig, loadRawConfig, saveConfig, getSessionsDir, getLogsDir, getAgentDir, listMemoryFiles, readMemoryFile, } from './config.js';
|
|
@@ -102,7 +103,10 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
102
103
|
return reply.code(401).send({ error: 'Unauthorized: Bearer token required' });
|
|
103
104
|
}
|
|
104
105
|
const providedToken = authHeader.slice(7);
|
|
105
|
-
|
|
106
|
+
// Timing-safe comparison to prevent token extraction via timing attacks
|
|
107
|
+
const tokenBuf = Buffer.from(token, 'utf8');
|
|
108
|
+
const providedBuf = Buffer.from(providedToken, 'utf8');
|
|
109
|
+
if (tokenBuf.length !== providedBuf.length || !timingSafeEqual(tokenBuf, providedBuf)) {
|
|
106
110
|
return reply.code(401).send({ error: 'Unauthorized: Invalid token' });
|
|
107
111
|
}
|
|
108
112
|
});
|
|
@@ -114,8 +114,8 @@ export function getDefaultTelegramToolConfig(cfg) {
|
|
|
114
114
|
}
|
|
115
115
|
return {
|
|
116
116
|
enabled: true,
|
|
117
|
-
allowedPaths: [join(homedir(), '.skimpyclaw')
|
|
118
|
-
maxIterations:
|
|
117
|
+
allowedPaths: [join(homedir(), '.skimpyclaw')],
|
|
118
|
+
maxIterations: 30,
|
|
119
119
|
bashTimeout: 15000,
|
|
120
120
|
};
|
|
121
121
|
}
|