skimpyclaw 0.1.8 → 0.2.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__/doctor.runner.test.js +5 -1
- package/dist/__tests__/heartbeat.test.js +30 -3
- 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 +140 -0
- package/dist/__tests__/setup.test.js +32 -3
- package/dist/__tests__/skills.test.js +2 -11
- package/dist/__tests__/tools.test.js +6 -1
- package/dist/__tests__/voice.test.js +12 -0
- package/dist/agent.js +2 -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.js +16 -2
- 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 +7 -3
- package/dist/providers/openai.js +1 -1
- 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 +33 -0
- package/dist/sandbox/runtime.js +167 -0
- package/dist/service.js +17 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +336 -23
- 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 +4 -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 +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
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, } 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('cleanupOrphans', () => {
|
|
121
|
+
it('lists containers, filters by prefix, removes matches', async () => {
|
|
122
|
+
let callCount = 0;
|
|
123
|
+
mockSpawn.mockImplementation(() => {
|
|
124
|
+
callCount++;
|
|
125
|
+
if (callCount === 1) {
|
|
126
|
+
// ps call
|
|
127
|
+
return fakeChild(0, 'skimpyclaw-sbx-abc\nother-ctr\nskimpyclaw-sbx-def\n');
|
|
128
|
+
}
|
|
129
|
+
// stop/rm calls
|
|
130
|
+
return fakeChild(0);
|
|
131
|
+
});
|
|
132
|
+
const count = await cleanupOrphans();
|
|
133
|
+
expect(count).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
it('returns 0 on ps failure', async () => {
|
|
136
|
+
mockSpawn.mockImplementation(() => fakeChild(1));
|
|
137
|
+
expect(await cleanupOrphans()).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -15,7 +15,10 @@ describe('setup config generation', () => {
|
|
|
15
15
|
expect(config.models.providers.anthropic.apiKey).toBe('${ANTHROPIC_API_KEY}');
|
|
16
16
|
expect(config.models.providers.codex.authPath).toBe('${HOME}/.codex/auth.json');
|
|
17
17
|
expect(config.channels.telegram.allowFrom).toEqual([12345]);
|
|
18
|
-
expect(config.channels.telegram.
|
|
18
|
+
expect(config.channels.telegram.dailyNotesDir).toBe('${HOME}/.skimpyclaw/Daily Notes');
|
|
19
|
+
expect(config.channels.telegram.defaultAllowedPaths).toEqual(['${HOME}/.skimpyclaw']);
|
|
20
|
+
expect(config.channels.discord.defaultAllowedPaths).toEqual(['${HOME}/.skimpyclaw']);
|
|
21
|
+
expect(config.heartbeat.tools.allowedPaths).toEqual(['${HOME}/.skimpyclaw']);
|
|
19
22
|
expect(config.models.aliases['claude-think']).toBe('anthropic/claude-sonnet-4-6');
|
|
20
23
|
expect(config.models.aliases['claude-opus']).toBe('anthropic/claude-opus-4-6');
|
|
21
24
|
expect(config.models.aliases.codex).toBe('codex/gpt-5.3-codex');
|
|
@@ -76,7 +79,7 @@ describe('setup config generation', () => {
|
|
|
76
79
|
agentName: 'Claw',
|
|
77
80
|
selectedProviders: new Set(['anthropic-api']),
|
|
78
81
|
providerSecrets: { anthropicKey: 'sk-ant-test' },
|
|
79
|
-
features: { browser: false, voice: false, mcp: false },
|
|
82
|
+
features: { browser: false, voice: false, mcp: false, sandbox: false },
|
|
80
83
|
});
|
|
81
84
|
expect(config.heartbeat.tools.browser.enabled).toBe(false);
|
|
82
85
|
expect(config.voice).toBeUndefined();
|
|
@@ -89,7 +92,7 @@ describe('setup config generation', () => {
|
|
|
89
92
|
agentName: 'Claw',
|
|
90
93
|
selectedProviders: new Set(['anthropic-api']),
|
|
91
94
|
providerSecrets: { anthropicKey: 'sk-ant-test' },
|
|
92
|
-
features: { browser: true, voice: true, mcp: false },
|
|
95
|
+
features: { browser: true, voice: true, mcp: false, sandbox: false },
|
|
93
96
|
});
|
|
94
97
|
expect(config.heartbeat.tools.browser.enabled).toBe(true);
|
|
95
98
|
expect(config.voice).toBeDefined();
|
|
@@ -112,4 +115,30 @@ describe('setup config generation', () => {
|
|
|
112
115
|
// parseInt('not-a-number') is NaN, so || falls through to string
|
|
113
116
|
expect(config.channels.telegram.allowFrom).toEqual(['not-a-number']);
|
|
114
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('starter-tech-news-hn');
|
|
137
|
+
expect(config.cron.jobs[1].id).toBe('starter-weather-7am');
|
|
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
|
+
});
|
|
115
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');
|
|
@@ -240,3 +240,15 @@ describe('checkVoiceDependencies', () => {
|
|
|
240
240
|
expect(result.missing[0]).toContain('No local whisper CLI and no API providers configured');
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
|
+
describe('transcription provider messaging', () => {
|
|
244
|
+
it('explains that macos is TTS-only when no STT provider exists', async () => {
|
|
245
|
+
const { transcribeAudio } = await import('../voice.js');
|
|
246
|
+
const config = {
|
|
247
|
+
...baseVoiceConfig,
|
|
248
|
+
providers: {
|
|
249
|
+
macos: { tts: { voice: 'Samantha' } },
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
await expect(transcribeAudio('/tmp/fake-audio.ogg', config)).rejects.toThrow('macos" is TTS-only');
|
|
253
|
+
});
|
|
254
|
+
});
|
package/dist/agent.js
CHANGED
|
@@ -153,6 +153,8 @@ 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'),
|
|
156
158
|
};
|
|
157
159
|
const runTurn = async () => {
|
|
158
160
|
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
|
}
|
package/dist/cli.js
CHANGED
|
@@ -43,6 +43,10 @@ Commands:
|
|
|
43
43
|
tools list List available tools (built-in + MCP)
|
|
44
44
|
tools install <name> Add MCP server (--command <cmd> [--args ...] or --url <url>)
|
|
45
45
|
tools remove <name> Remove MCP server
|
|
46
|
+
sandbox status Show active sandbox containers
|
|
47
|
+
sandbox prune Force-prune all sandbox containers
|
|
48
|
+
sandbox init Auto-setup sandbox runtime/image/config (supports --profile)
|
|
49
|
+
sandbox doctor Sandbox-specific diagnostics and hints
|
|
46
50
|
help Show this help
|
|
47
51
|
`);
|
|
48
52
|
}
|
|
@@ -724,6 +728,211 @@ async function commandTools(args) {
|
|
|
724
728
|
console.error('Usage: skimpyclaw tools <list|install|remove>');
|
|
725
729
|
return 1;
|
|
726
730
|
}
|
|
731
|
+
const SANDBOX_CLI_BY_PROFILE = {
|
|
732
|
+
minimal: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm'],
|
|
733
|
+
dev: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make'],
|
|
734
|
+
full: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make', 'pip3', 'sqlite3'],
|
|
735
|
+
};
|
|
736
|
+
function defaultSandboxNetwork(runtime) {
|
|
737
|
+
return runtime === 'container' ? 'default' : 'bridge';
|
|
738
|
+
}
|
|
739
|
+
function detectSandboxRuntime(preferred) {
|
|
740
|
+
if (preferred === 'container' || preferred === 'docker') {
|
|
741
|
+
return spawnSync(preferred, ['--version'], { encoding: 'utf-8' }).status === 0 ? preferred : null;
|
|
742
|
+
}
|
|
743
|
+
if (spawnSync('container', ['--version'], { encoding: 'utf-8' }).status === 0) {
|
|
744
|
+
return 'container';
|
|
745
|
+
}
|
|
746
|
+
if (spawnSync('docker', ['--version'], { encoding: 'utf-8' }).status === 0) {
|
|
747
|
+
return 'docker';
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
function isSandboxRuntimeRunning(runtime) {
|
|
752
|
+
if (runtime === 'container') {
|
|
753
|
+
return spawnSync('container', ['system', 'status'], { encoding: 'utf-8' }).status === 0;
|
|
754
|
+
}
|
|
755
|
+
return spawnSync('docker', ['info'], { encoding: 'utf-8' }).status === 0;
|
|
756
|
+
}
|
|
757
|
+
function sandboxNetworkExists(runtime, network) {
|
|
758
|
+
if (runtime === 'container') {
|
|
759
|
+
const result = spawnSync('container', ['network', 'ls'], { encoding: 'utf-8' });
|
|
760
|
+
if (result.status !== 0)
|
|
761
|
+
return false;
|
|
762
|
+
return result.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === network);
|
|
763
|
+
}
|
|
764
|
+
const result = spawnSync('docker', ['network', 'inspect', network], { encoding: 'utf-8' });
|
|
765
|
+
return result.status === 0;
|
|
766
|
+
}
|
|
767
|
+
function sandboxImageExists(runtime, image) {
|
|
768
|
+
return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
|
|
769
|
+
}
|
|
770
|
+
function resolveSandboxDir() {
|
|
771
|
+
const cwdSandbox = join(process.cwd(), 'sandbox');
|
|
772
|
+
if (existsSync(join(cwdSandbox, 'Dockerfile'))) {
|
|
773
|
+
return cwdSandbox;
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
function parseSandboxOption(args, flag) {
|
|
778
|
+
const idx = args.indexOf(flag);
|
|
779
|
+
if (idx === -1 || idx + 1 >= args.length)
|
|
780
|
+
return undefined;
|
|
781
|
+
return args[idx + 1];
|
|
782
|
+
}
|
|
783
|
+
function runSandboxImageCheck(runtime, image, network, cmd) {
|
|
784
|
+
const result = spawnSync(runtime, ['run', '--rm', '--network', network, image, 'sh', '-lc', cmd], { encoding: 'utf-8' });
|
|
785
|
+
if (result.status === 0) {
|
|
786
|
+
return { ok: true, detail: (result.stdout || '').trim() || 'ok' };
|
|
787
|
+
}
|
|
788
|
+
const detail = `${(result.stderr || '').trim()} ${(result.stdout || '').trim()}`.trim() || `exit ${result.status ?? 1}`;
|
|
789
|
+
return { ok: false, detail };
|
|
790
|
+
}
|
|
791
|
+
function printSandboxCheck(ok, name, detail, hint) {
|
|
792
|
+
const prefix = ok ? '✓' : '✗';
|
|
793
|
+
console.log(`${prefix} ${name}: ${detail}`);
|
|
794
|
+
if (!ok && hint) {
|
|
795
|
+
console.log(` → ${hint}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function commandSandbox(args) {
|
|
799
|
+
const sub = args[0];
|
|
800
|
+
if (sub === 'status') {
|
|
801
|
+
const rt = detectSandboxRuntime();
|
|
802
|
+
if (!rt) {
|
|
803
|
+
console.log('No container runtime found (install Docker or Apple Containers).');
|
|
804
|
+
return 1;
|
|
805
|
+
}
|
|
806
|
+
const result = spawnSync(rt, ['ps', '--format', '{{.Names}}'], { encoding: 'utf-8' });
|
|
807
|
+
const lines = (result.stdout || '').trim().split('\n').filter(Boolean);
|
|
808
|
+
const containers = lines.filter((line) => line.includes('skimpyclaw-sbx'));
|
|
809
|
+
if (containers.length === 0) {
|
|
810
|
+
console.log('No active sandbox containers.');
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
console.log(`Active sandbox containers (${containers.length}):`);
|
|
814
|
+
containers.forEach((c) => console.log(` ${c}`));
|
|
815
|
+
}
|
|
816
|
+
return 0;
|
|
817
|
+
}
|
|
818
|
+
if (sub === 'prune') {
|
|
819
|
+
const { cleanupOrphans } = await import('./sandbox/index.js');
|
|
820
|
+
const count = await cleanupOrphans();
|
|
821
|
+
console.log(`Pruned ${count} sandbox container(s).`);
|
|
822
|
+
return 0;
|
|
823
|
+
}
|
|
824
|
+
if (sub === 'init') {
|
|
825
|
+
const runtimeFlag = parseSandboxOption(args, '--runtime');
|
|
826
|
+
const profileFlag = parseSandboxOption(args, '--profile') || 'minimal';
|
|
827
|
+
const imageFlag = parseSandboxOption(args, '--image');
|
|
828
|
+
const networkFlag = parseSandboxOption(args, '--network');
|
|
829
|
+
const validProfiles = ['minimal', 'dev', 'full'];
|
|
830
|
+
if (!validProfiles.includes(profileFlag)) {
|
|
831
|
+
console.error(`Invalid profile "${profileFlag}". Use one of: minimal, dev, full`);
|
|
832
|
+
return 1;
|
|
833
|
+
}
|
|
834
|
+
const profile = profileFlag;
|
|
835
|
+
const runtime = detectSandboxRuntime(runtimeFlag);
|
|
836
|
+
if (!runtime) {
|
|
837
|
+
console.error('No supported runtime found. Install Apple Containers or Docker.');
|
|
838
|
+
return 1;
|
|
839
|
+
}
|
|
840
|
+
const network = networkFlag || defaultSandboxNetwork(runtime);
|
|
841
|
+
const image = imageFlag || 'skimpyclaw-sandbox:latest';
|
|
842
|
+
if (!isSandboxRuntimeRunning(runtime)) {
|
|
843
|
+
const hint = runtime === 'container' ? 'Run: container system start' : 'Start Docker Desktop (or run `docker info`).';
|
|
844
|
+
console.error(`Runtime "${runtime}" is not running.`);
|
|
845
|
+
console.error(hint);
|
|
846
|
+
return 1;
|
|
847
|
+
}
|
|
848
|
+
if (!sandboxNetworkExists(runtime, network)) {
|
|
849
|
+
const hint = runtime === 'container'
|
|
850
|
+
? 'Create/list networks with `container network ls`.'
|
|
851
|
+
: 'Create/list networks with `docker network ls`.';
|
|
852
|
+
console.error(`Sandbox network "${network}" not found for runtime "${runtime}".`);
|
|
853
|
+
console.error(hint);
|
|
854
|
+
return 1;
|
|
855
|
+
}
|
|
856
|
+
const sandboxDir = resolveSandboxDir();
|
|
857
|
+
if (!sandboxDir) {
|
|
858
|
+
console.error('Could not find sandbox/Dockerfile from current directory.');
|
|
859
|
+
console.error('Run from repo root (contains ./sandbox) or build image manually.');
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
862
|
+
console.log(`Building sandbox image "${image}" (runtime=${runtime}, profile=${profile})...`);
|
|
863
|
+
const build = spawnSync(runtime, ['build', '--build-arg', `SKIMPY_PROFILE=${profile}`, '-t', image, sandboxDir], { stdio: 'inherit' });
|
|
864
|
+
if (build.status !== 0) {
|
|
865
|
+
console.error('Sandbox image build failed.');
|
|
866
|
+
return 1;
|
|
867
|
+
}
|
|
868
|
+
const raw = loadRawConfig();
|
|
869
|
+
const sandbox = raw.sandbox ?? {};
|
|
870
|
+
sandbox.enabled = true;
|
|
871
|
+
sandbox.runtime = runtime;
|
|
872
|
+
sandbox.network = network;
|
|
873
|
+
sandbox.image = image;
|
|
874
|
+
raw.sandbox = sandbox;
|
|
875
|
+
saveConfig(raw);
|
|
876
|
+
console.log('Updated config: sandbox.enabled=true');
|
|
877
|
+
console.log(`Updated config: sandbox.runtime="${runtime}"`);
|
|
878
|
+
console.log(`Updated config: sandbox.network="${network}"`);
|
|
879
|
+
console.log(`Updated config: sandbox.image="${image}"`);
|
|
880
|
+
const required = SANDBOX_CLI_BY_PROFILE[profile];
|
|
881
|
+
const checkCmd = `for c in ${required.join(' ')}; do command -v "$c" >/dev/null || { echo "missing:$c"; exit 1; }; done; echo cli-ok`;
|
|
882
|
+
const cliCheck = runSandboxImageCheck(runtime, image, network, checkCmd);
|
|
883
|
+
const netCheck = runSandboxImageCheck(runtime, image, network, 'curl -fsS --max-time 8 https://example.com >/dev/null && echo net-ok');
|
|
884
|
+
const hostCheck = runSandboxImageCheck(runtime, image, network, 'hostname');
|
|
885
|
+
printSandboxCheck(hostCheck.ok, 'sandbox_hostname', hostCheck.detail);
|
|
886
|
+
printSandboxCheck(cliCheck.ok, 'sandbox_tools', cliCheck.detail, 'Rebuild image or choose a lighter profile.');
|
|
887
|
+
printSandboxCheck(netCheck.ok, 'sandbox_network_egress', netCheck.detail, 'Try a different sandbox.network or check runtime DNS/network settings.');
|
|
888
|
+
if (!hostCheck.ok || !cliCheck.ok || !netCheck.ok) {
|
|
889
|
+
return 1;
|
|
890
|
+
}
|
|
891
|
+
console.log('\nSandbox init complete. Restart Skimpy to apply runtime config.');
|
|
892
|
+
return 0;
|
|
893
|
+
}
|
|
894
|
+
if (sub === 'doctor') {
|
|
895
|
+
const config = loadConfig();
|
|
896
|
+
const runtime = detectSandboxRuntime(config.sandbox?.runtime);
|
|
897
|
+
const image = config.sandbox?.image || 'skimpyclaw-sandbox:latest';
|
|
898
|
+
const network = config.sandbox?.network || (runtime ? defaultSandboxNetwork(runtime) : 'unknown');
|
|
899
|
+
const profileFlag = parseSandboxOption(args, '--profile') || 'minimal';
|
|
900
|
+
const profile = (['minimal', 'dev', 'full'].includes(profileFlag) ? profileFlag : 'minimal');
|
|
901
|
+
let failed = false;
|
|
902
|
+
printSandboxCheck(config.sandbox?.enabled === true, 'sandbox_enabled', config.sandbox?.enabled ? 'enabled' : 'disabled', 'Run: skimpyclaw sandbox init');
|
|
903
|
+
if (!config.sandbox?.enabled)
|
|
904
|
+
failed = true;
|
|
905
|
+
printSandboxCheck(!!runtime, 'runtime_detected', runtime || 'none', 'Install Docker or Apple Containers.');
|
|
906
|
+
if (!runtime)
|
|
907
|
+
return 1;
|
|
908
|
+
printSandboxCheck(isSandboxRuntimeRunning(runtime), 'runtime_running', runtime, runtime === 'container' ? 'Run: container system start' : 'Start Docker Desktop.');
|
|
909
|
+
if (!isSandboxRuntimeRunning(runtime))
|
|
910
|
+
failed = true;
|
|
911
|
+
const networkOk = sandboxNetworkExists(runtime, network);
|
|
912
|
+
printSandboxCheck(networkOk, 'network_exists', network, `Use "${runtime === 'container' ? 'container' : 'docker'} network ls" and update sandbox.network.`);
|
|
913
|
+
if (!networkOk)
|
|
914
|
+
failed = true;
|
|
915
|
+
const imageOk = sandboxImageExists(runtime, image);
|
|
916
|
+
printSandboxCheck(imageOk, 'image_exists', image, `Build image: ${runtime} build -t ${image} sandbox/`);
|
|
917
|
+
if (!imageOk)
|
|
918
|
+
failed = true;
|
|
919
|
+
if (imageOk && networkOk) {
|
|
920
|
+
const required = SANDBOX_CLI_BY_PROFILE[profile];
|
|
921
|
+
const checkCmd = `for c in ${required.join(' ')}; do command -v "$c" >/dev/null || { echo "missing:$c"; exit 1; }; done; echo cli-ok`;
|
|
922
|
+
const cliCheck = runSandboxImageCheck(runtime, image, network, checkCmd);
|
|
923
|
+
printSandboxCheck(cliCheck.ok, 'image_toolchain', cliCheck.detail, 'Rebuild with: skimpyclaw sandbox init --profile dev');
|
|
924
|
+
if (!cliCheck.ok)
|
|
925
|
+
failed = true;
|
|
926
|
+
const netCheck = runSandboxImageCheck(runtime, image, network, 'curl -fsS --max-time 8 https://api.duckduckgo.com/?q=skimpyclaw&format=json >/dev/null && echo net-ok');
|
|
927
|
+
printSandboxCheck(netCheck.ok, 'network_egress', netCheck.detail, 'Some sources may timeout; verify DNS/network in runtime.');
|
|
928
|
+
if (!netCheck.ok)
|
|
929
|
+
failed = true;
|
|
930
|
+
}
|
|
931
|
+
return failed ? 1 : 0;
|
|
932
|
+
}
|
|
933
|
+
console.log('Usage: skimpyclaw sandbox <status|prune|init|doctor>');
|
|
934
|
+
return 1;
|
|
935
|
+
}
|
|
727
936
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
728
937
|
const [command, ...args] = argv;
|
|
729
938
|
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
@@ -787,6 +996,9 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
787
996
|
if (command === 'tools') {
|
|
788
997
|
return await commandTools(args);
|
|
789
998
|
}
|
|
999
|
+
if (command === 'sandbox') {
|
|
1000
|
+
return await commandSandbox(args);
|
|
1001
|
+
}
|
|
790
1002
|
console.error(`Unknown command: ${command}`);
|
|
791
1003
|
printHelp();
|
|
792
1004
|
return 1;
|
|
@@ -10,6 +10,7 @@ import { buildCodeAgentArgs, notifyCodeAgentResult } from './utils.js';
|
|
|
10
10
|
import { parseStreamJsonForLive, parseClaudeOutput, parseCodexOutput } from './parser.js';
|
|
11
11
|
import { startTrace, addEvent, endTrace } from '../audit.js';
|
|
12
12
|
import { buildUsageRecord, recordUsage } from '../usage.js';
|
|
13
|
+
import { ensureContainer, SANDBOX_DEFAULTS, getRuntime } from '../sandbox/index.js';
|
|
13
14
|
const CANCELLED_MESSAGE = 'Cancelled by user';
|
|
14
15
|
/** Run build/test validation. Shared by solo agents and team orchestrator. */
|
|
15
16
|
export function runValidation(workdir) {
|
|
@@ -87,6 +88,13 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
|
|
|
87
88
|
logStream.write(`Task: ${task.slice(0, 500)}\n`);
|
|
88
89
|
logStream.write(`Workdir: ${workdir}\n\n`);
|
|
89
90
|
try {
|
|
91
|
+
// Resolve sandbox container name if enabled (used for spawn wrapping)
|
|
92
|
+
let sandboxContainer;
|
|
93
|
+
if (options?.sandboxConfig?.enabled) {
|
|
94
|
+
const merged = { ...SANDBOX_DEFAULTS, ...options.sandboxConfig };
|
|
95
|
+
sandboxContainer = await ensureContainer(`code-${id}`, merged, options.allowedPaths || [workdir]);
|
|
96
|
+
console.log(`[code-agent] Running in sandbox container: ${sandboxContainer}`);
|
|
97
|
+
}
|
|
90
98
|
ensureNotCancelled();
|
|
91
99
|
const exitCode = await new Promise((resolvePromise, reject) => {
|
|
92
100
|
const spawnEnv = { ...process.env };
|
|
@@ -94,8 +102,11 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
|
|
|
94
102
|
// Apply extra env vars (e.g. team mode feature flag)
|
|
95
103
|
if (options?.env)
|
|
96
104
|
Object.assign(spawnEnv, options.env);
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
// When sandbox is enabled, wrap the spawn: container exec <name> <cmd> <args>
|
|
106
|
+
const spawnCmd = sandboxContainer ? getRuntime() : cmd;
|
|
107
|
+
const spawnArgs = sandboxContainer ? ['exec', sandboxContainer, cmd, ...args] : args;
|
|
108
|
+
const proc = spawn(spawnCmd, spawnArgs, {
|
|
109
|
+
cwd: sandboxContainer ? undefined : workdir, // container has its own cwd
|
|
99
110
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
111
|
env: spawnEnv,
|
|
101
112
|
});
|
|
@@ -277,8 +288,10 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
|
|
|
277
288
|
const retryExitCode = await new Promise((resolveRetry, rejectRetry) => {
|
|
278
289
|
const spawnEnv = { ...process.env };
|
|
279
290
|
delete spawnEnv.CLAUDECODE;
|
|
280
|
-
const
|
|
281
|
-
|
|
291
|
+
const retrySpawnCmd = sandboxContainer ? getRuntime() : retryCmd;
|
|
292
|
+
const retrySpawnArgs = sandboxContainer ? ['exec', sandboxContainer, retryCmd, ...retryArgs] : retryArgs;
|
|
293
|
+
const retryProc = spawn(retrySpawnCmd, retrySpawnArgs, {
|
|
294
|
+
cwd: sandboxContainer ? undefined : workdir,
|
|
282
295
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
283
296
|
env: spawnEnv,
|
|
284
297
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SandboxConfig } from '../types.js';
|
|
1
2
|
export interface CodeAgentTask {
|
|
2
3
|
id: string;
|
|
3
4
|
agent: string;
|
|
@@ -44,6 +45,10 @@ export interface CodeAgentBackgroundOptions {
|
|
|
44
45
|
maxTimeoutMinutes?: number;
|
|
45
46
|
/** Skip sending notification on completion (parent handles it) */
|
|
46
47
|
skipNotification?: boolean;
|
|
48
|
+
/** Sandbox configuration — when enabled, run CLI inside container */
|
|
49
|
+
sandboxConfig?: SandboxConfig;
|
|
50
|
+
/** Paths to mount into the sandbox container */
|
|
51
|
+
allowedPaths?: string[];
|
|
47
52
|
}
|
|
48
53
|
export interface BuildCodeAgentArgsInput {
|
|
49
54
|
task: string;
|
package/dist/cron.js
CHANGED
|
@@ -9,6 +9,7 @@ import { startTrace, addEvent, endTrace } from './audit.js';
|
|
|
9
9
|
import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
|
|
10
10
|
import { parseAndSaveDigest } from './digests.js';
|
|
11
11
|
import { synthesizeSpeech } from './voice.js';
|
|
12
|
+
import { ensureContainer, SANDBOX_DEFAULTS, sandboxBash } from './sandbox/index.js';
|
|
12
13
|
const scheduledJobs = new Map();
|
|
13
14
|
let configWatcher = null;
|
|
14
15
|
function getCronLogDir() {
|
|
@@ -189,7 +190,7 @@ async function executeJobPayload(jobDef, config) {
|
|
|
189
190
|
});
|
|
190
191
|
appendCronLogLine(jobDef.id, `Script started: ${(jobDef.payload.script || '').slice(0, 100)}`);
|
|
191
192
|
try {
|
|
192
|
-
const output = await executeScript(jobDef);
|
|
193
|
+
const output = await executeScript(jobDef, config);
|
|
193
194
|
logEntry.output = output.slice(0, 50000);
|
|
194
195
|
appendCronLogLine(jobDef.id, `Script completed (${output.length} chars)`);
|
|
195
196
|
addEvent(scriptTraceId, {
|
|
@@ -248,7 +249,7 @@ async function executeJobPayload(jobDef, config) {
|
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
}
|
|
251
|
-
async function executeScript(jobDef) {
|
|
252
|
+
async function executeScript(jobDef, config) {
|
|
252
253
|
const script = expandVariables(jobDef.payload.script || '');
|
|
253
254
|
if (!script) {
|
|
254
255
|
throw new Error(`Script payload is empty for job: ${jobDef.id}`);
|
|
@@ -258,6 +259,19 @@ async function executeScript(jobDef) {
|
|
|
258
259
|
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
259
260
|
}
|
|
260
261
|
const timeoutMs = jobDef.payload.timeoutMs || 600000; // 10 min default
|
|
262
|
+
// Sandbox routing for script payloads
|
|
263
|
+
const sandboxCfg = config.sandbox;
|
|
264
|
+
if (sandboxCfg?.enabled) {
|
|
265
|
+
const merged = { ...SANDBOX_DEFAULTS, ...sandboxCfg };
|
|
266
|
+
const containerName = await ensureContainer(`cron-${jobDef.id}`, merged, jobDef.payload.tools?.allowedPaths || []);
|
|
267
|
+
console.log(`[cron:script] Running in sandbox container: ${containerName}`);
|
|
268
|
+
console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
|
|
269
|
+
if (cwd)
|
|
270
|
+
console.log(`[cron:script] cwd: ${cwd}`);
|
|
271
|
+
const output = await sandboxBash(containerName, script, cwd, timeoutMs);
|
|
272
|
+
console.log(`[cron:script] Sandbox completed (${output.length} chars)`);
|
|
273
|
+
return output;
|
|
274
|
+
}
|
|
261
275
|
return new Promise((resolve, reject) => {
|
|
262
276
|
const startTime = Date.now();
|
|
263
277
|
console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
|
package/dist/discord.js
CHANGED
|
@@ -47,8 +47,8 @@ const chatHistory = new Map();
|
|
|
47
47
|
const loadedFromDisk = new Set();
|
|
48
48
|
const DEFAULT_DISCORD_TOOLS = {
|
|
49
49
|
enabled: true,
|
|
50
|
-
allowedPaths: [join(homedir(), '.skimpyclaw')
|
|
51
|
-
maxIterations:
|
|
50
|
+
allowedPaths: [join(homedir(), '.skimpyclaw')],
|
|
51
|
+
maxIterations: 30,
|
|
52
52
|
bashTimeout: 15000,
|
|
53
53
|
};
|
|
54
54
|
let client = null;
|