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
package/dist/providers/openai.js
CHANGED
|
@@ -33,7 +33,7 @@ function recordOpenAIUsage(params) {
|
|
|
33
33
|
let inputTokens = typeof usage?.prompt_tokens === 'number'
|
|
34
34
|
? usage.prompt_tokens
|
|
35
35
|
: (typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0);
|
|
36
|
-
|
|
36
|
+
const outputTokens = typeof usage?.completion_tokens === 'number'
|
|
37
37
|
? usage.completion_tokens
|
|
38
38
|
: (typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0);
|
|
39
39
|
// Some OpenAI-compatible providers only return total_tokens.
|
|
@@ -121,7 +121,7 @@ export async function chatWithToolsOpenAI(params, provider) {
|
|
|
121
121
|
const modelId = stripProvider(options.model, openaiClients);
|
|
122
122
|
const maxIterations = toolConfig.maxIterations || 20;
|
|
123
123
|
// Resolve tools once at start
|
|
124
|
-
const includeSpawn = !!(toolContext?.
|
|
124
|
+
const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
|
|
125
125
|
const toolDefs = await getToolDefinitions(toolConfig, {
|
|
126
126
|
includeSpawnSubagent: includeSpawn,
|
|
127
127
|
includeMcp: false,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function sandboxBash(containerName: string, command: string, cwd?: string, timeout?: number): Promise<string>;
|
|
2
|
+
export declare function sandboxReadFile(containerName: string, path: string): Promise<string>;
|
|
3
|
+
export declare function sandboxWriteFile(containerName: string, path: string, content: string): Promise<string>;
|
|
4
|
+
export declare function sandboxListDir(containerName: string, path: string): Promise<string>;
|
|
5
|
+
export declare function sandboxGlob(containerName: string, base: string, pattern: string): Promise<string>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execInContainer } from './runtime.js';
|
|
2
|
+
const MAX_BASH_OUTPUT = 50 * 1024; // 50KB
|
|
3
|
+
const MAX_READ_OUTPUT = 100 * 1024; // 100KB
|
|
4
|
+
function truncate(s, maxBytes) {
|
|
5
|
+
if (Buffer.byteLength(s) <= maxBytes)
|
|
6
|
+
return s;
|
|
7
|
+
const truncated = Buffer.from(s).subarray(0, maxBytes).toString('utf-8');
|
|
8
|
+
return truncated + '\n... (output truncated)';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Shell-escape a string for use inside sh -c '...'
|
|
12
|
+
* NOTE: execInContainer already wraps in sh -c, so callers pass
|
|
13
|
+
* args as individual tokens. The runtime joins them with spaces
|
|
14
|
+
* and does basic single-quote escaping. For bash commands we pass
|
|
15
|
+
* the whole command as a single arg string.
|
|
16
|
+
*/
|
|
17
|
+
function shellEscape(s) {
|
|
18
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
19
|
+
}
|
|
20
|
+
export async function sandboxBash(containerName, command, cwd, timeout) {
|
|
21
|
+
// Pass the full command as a single string so sh -c runs it verbatim
|
|
22
|
+
const shellCmd = cwd
|
|
23
|
+
? `cd ${shellEscape(cwd)} && ${command}`
|
|
24
|
+
: command;
|
|
25
|
+
const result = await execInContainer(containerName, [shellCmd], { timeout: timeout ?? 30_000 });
|
|
26
|
+
let output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
27
|
+
output = truncate(output, MAX_BASH_OUTPUT);
|
|
28
|
+
if (result.exitCode !== 0) {
|
|
29
|
+
output += `\n[exit code: ${result.exitCode}]`;
|
|
30
|
+
}
|
|
31
|
+
return output || '(no output)';
|
|
32
|
+
}
|
|
33
|
+
export async function sandboxReadFile(containerName, path) {
|
|
34
|
+
// Pass as a single command string to avoid double-escaping
|
|
35
|
+
const result = await execInContainer(containerName, [`cat -- ${shellEscape(path)}`]);
|
|
36
|
+
if (result.exitCode !== 0) {
|
|
37
|
+
throw new Error(`Failed to read ${path}: ${result.stderr}`);
|
|
38
|
+
}
|
|
39
|
+
return truncate(result.stdout, MAX_READ_OUTPUT);
|
|
40
|
+
}
|
|
41
|
+
export async function sandboxWriteFile(containerName, path, content) {
|
|
42
|
+
// mkdir -p for parent dir, then write via stdin
|
|
43
|
+
const result = await execInContainer(containerName, [`mkdir -p $(dirname ${shellEscape(path)}) && cat > ${shellEscape(path)}`], { stdin: content });
|
|
44
|
+
if (result.exitCode !== 0) {
|
|
45
|
+
throw new Error(`Failed to write ${path}: ${result.stderr}`);
|
|
46
|
+
}
|
|
47
|
+
const bytes = Buffer.byteLength(content);
|
|
48
|
+
return `Written: ${path} (${bytes} bytes)`;
|
|
49
|
+
}
|
|
50
|
+
export async function sandboxListDir(containerName, path) {
|
|
51
|
+
const result = await execInContainer(containerName, [`ls -la ${shellEscape(path)}`]);
|
|
52
|
+
if (result.exitCode !== 0) {
|
|
53
|
+
throw new Error(`Failed to list ${path}: ${result.stderr}`);
|
|
54
|
+
}
|
|
55
|
+
return result.stdout;
|
|
56
|
+
}
|
|
57
|
+
export async function sandboxGlob(containerName, base, pattern) {
|
|
58
|
+
const result = await execInContainer(containerName, [`find ${shellEscape(base)} -name ${shellEscape(pattern)} -maxdepth 10 -type f`]);
|
|
59
|
+
if (result.exitCode !== 0) {
|
|
60
|
+
throw new Error(`Failed to glob ${base}/${pattern}: ${result.stderr}`);
|
|
61
|
+
}
|
|
62
|
+
return result.stdout;
|
|
63
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
|
|
2
|
+
export type { ContainerOpts, ExecOpts, ExecResult } from './runtime.js';
|
|
3
|
+
export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
|
|
4
|
+
export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
|
|
5
|
+
export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
|
|
2
|
+
export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
|
|
3
|
+
export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
|
|
4
|
+
export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SandboxConfig } from '../types.js';
|
|
2
|
+
export declare const SANDBOX_DEFAULTS: SandboxConfig;
|
|
3
|
+
export declare function ensureContainer(sessionId: string, config: SandboxConfig, allowedPaths: string[]): Promise<string>;
|
|
4
|
+
export declare function releaseContainer(sessionId: string): Promise<void>;
|
|
5
|
+
export declare function pruneIdle(maxIdleMs: number): Promise<number>;
|
|
6
|
+
export declare function releaseAll(): Promise<void>;
|
|
7
|
+
export declare function resetForTesting(): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createContainer, removeContainer, isContainerRunning } from './runtime.js';
|
|
2
|
+
import { validateMountPaths } from './mount-security.js';
|
|
3
|
+
export const SANDBOX_DEFAULTS = {
|
|
4
|
+
enabled: false,
|
|
5
|
+
image: 'skimpyclaw-sandbox',
|
|
6
|
+
cpus: 2,
|
|
7
|
+
memory: '2G',
|
|
8
|
+
network: 'bridge',
|
|
9
|
+
idleTimeoutMs: 3_600_000,
|
|
10
|
+
};
|
|
11
|
+
const activeContainers = new Map();
|
|
12
|
+
export async function ensureContainer(sessionId, config, allowedPaths) {
|
|
13
|
+
const name = `skimpyclaw-sbx-${sessionId}`;
|
|
14
|
+
const existing = activeContainers.get(sessionId);
|
|
15
|
+
if (existing) {
|
|
16
|
+
const running = await isContainerRunning(existing.name);
|
|
17
|
+
if (running) {
|
|
18
|
+
existing.lastUsed = Date.now();
|
|
19
|
+
return existing.name;
|
|
20
|
+
}
|
|
21
|
+
// Container died — remove from map, recreate
|
|
22
|
+
activeContainers.delete(sessionId);
|
|
23
|
+
}
|
|
24
|
+
// Process restarts clear in-memory state; adopt or clean an existing named
|
|
25
|
+
// container so a duplicate-name create does not fail.
|
|
26
|
+
const alreadyRunning = await isContainerRunning(name);
|
|
27
|
+
if (alreadyRunning) {
|
|
28
|
+
activeContainers.set(sessionId, {
|
|
29
|
+
name,
|
|
30
|
+
sessionId,
|
|
31
|
+
lastUsed: Date.now(),
|
|
32
|
+
});
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
// Best effort cleanup for stopped or half-created containers with same name.
|
|
36
|
+
await removeContainer(name);
|
|
37
|
+
const mounts = validateMountPaths(allowedPaths);
|
|
38
|
+
const uid = process.getuid?.() ?? 501;
|
|
39
|
+
const gid = process.getgid?.() ?? 20;
|
|
40
|
+
const merged = { ...SANDBOX_DEFAULTS, ...config };
|
|
41
|
+
const opts = {
|
|
42
|
+
image: merged.image,
|
|
43
|
+
cpus: merged.cpus,
|
|
44
|
+
memory: merged.memory,
|
|
45
|
+
network: merged.network,
|
|
46
|
+
mounts: mounts.map((m) => ({
|
|
47
|
+
host: m.host,
|
|
48
|
+
container: m.container,
|
|
49
|
+
readOnly: m.readOnly,
|
|
50
|
+
})),
|
|
51
|
+
user: `${uid}:${gid}`,
|
|
52
|
+
};
|
|
53
|
+
await createContainer(name, opts);
|
|
54
|
+
activeContainers.set(sessionId, {
|
|
55
|
+
name,
|
|
56
|
+
sessionId,
|
|
57
|
+
lastUsed: Date.now(),
|
|
58
|
+
});
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
export async function releaseContainer(sessionId) {
|
|
62
|
+
const entry = activeContainers.get(sessionId);
|
|
63
|
+
if (!entry)
|
|
64
|
+
return;
|
|
65
|
+
activeContainers.delete(sessionId);
|
|
66
|
+
await removeContainer(entry.name);
|
|
67
|
+
}
|
|
68
|
+
export async function pruneIdle(maxIdleMs) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
let pruned = 0;
|
|
71
|
+
for (const [sessionId, entry] of activeContainers) {
|
|
72
|
+
if (now - entry.lastUsed > maxIdleMs) {
|
|
73
|
+
activeContainers.delete(sessionId);
|
|
74
|
+
await removeContainer(entry.name);
|
|
75
|
+
pruned++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return pruned;
|
|
79
|
+
}
|
|
80
|
+
export async function releaseAll() {
|
|
81
|
+
const entries = Array.from(activeContainers.values());
|
|
82
|
+
activeContainers.clear();
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
await removeContainer(entry.name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function resetForTesting() {
|
|
88
|
+
activeContainers.clear();
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface MountSpec {
|
|
2
|
+
host: string;
|
|
3
|
+
container: string;
|
|
4
|
+
readOnly: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function isBlockedPath(resolvedPath: string): boolean;
|
|
7
|
+
export declare function validateMountPaths(allowedPaths: string[]): MountSpec[];
|
|
8
|
+
/**
|
|
9
|
+
* Translate a host path to its container equivalent using mount specs.
|
|
10
|
+
* Returns the original path if no mount matches (will likely fail inside container).
|
|
11
|
+
*/
|
|
12
|
+
export declare function translatePath(hostPath: string, mounts: MountSpec[]): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { realpathSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve, basename } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
const BLOCKED_PATTERNS = [
|
|
5
|
+
'.ssh',
|
|
6
|
+
'.gnupg',
|
|
7
|
+
'.gpg',
|
|
8
|
+
'.aws',
|
|
9
|
+
'.azure',
|
|
10
|
+
'.gcloud',
|
|
11
|
+
'credentials',
|
|
12
|
+
'.env',
|
|
13
|
+
'.npmrc',
|
|
14
|
+
'.pypirc',
|
|
15
|
+
'.docker/config.json',
|
|
16
|
+
'.kube',
|
|
17
|
+
];
|
|
18
|
+
export function isBlockedPath(resolvedPath) {
|
|
19
|
+
const segments = resolvedPath.split('/');
|
|
20
|
+
for (const segment of segments) {
|
|
21
|
+
if (BLOCKED_PATTERNS.includes(segment)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Also check for compound patterns like .docker/config.json
|
|
26
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
27
|
+
if (pattern.includes('/') && resolvedPath.includes(pattern)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
export function validateMountPaths(allowedPaths) {
|
|
34
|
+
const home = homedir();
|
|
35
|
+
const mounts = [];
|
|
36
|
+
const usedBaseNames = new Map();
|
|
37
|
+
for (const rawPath of allowedPaths) {
|
|
38
|
+
const expanded = rawPath.startsWith('~')
|
|
39
|
+
? resolve(home, rawPath.slice(2))
|
|
40
|
+
: resolve(rawPath);
|
|
41
|
+
let resolved;
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(expanded)) {
|
|
44
|
+
continue; // Skip missing paths
|
|
45
|
+
}
|
|
46
|
+
resolved = realpathSync(expanded);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue; // Skip paths that can't be resolved
|
|
50
|
+
}
|
|
51
|
+
if (isBlockedPath(resolved)) {
|
|
52
|
+
throw new Error(`Blocked path: ${resolved} matches security exclusion pattern`);
|
|
53
|
+
}
|
|
54
|
+
let base = basename(resolved);
|
|
55
|
+
const count = usedBaseNames.get(base) ?? 0;
|
|
56
|
+
usedBaseNames.set(base, count + 1);
|
|
57
|
+
if (count > 0) {
|
|
58
|
+
base = `${base}_${count}`;
|
|
59
|
+
}
|
|
60
|
+
mounts.push({
|
|
61
|
+
host: resolved,
|
|
62
|
+
container: `/workspace/${base}`,
|
|
63
|
+
readOnly: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Always add ~/.skimpyclaw at /workspace/config
|
|
67
|
+
const skimpyclawDir = resolve(home, '.skimpyclaw');
|
|
68
|
+
if (existsSync(skimpyclawDir)) {
|
|
69
|
+
const resolved = realpathSync(skimpyclawDir);
|
|
70
|
+
// Only add if not already included
|
|
71
|
+
if (!mounts.some((m) => m.host === resolved)) {
|
|
72
|
+
mounts.push({
|
|
73
|
+
host: resolved,
|
|
74
|
+
container: '/workspace/config',
|
|
75
|
+
readOnly: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return mounts;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Translate a host path to its container equivalent using mount specs.
|
|
83
|
+
* Returns the original path if no mount matches (will likely fail inside container).
|
|
84
|
+
*/
|
|
85
|
+
export function translatePath(hostPath, mounts) {
|
|
86
|
+
const candidates = getPathCandidates(hostPath);
|
|
87
|
+
// Sort by host path length descending so we match the most specific mount first
|
|
88
|
+
const sorted = [...mounts].sort((a, b) => b.host.length - a.host.length);
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
for (const mount of sorted) {
|
|
91
|
+
if (candidate === mount.host || candidate.startsWith(mount.host + '/')) {
|
|
92
|
+
const relative = candidate.slice(mount.host.length);
|
|
93
|
+
return mount.container + relative;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return hostPath;
|
|
98
|
+
}
|
|
99
|
+
function getPathCandidates(hostPath) {
|
|
100
|
+
const set = new Set();
|
|
101
|
+
const resolved = resolve(hostPath);
|
|
102
|
+
set.add(resolved);
|
|
103
|
+
try {
|
|
104
|
+
set.add(realpathSync(resolved));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Path may not exist yet (e.g. write targets); keep resolved form.
|
|
108
|
+
}
|
|
109
|
+
for (const p of Array.from(set)) {
|
|
110
|
+
if (p.startsWith('/Users/')) {
|
|
111
|
+
set.add(`/System/Volumes/Data${p}`);
|
|
112
|
+
}
|
|
113
|
+
else if (p.startsWith('/System/Volumes/Data/Users/')) {
|
|
114
|
+
set.add(p.replace('/System/Volumes/Data', ''));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Array.from(set);
|
|
118
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ContainerOpts {
|
|
2
|
+
image: string;
|
|
3
|
+
cpus?: number;
|
|
4
|
+
memory?: string;
|
|
5
|
+
network?: string;
|
|
6
|
+
mounts?: Array<{
|
|
7
|
+
host: string;
|
|
8
|
+
container: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
user?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ExecOpts {
|
|
14
|
+
stdin?: string;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
export interface ExecResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
/** Set the container runtime binary ('container' or 'docker'). */
|
|
24
|
+
export declare function setRuntime(runtime: 'container' | 'docker'): void;
|
|
25
|
+
/** Get the container runtime binary, auto-detecting if not explicitly set. */
|
|
26
|
+
export declare function getRuntime(): string;
|
|
27
|
+
/**
|
|
28
|
+
* Check if a usable container runtime is available.
|
|
29
|
+
* Returns the runtime name if found, null otherwise. Never throws.
|
|
30
|
+
*/
|
|
31
|
+
export declare function probeRuntime(preferred?: string): string | null;
|
|
32
|
+
/** Reset runtime detection (for testing). */
|
|
33
|
+
export declare function resetRuntime(): void;
|
|
34
|
+
export declare function createContainer(name: string, opts: ContainerOpts): Promise<void>;
|
|
35
|
+
export declare function execInContainer(name: string, args: string[], opts?: ExecOpts): Promise<ExecResult>;
|
|
36
|
+
export declare function removeContainer(name: string): Promise<void>;
|
|
37
|
+
export declare function isContainerRunning(name: string): Promise<boolean>;
|
|
38
|
+
export declare function cleanupOrphans(): Promise<number>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
3
|
+
const CONTAINER_PREFIX = 'skimpyclaw-sbx-';
|
|
4
|
+
/** Resolved container CLI binary. Set once via setRuntime() or auto-detected on first use. */
|
|
5
|
+
let runtimeBinary = null;
|
|
6
|
+
/** Set the container runtime binary ('container' or 'docker'). */
|
|
7
|
+
export function setRuntime(runtime) {
|
|
8
|
+
runtimeBinary = runtime;
|
|
9
|
+
}
|
|
10
|
+
/** Get the container runtime binary, auto-detecting if not explicitly set. */
|
|
11
|
+
export function getRuntime() {
|
|
12
|
+
if (runtimeBinary)
|
|
13
|
+
return runtimeBinary;
|
|
14
|
+
// Auto-detect: prefer Apple Containers, fall back to Docker
|
|
15
|
+
if (spawnSync('container', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
16
|
+
runtimeBinary = 'container';
|
|
17
|
+
}
|
|
18
|
+
else if (spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
19
|
+
runtimeBinary = 'docker';
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
throw new Error('No container runtime found. Install Apple Containers or Docker.');
|
|
23
|
+
}
|
|
24
|
+
return runtimeBinary;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if a usable container runtime is available.
|
|
28
|
+
* Returns the runtime name if found, null otherwise. Never throws.
|
|
29
|
+
*/
|
|
30
|
+
export function probeRuntime(preferred) {
|
|
31
|
+
// If a preferred runtime is specified, check that one first
|
|
32
|
+
if (preferred) {
|
|
33
|
+
const result = spawnSync(preferred, ['--version'], { stdio: 'ignore' });
|
|
34
|
+
if (result.status === 0)
|
|
35
|
+
return preferred;
|
|
36
|
+
}
|
|
37
|
+
// Auto-detect: prefer Apple Containers, fall back to Docker
|
|
38
|
+
if (spawnSync('container', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
39
|
+
return 'container';
|
|
40
|
+
}
|
|
41
|
+
if (spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
42
|
+
return 'docker';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/** Reset runtime detection (for testing). */
|
|
47
|
+
export function resetRuntime() {
|
|
48
|
+
runtimeBinary = null;
|
|
49
|
+
}
|
|
50
|
+
function runCommand(cmd, args, opts) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
53
|
+
const child = spawn(cmd, args, {
|
|
54
|
+
stdio: [opts?.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
55
|
+
signal: AbortSignal.timeout(timeout),
|
|
56
|
+
});
|
|
57
|
+
const stdoutChunks = [];
|
|
58
|
+
const stderrChunks = [];
|
|
59
|
+
child.stdout?.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
60
|
+
child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
|
|
61
|
+
if (opts?.stdin && child.stdin) {
|
|
62
|
+
child.stdin.write(opts.stdin);
|
|
63
|
+
child.stdin.end();
|
|
64
|
+
}
|
|
65
|
+
child.on('close', (code) => {
|
|
66
|
+
resolve({
|
|
67
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
68
|
+
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
|
|
69
|
+
exitCode: code ?? 1,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
child.on('error', (err) => {
|
|
73
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
74
|
+
if (err.name === 'AbortError') {
|
|
75
|
+
reject(new Error(`Sandbox command timed out after ${Math.round(timeout / 1000)}s`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
reject(err);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function formatCreateContainerError(runtime, network, stderr) {
|
|
83
|
+
const raw = stderr.trim();
|
|
84
|
+
if (raw.includes('network bridge not found')) {
|
|
85
|
+
return `network "${network || 'bridge'}" not found for ${runtime}. ` +
|
|
86
|
+
`Use sandbox.network="${runtime === 'container' ? 'default' : 'bridge'}" and retry.`;
|
|
87
|
+
}
|
|
88
|
+
if (raw.includes('pull access denied') || raw.includes('not found')) {
|
|
89
|
+
return `sandbox image is missing. Build it with: ${runtime} build -t skimpyclaw-sandbox:latest sandbox/`;
|
|
90
|
+
}
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
export async function createContainer(name, opts) {
|
|
94
|
+
const runtime = getRuntime();
|
|
95
|
+
const args = ['run', '-d', '--name', name];
|
|
96
|
+
if (opts.user) {
|
|
97
|
+
args.push('--user', opts.user);
|
|
98
|
+
}
|
|
99
|
+
if (opts.cpus) {
|
|
100
|
+
args.push('--cpus', String(opts.cpus));
|
|
101
|
+
}
|
|
102
|
+
if (opts.memory) {
|
|
103
|
+
args.push('--memory', opts.memory);
|
|
104
|
+
}
|
|
105
|
+
if (opts.network) {
|
|
106
|
+
args.push('--network', opts.network);
|
|
107
|
+
}
|
|
108
|
+
if (opts.mounts) {
|
|
109
|
+
for (const m of opts.mounts) {
|
|
110
|
+
let mountArg = `type=bind,src=${m.host},dst=${m.container}`;
|
|
111
|
+
if (m.readOnly) {
|
|
112
|
+
mountArg += ',ro';
|
|
113
|
+
}
|
|
114
|
+
args.push('--mount', mountArg);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
args.push(opts.image, 'sleep', 'infinity');
|
|
118
|
+
const result = await runCommand(runtime, args);
|
|
119
|
+
if (result.exitCode !== 0) {
|
|
120
|
+
const hint = formatCreateContainerError(runtime, opts.network, result.stderr);
|
|
121
|
+
throw new Error(`Failed to create container ${name}: ${hint}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export async function execInContainer(name, args, opts) {
|
|
125
|
+
// Callers (bridge.ts) handle their own escaping — just join args
|
|
126
|
+
const escaped = args.join(' ');
|
|
127
|
+
const execArgs = ['exec'];
|
|
128
|
+
if (opts?.stdin) {
|
|
129
|
+
execArgs.push('-i');
|
|
130
|
+
}
|
|
131
|
+
if (opts?.env) {
|
|
132
|
+
for (const [key, val] of Object.entries(opts.env)) {
|
|
133
|
+
execArgs.push('-e', `${key}=${val}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
execArgs.push(name, 'sh', '-c', escaped);
|
|
137
|
+
return runCommand(getRuntime(), execArgs, {
|
|
138
|
+
stdin: opts?.stdin,
|
|
139
|
+
timeout: opts?.timeout ?? DEFAULT_TIMEOUT,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export async function removeContainer(name) {
|
|
143
|
+
const runtime = getRuntime();
|
|
144
|
+
// Best-effort stop then force rm to clear stopped/dead containers
|
|
145
|
+
await runCommand(runtime, ['stop', name]).catch(() => { });
|
|
146
|
+
await runCommand(runtime, ['rm', '-f', name]).catch(() => { });
|
|
147
|
+
}
|
|
148
|
+
export async function isContainerRunning(name) {
|
|
149
|
+
const runtime = getRuntime();
|
|
150
|
+
// Docker: use --format to check the running state directly
|
|
151
|
+
if (runtime === 'docker') {
|
|
152
|
+
const result = await runCommand(runtime, ['inspect', '--format', '{{.State.Running}}', name]);
|
|
153
|
+
return result.exitCode === 0 && result.stdout.trim() === 'true';
|
|
154
|
+
}
|
|
155
|
+
// Apple Containers: inspect succeeds for any state; check ps output
|
|
156
|
+
const result = await runCommand(runtime, ['inspect', name]);
|
|
157
|
+
if (result.exitCode !== 0)
|
|
158
|
+
return false;
|
|
159
|
+
// If stdout contains "running" state indicator, it's running
|
|
160
|
+
const output = result.stdout.toLowerCase();
|
|
161
|
+
return output.includes('"running"') || output.includes('status: running') || output.includes('state: running');
|
|
162
|
+
}
|
|
163
|
+
export async function cleanupOrphans() {
|
|
164
|
+
const rt = getRuntime();
|
|
165
|
+
// Try Docker-style --format first, fall back to plain ps for Apple Containers
|
|
166
|
+
let result = await runCommand(rt, ['ps', '-a', '--format', '{{.Names}}']);
|
|
167
|
+
if (result.exitCode !== 0) {
|
|
168
|
+
// Fallback: plain ps output, grep for our prefix
|
|
169
|
+
result = await runCommand(rt, ['ps', '-a']);
|
|
170
|
+
if (result.exitCode !== 0)
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
const names = result.stdout
|
|
174
|
+
.split('\n')
|
|
175
|
+
.map((n) => n.trim())
|
|
176
|
+
.filter((n) => n.startsWith(CONTAINER_PREFIX) || n.includes(CONTAINER_PREFIX))
|
|
177
|
+
// Extract container name if it's in a table row
|
|
178
|
+
.map((n) => {
|
|
179
|
+
const match = n.match(new RegExp(`(${CONTAINER_PREFIX}[\\w-]+)`));
|
|
180
|
+
return match ? match[1] : n;
|
|
181
|
+
})
|
|
182
|
+
.filter((n) => n.startsWith(CONTAINER_PREFIX));
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
await removeContainer(name);
|
|
185
|
+
}
|
|
186
|
+
return names.length;
|
|
187
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -5,12 +5,36 @@ import { initActiveChannel, startActiveChannel, stopActiveChannel } from './chan
|
|
|
5
5
|
import { initProviders } from './agent.js';
|
|
6
6
|
import { initLangfuse, shutdownLangfuse } from './langfuse.js';
|
|
7
7
|
import { restoreCodeAgentTasks, setCodeAgentConfig } from './tools.js';
|
|
8
|
+
import { releaseAll, cleanupOrphans, setRuntime, probeRuntime } from './sandbox/index.js';
|
|
8
9
|
export async function startRuntime(config) {
|
|
9
10
|
const smokeTest = process.env.SKIMPYCLAW_SMOKE_TEST === '1';
|
|
10
11
|
initLangfuse(config);
|
|
11
12
|
initProviders(config);
|
|
12
13
|
restoreCodeAgentTasks();
|
|
13
14
|
setCodeAgentConfig(config);
|
|
15
|
+
// Initialize sandbox runtime if configured — auto-disable if no runtime available
|
|
16
|
+
if (config.sandbox?.enabled) {
|
|
17
|
+
const detected = probeRuntime(config.sandbox.runtime);
|
|
18
|
+
if (detected) {
|
|
19
|
+
setRuntime(detected);
|
|
20
|
+
if (detected !== config.sandbox.runtime) {
|
|
21
|
+
console.log(`[sandbox] Configured runtime "${config.sandbox.runtime}" not found, using "${detected}" instead`);
|
|
22
|
+
}
|
|
23
|
+
// Clean up orphaned sandbox containers from previous runs
|
|
24
|
+
try {
|
|
25
|
+
const count = await cleanupOrphans();
|
|
26
|
+
if (count > 0)
|
|
27
|
+
console.log(`[sandbox] Cleaned up ${count} orphaned container(s)`);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.warn('[sandbox] Failed to clean up orphaned containers:', err instanceof Error ? err.message : err);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.warn('[sandbox] No container runtime found (docker/container not installed). Sandbox disabled.');
|
|
35
|
+
config.sandbox.enabled = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
14
38
|
const port = smokeTest ? (parseInt(process.env.SKIMPYCLAW_SMOKE_PORT || '19999', 10)) : config.gateway.port;
|
|
15
39
|
const gateway = await createGateway(config);
|
|
16
40
|
const host = config.gateway.host ?? '127.0.0.1';
|
|
@@ -28,6 +52,7 @@ export async function startRuntime(config) {
|
|
|
28
52
|
config,
|
|
29
53
|
gateway,
|
|
30
54
|
stop: async () => {
|
|
55
|
+
await releaseAll();
|
|
31
56
|
stopCron();
|
|
32
57
|
stopHeartbeat();
|
|
33
58
|
await stopActiveChannel();
|
package/dist/setup.d.ts
CHANGED
|
@@ -13,9 +13,19 @@ interface SetupFeatures {
|
|
|
13
13
|
browser: boolean;
|
|
14
14
|
voice: boolean;
|
|
15
15
|
mcp: boolean;
|
|
16
|
+
sandbox: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface SetupStarters {
|
|
19
|
+
cronTechNews: boolean;
|
|
20
|
+
cronWeather: boolean;
|
|
21
|
+
timezone: string;
|
|
22
|
+
weatherLocation: string;
|
|
23
|
+
skillCodeReview: boolean;
|
|
24
|
+
skillDailyNotes: boolean;
|
|
16
25
|
}
|
|
17
26
|
interface SetupBuildInput {
|
|
18
27
|
workspaceDir: string;
|
|
28
|
+
extraAllowedPaths?: string[];
|
|
19
29
|
telegramId: string;
|
|
20
30
|
telegramToken: string;
|
|
21
31
|
discordToken?: string;
|
|
@@ -25,6 +35,7 @@ interface SetupBuildInput {
|
|
|
25
35
|
selectedProviders: Set<ProviderChoice>;
|
|
26
36
|
providerSecrets: ProviderSecrets;
|
|
27
37
|
features?: SetupFeatures;
|
|
38
|
+
starters?: SetupStarters;
|
|
28
39
|
}
|
|
29
40
|
export declare function buildSetupConfig(input: SetupBuildInput): Record<string, unknown>;
|
|
30
41
|
export declare function buildSetupArtifacts(input: SetupBuildInput): {
|