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.
Files changed (58) hide show
  1. package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
  2. package/dist/__tests__/bash-path-validation.test.js +164 -0
  3. package/dist/__tests__/doctor.runner.test.js +5 -1
  4. package/dist/__tests__/heartbeat.test.js +30 -3
  5. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  6. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  7. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  8. package/dist/__tests__/sandbox-manager.test.js +119 -0
  9. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  10. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  11. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  12. package/dist/__tests__/sandbox-runtime.test.js +140 -0
  13. package/dist/__tests__/setup.test.js +32 -3
  14. package/dist/__tests__/skills.test.js +2 -11
  15. package/dist/__tests__/tools.test.js +6 -1
  16. package/dist/__tests__/voice.test.js +12 -0
  17. package/dist/agent.js +2 -0
  18. package/dist/api.js +5 -1
  19. package/dist/channels/telegram/utils.js +2 -2
  20. package/dist/cli.js +212 -0
  21. package/dist/code-agents/executor.js +17 -4
  22. package/dist/code-agents/types.d.ts +5 -0
  23. package/dist/cron.js +16 -2
  24. package/dist/discord.js +2 -2
  25. package/dist/doctor/checks.d.ts +1 -0
  26. package/dist/doctor/checks.js +47 -0
  27. package/dist/doctor/runner.js +2 -1
  28. package/dist/exec-approval.d.ts +4 -0
  29. package/dist/exec-approval.js +4 -4
  30. package/dist/gateway.js +33 -2
  31. package/dist/heartbeat.js +7 -3
  32. package/dist/providers/openai.js +1 -1
  33. package/dist/sandbox/bridge.d.ts +5 -0
  34. package/dist/sandbox/bridge.js +63 -0
  35. package/dist/sandbox/index.d.ts +5 -0
  36. package/dist/sandbox/index.js +4 -0
  37. package/dist/sandbox/manager.d.ts +7 -0
  38. package/dist/sandbox/manager.js +89 -0
  39. package/dist/sandbox/mount-security.d.ts +12 -0
  40. package/dist/sandbox/mount-security.js +118 -0
  41. package/dist/sandbox/runtime.d.ts +33 -0
  42. package/dist/sandbox/runtime.js +167 -0
  43. package/dist/service.js +17 -0
  44. package/dist/setup.d.ts +11 -0
  45. package/dist/setup.js +336 -23
  46. package/dist/skills.d.ts +1 -2
  47. package/dist/skills.js +1 -13
  48. package/dist/tools/bash-path-validation.d.ts +22 -0
  49. package/dist/tools/bash-path-validation.js +130 -0
  50. package/dist/tools/bash-tool.js +23 -1
  51. package/dist/tools/definitions.d.ts +0 -7
  52. package/dist/tools/definitions.js +0 -5
  53. package/dist/tools/execute-context.d.ts +4 -0
  54. package/dist/tools/path-utils.js +16 -2
  55. package/dist/tools.js +84 -2
  56. package/dist/types.d.ts +10 -0
  57. package/dist/voice.js +5 -1
  58. package/package.json +1 -1
@@ -0,0 +1,167 @@
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
+ /** Reset runtime detection (for testing). */
27
+ export function resetRuntime() {
28
+ runtimeBinary = null;
29
+ }
30
+ function runCommand(cmd, args, opts) {
31
+ return new Promise((resolve, reject) => {
32
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
33
+ const child = spawn(cmd, args, {
34
+ stdio: [opts?.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
35
+ signal: AbortSignal.timeout(timeout),
36
+ });
37
+ const stdoutChunks = [];
38
+ const stderrChunks = [];
39
+ child.stdout?.on('data', (chunk) => stdoutChunks.push(chunk));
40
+ child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
41
+ if (opts?.stdin && child.stdin) {
42
+ child.stdin.write(opts.stdin);
43
+ child.stdin.end();
44
+ }
45
+ child.on('close', (code) => {
46
+ resolve({
47
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
48
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
49
+ exitCode: code ?? 1,
50
+ });
51
+ });
52
+ child.on('error', (err) => {
53
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
54
+ if (err.name === 'AbortError') {
55
+ reject(new Error(`Sandbox command timed out after ${Math.round(timeout / 1000)}s`));
56
+ return;
57
+ }
58
+ reject(err);
59
+ });
60
+ });
61
+ }
62
+ function formatCreateContainerError(runtime, network, stderr) {
63
+ const raw = stderr.trim();
64
+ if (raw.includes('network bridge not found')) {
65
+ return `network "${network || 'bridge'}" not found for ${runtime}. ` +
66
+ `Use sandbox.network="${runtime === 'container' ? 'default' : 'bridge'}" and retry.`;
67
+ }
68
+ if (raw.includes('pull access denied') || raw.includes('not found')) {
69
+ return `sandbox image is missing. Build it with: ${runtime} build -t skimpyclaw-sandbox:latest sandbox/`;
70
+ }
71
+ return raw;
72
+ }
73
+ export async function createContainer(name, opts) {
74
+ const runtime = getRuntime();
75
+ const args = ['run', '-d', '--name', name];
76
+ if (opts.user) {
77
+ args.push('--user', opts.user);
78
+ }
79
+ if (opts.cpus) {
80
+ args.push('--cpus', String(opts.cpus));
81
+ }
82
+ if (opts.memory) {
83
+ args.push('--memory', opts.memory);
84
+ }
85
+ if (opts.network) {
86
+ args.push('--network', opts.network);
87
+ }
88
+ if (opts.mounts) {
89
+ for (const m of opts.mounts) {
90
+ let mountArg = `type=bind,src=${m.host},dst=${m.container}`;
91
+ if (m.readOnly) {
92
+ mountArg += ',ro';
93
+ }
94
+ args.push('--mount', mountArg);
95
+ }
96
+ }
97
+ args.push(opts.image, 'sleep', 'infinity');
98
+ const result = await runCommand(runtime, args);
99
+ if (result.exitCode !== 0) {
100
+ const hint = formatCreateContainerError(runtime, opts.network, result.stderr);
101
+ throw new Error(`Failed to create container ${name}: ${hint}`);
102
+ }
103
+ }
104
+ export async function execInContainer(name, args, opts) {
105
+ // Callers (bridge.ts) handle their own escaping — just join args
106
+ const escaped = args.join(' ');
107
+ const execArgs = ['exec'];
108
+ if (opts?.stdin) {
109
+ execArgs.push('-i');
110
+ }
111
+ if (opts?.env) {
112
+ for (const [key, val] of Object.entries(opts.env)) {
113
+ execArgs.push('-e', `${key}=${val}`);
114
+ }
115
+ }
116
+ execArgs.push(name, 'sh', '-c', escaped);
117
+ return runCommand(getRuntime(), execArgs, {
118
+ stdin: opts?.stdin,
119
+ timeout: opts?.timeout ?? DEFAULT_TIMEOUT,
120
+ });
121
+ }
122
+ export async function removeContainer(name) {
123
+ const runtime = getRuntime();
124
+ // Best-effort stop then force rm to clear stopped/dead containers
125
+ await runCommand(runtime, ['stop', name]).catch(() => { });
126
+ await runCommand(runtime, ['rm', '-f', name]).catch(() => { });
127
+ }
128
+ export async function isContainerRunning(name) {
129
+ const runtime = getRuntime();
130
+ // Docker: use --format to check the running state directly
131
+ if (runtime === 'docker') {
132
+ const result = await runCommand(runtime, ['inspect', '--format', '{{.State.Running}}', name]);
133
+ return result.exitCode === 0 && result.stdout.trim() === 'true';
134
+ }
135
+ // Apple Containers: inspect succeeds for any state; check ps output
136
+ const result = await runCommand(runtime, ['inspect', name]);
137
+ if (result.exitCode !== 0)
138
+ return false;
139
+ // If stdout contains "running" state indicator, it's running
140
+ const output = result.stdout.toLowerCase();
141
+ return output.includes('"running"') || output.includes('status: running') || output.includes('state: running');
142
+ }
143
+ export async function cleanupOrphans() {
144
+ const rt = getRuntime();
145
+ // Try Docker-style --format first, fall back to plain ps for Apple Containers
146
+ let result = await runCommand(rt, ['ps', '-a', '--format', '{{.Names}}']);
147
+ if (result.exitCode !== 0) {
148
+ // Fallback: plain ps output, grep for our prefix
149
+ result = await runCommand(rt, ['ps', '-a']);
150
+ if (result.exitCode !== 0)
151
+ return 0;
152
+ }
153
+ const names = result.stdout
154
+ .split('\n')
155
+ .map((n) => n.trim())
156
+ .filter((n) => n.startsWith(CONTAINER_PREFIX) || n.includes(CONTAINER_PREFIX))
157
+ // Extract container name if it's in a table row
158
+ .map((n) => {
159
+ const match = n.match(new RegExp(`(${CONTAINER_PREFIX}[\\w-]+)`));
160
+ return match ? match[1] : n;
161
+ })
162
+ .filter((n) => n.startsWith(CONTAINER_PREFIX));
163
+ for (const name of names) {
164
+ await removeContainer(name);
165
+ }
166
+ return names.length;
167
+ }
package/dist/service.js CHANGED
@@ -5,12 +5,28 @@ 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 } 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
16
+ if (config.sandbox?.runtime) {
17
+ setRuntime(config.sandbox.runtime);
18
+ }
19
+ // Clean up orphaned sandbox containers from previous runs
20
+ if (config.sandbox?.enabled) {
21
+ try {
22
+ const count = await cleanupOrphans();
23
+ if (count > 0)
24
+ console.log(`[sandbox] Cleaned up ${count} orphaned container(s)`);
25
+ }
26
+ catch (err) {
27
+ console.warn('[sandbox] Failed to clean up orphaned containers:', err instanceof Error ? err.message : err);
28
+ }
29
+ }
14
30
  const port = smokeTest ? (parseInt(process.env.SKIMPYCLAW_SMOKE_PORT || '19999', 10)) : config.gateway.port;
15
31
  const gateway = await createGateway(config);
16
32
  const host = config.gateway.host ?? '127.0.0.1';
@@ -28,6 +44,7 @@ export async function startRuntime(config) {
28
44
  config,
29
45
  gateway,
30
46
  stop: async () => {
47
+ await releaseAll();
31
48
  stopCron();
32
49
  stopHeartbeat();
33
50
  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): {