@hackerai/local 0.5.2 → 0.6.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/index.d.ts +2 -2
- package/dist/index.js +229 -189
- package/dist/index.js.map +1 -1
- package/dist/pty-manager.d.ts +95 -0
- package/dist/pty-manager.d.ts.map +1 -0
- package/dist/pty-manager.js +452 -0
- package/dist/pty-manager.js.map +1 -0
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* HackerAI Local Sandbox Client
|
|
4
4
|
*
|
|
5
|
-
* Connects to HackerAI backend via Convex
|
|
6
|
-
*
|
|
5
|
+
* Connects to HackerAI backend via Convex for connection lifecycle
|
|
6
|
+
* and uses Centrifugo for real-time command relay and streaming output.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* npx @hackerai/local --token TOKEN --name "My Laptop"
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* HackerAI Local Sandbox Client
|
|
5
5
|
*
|
|
6
|
-
* Connects to HackerAI backend via Convex
|
|
7
|
-
*
|
|
6
|
+
* Connects to HackerAI backend via Convex for connection lifecycle
|
|
7
|
+
* and uses Centrifugo for real-time command relay and streaming output.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* npx @hackerai/local --token TOKEN --name "My Laptop"
|
|
@@ -15,12 +15,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
const browser_1 = require("convex/browser");
|
|
18
|
+
const centrifuge_1 = require("centrifuge");
|
|
18
19
|
const child_process_1 = require("child_process");
|
|
19
20
|
const os_1 = __importDefault(require("os"));
|
|
20
21
|
const utils_1 = require("./utils");
|
|
21
22
|
const DEFAULT_SHELL = (0, utils_1.getDefaultShell)(os_1.default.platform());
|
|
22
23
|
// Idle timeout: auto-terminate after 1 hour without commands
|
|
23
24
|
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
25
|
+
// Idle check interval: check every 5 minutes
|
|
26
|
+
const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
24
27
|
/**
|
|
25
28
|
* Runs a shell command using spawn for better output control.
|
|
26
29
|
* Collects stdout/stderr and handles timeouts gracefully.
|
|
@@ -64,7 +67,7 @@ function runShellCommand(command, options = {}) {
|
|
|
64
67
|
proc.on("close", (code) => {
|
|
65
68
|
if (timeoutId)
|
|
66
69
|
clearTimeout(timeoutId);
|
|
67
|
-
// Final truncation
|
|
70
|
+
// Final truncation
|
|
68
71
|
const truncatedStdout = (0, utils_1.truncateOutput)(stdout, maxOutputSize);
|
|
69
72
|
const truncatedStderr = (0, utils_1.truncateOutput)(stderr, maxOutputSize);
|
|
70
73
|
if (killed) {
|
|
@@ -113,11 +116,8 @@ const DEFAULT_IMAGE = "hackerai/sandbox";
|
|
|
113
116
|
const api = {
|
|
114
117
|
localSandbox: {
|
|
115
118
|
connect: "localSandbox:connect",
|
|
116
|
-
heartbeat: "localSandbox:heartbeat",
|
|
117
119
|
disconnect: "localSandbox:disconnect",
|
|
118
|
-
|
|
119
|
-
markCommandExecuting: "localSandbox:markCommandExecuting",
|
|
120
|
-
submitResult: "localSandbox:submitResult",
|
|
120
|
+
refreshCentrifugoToken: "localSandbox:refreshCentrifugoToken",
|
|
121
121
|
},
|
|
122
122
|
};
|
|
123
123
|
// ANSI color codes for terminal output
|
|
@@ -132,19 +132,19 @@ const chalk = {
|
|
|
132
132
|
};
|
|
133
133
|
class LocalSandboxClient {
|
|
134
134
|
config;
|
|
135
|
-
|
|
135
|
+
convexHttp;
|
|
136
|
+
centrifuge;
|
|
137
|
+
subscription;
|
|
136
138
|
containerId;
|
|
137
|
-
containerShell = "/bin/bash";
|
|
139
|
+
containerShell = "/bin/bash";
|
|
138
140
|
userId;
|
|
139
141
|
connectionId;
|
|
140
|
-
session;
|
|
141
|
-
heartbeatInterval;
|
|
142
|
-
commandSubscription;
|
|
143
142
|
isShuttingDown = false;
|
|
144
143
|
lastActivityTime;
|
|
144
|
+
idleCheckInterval;
|
|
145
145
|
constructor(config) {
|
|
146
146
|
this.config = config;
|
|
147
|
-
this.
|
|
147
|
+
this.convexHttp = new browser_1.ConvexHttpClient(config.convexUrl);
|
|
148
148
|
this.lastActivityTime = Date.now();
|
|
149
149
|
}
|
|
150
150
|
async start() {
|
|
@@ -160,7 +160,6 @@ class LocalSandboxClient {
|
|
|
160
160
|
console.log(chalk.green("✓ Docker is available"));
|
|
161
161
|
this.containerId = await this.createContainer();
|
|
162
162
|
console.log(chalk.green(`✓ Container: ${this.containerId.slice(0, 12)}`));
|
|
163
|
-
// Detect available shell (bash or sh fallback for Alpine/minimal images)
|
|
164
163
|
await this.detectContainerShell();
|
|
165
164
|
}
|
|
166
165
|
else {
|
|
@@ -169,8 +168,6 @@ class LocalSandboxClient {
|
|
|
169
168
|
await this.connect();
|
|
170
169
|
}
|
|
171
170
|
getContainerName() {
|
|
172
|
-
// Generate a predictable container name for --persist mode
|
|
173
|
-
// Sanitize the connection name to be docker-compatible
|
|
174
171
|
const sanitized = this.config.name
|
|
175
172
|
.toLowerCase()
|
|
176
173
|
.replace(/[^a-z0-9-]/g, "-")
|
|
@@ -179,7 +176,6 @@ class LocalSandboxClient {
|
|
|
179
176
|
return `hackerai-sandbox-${sanitized || "default"}`;
|
|
180
177
|
}
|
|
181
178
|
async findExistingContainer(containerName) {
|
|
182
|
-
// Check if container with this name exists
|
|
183
179
|
const result = await runShellCommand(`docker ps -a --filter "name=^${containerName}$" --format "{{.ID}}|{{.State}}"`, { timeout: 5000 });
|
|
184
180
|
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
185
181
|
return null;
|
|
@@ -188,7 +184,6 @@ class LocalSandboxClient {
|
|
|
188
184
|
return { id, running: state === "running" };
|
|
189
185
|
}
|
|
190
186
|
async createContainer() {
|
|
191
|
-
// In persist mode, try to reuse existing container
|
|
192
187
|
if (this.config.persist) {
|
|
193
188
|
const containerName = this.getContainerName();
|
|
194
189
|
const existing = await this.findExistingContainer(containerName);
|
|
@@ -198,14 +193,12 @@ class LocalSandboxClient {
|
|
|
198
193
|
return existing.id;
|
|
199
194
|
}
|
|
200
195
|
else {
|
|
201
|
-
// Container exists but stopped - start it
|
|
202
196
|
console.log(chalk.blue(`Starting existing container: ${containerName}`));
|
|
203
197
|
const startResult = await runShellCommand(`docker start ${existing.id}`, { timeout: 30000 });
|
|
204
198
|
if (startResult.exitCode === 0) {
|
|
205
199
|
console.log(chalk.green(`✓ Container started: ${containerName}`));
|
|
206
200
|
return existing.id;
|
|
207
201
|
}
|
|
208
|
-
// If start failed, remove and create fresh
|
|
209
202
|
console.log(chalk.yellow(`⚠️ Failed to start, creating new container...`));
|
|
210
203
|
await runShellCommand(`docker rm -f ${existing.id}`, {
|
|
211
204
|
timeout: 5000,
|
|
@@ -230,7 +223,6 @@ class LocalSandboxClient {
|
|
|
230
223
|
process.exit(1);
|
|
231
224
|
}
|
|
232
225
|
console.log(chalk.blue("Creating Docker container..."));
|
|
233
|
-
// Build docker run command with capabilities for penetration testing tools
|
|
234
226
|
const dockerCommand = (0, utils_1.buildDockerRunCommand)({
|
|
235
227
|
image: DEFAULT_IMAGE,
|
|
236
228
|
containerName: this.config.persist ? this.getContainerName() : undefined,
|
|
@@ -261,23 +253,15 @@ class LocalSandboxClient {
|
|
|
261
253
|
}
|
|
262
254
|
return "Docker";
|
|
263
255
|
}
|
|
264
|
-
/**
|
|
265
|
-
* Detects which shell is available in the container.
|
|
266
|
-
* Tries bash first, falls back to sh if bash is not available.
|
|
267
|
-
* This handles Alpine/BusyBox images that don't have bash installed.
|
|
268
|
-
*/
|
|
269
256
|
async detectContainerShell() {
|
|
270
257
|
if (!this.containerId)
|
|
271
258
|
return;
|
|
272
|
-
// Try to detect available shell using 'command -v' (POSIX compliant)
|
|
273
|
-
// We use 'sh' to run the detection since it's guaranteed to exist
|
|
274
259
|
const result = await runShellCommand(`docker exec ${this.containerId} sh -c 'command -v bash || command -v sh || echo /bin/sh'`, { timeout: 5000 });
|
|
275
260
|
if (result.exitCode === 0) {
|
|
276
261
|
this.containerShell = (0, utils_1.parseShellDetectionOutput)(result.stdout);
|
|
277
262
|
console.log(chalk.green(`✓ Shell: ${this.containerShell}`));
|
|
278
263
|
}
|
|
279
264
|
else {
|
|
280
|
-
// Fallback to /bin/sh if detection failed
|
|
281
265
|
this.containerShell = "/bin/sh";
|
|
282
266
|
console.log(chalk.yellow(`⚠️ Shell detection failed, using ${this.containerShell}`));
|
|
283
267
|
}
|
|
@@ -285,7 +269,7 @@ class LocalSandboxClient {
|
|
|
285
269
|
async connect() {
|
|
286
270
|
console.log(chalk.blue("Connecting to HackerAI..."));
|
|
287
271
|
try {
|
|
288
|
-
const result = (await this.
|
|
272
|
+
const result = (await this.convexHttp.mutation(api.localSandbox.connect, {
|
|
289
273
|
token: this.config.token,
|
|
290
274
|
connectionName: this.config.name,
|
|
291
275
|
containerId: this.containerId,
|
|
@@ -293,18 +277,19 @@ class LocalSandboxClient {
|
|
|
293
277
|
mode: this.getMode(),
|
|
294
278
|
osInfo: this.config.dangerous ? this.getOsInfo() : undefined,
|
|
295
279
|
}));
|
|
296
|
-
if (!result.success ||
|
|
280
|
+
if (!result.success ||
|
|
281
|
+
!result.centrifugoToken ||
|
|
282
|
+
!result.centrifugoWsUrl) {
|
|
297
283
|
throw new Error(result.error || "Authentication failed");
|
|
298
284
|
}
|
|
299
285
|
this.userId = result.userId;
|
|
300
286
|
this.connectionId = result.connectionId;
|
|
301
|
-
this.session = result.session;
|
|
302
287
|
console.log(chalk.green("✓ Authenticated"));
|
|
303
288
|
console.log(chalk.bold(chalk.green("🎉 Local sandbox is ready!")));
|
|
304
289
|
console.log(chalk.gray(`Connection: ${this.connectionId}`));
|
|
305
290
|
console.log(chalk.gray(`Mode: ${this.getModeDisplay()}${this.config.persist ? " (persistent)" : ""}`));
|
|
306
|
-
this.
|
|
307
|
-
this.
|
|
291
|
+
this.setupCentrifugo(result.centrifugoWsUrl, result.centrifugoToken);
|
|
292
|
+
this.startIdleCheck();
|
|
308
293
|
}
|
|
309
294
|
catch (error) {
|
|
310
295
|
const err = error;
|
|
@@ -318,52 +303,89 @@ class LocalSandboxClient {
|
|
|
318
303
|
process.exit(1);
|
|
319
304
|
}
|
|
320
305
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
306
|
+
setupCentrifugo(wsUrl, initialToken) {
|
|
307
|
+
this.centrifuge = new centrifuge_1.Centrifuge(wsUrl, {
|
|
308
|
+
token: initialToken,
|
|
309
|
+
getToken: async () => {
|
|
310
|
+
if (!this.connectionId) {
|
|
311
|
+
throw new Error("Cannot refresh token: connectionId is null");
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const result = (await this.convexHttp.mutation(api.localSandbox.refreshCentrifugoToken, {
|
|
315
|
+
token: this.config.token,
|
|
316
|
+
connectionId: this.connectionId,
|
|
317
|
+
}));
|
|
318
|
+
return result.centrifugoToken;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
console.error(chalk.red("Failed to refresh Centrifugo token:"), error);
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
332
324
|
},
|
|
333
|
-
}
|
|
325
|
+
});
|
|
326
|
+
const channel = `sandbox:user#${this.userId}`;
|
|
327
|
+
this.subscription = this.centrifuge.newSubscription(channel);
|
|
328
|
+
this.subscription.on("publication", (ctx) => {
|
|
334
329
|
if (this.isShuttingDown)
|
|
335
330
|
return;
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
331
|
+
const message = ctx.data;
|
|
332
|
+
if (message.type === "command") {
|
|
333
|
+
if (message.targetConnectionId &&
|
|
334
|
+
message.targetConnectionId !== this.connectionId) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.lastActivityTime = Date.now();
|
|
338
|
+
this.handleCommand(message).catch((error) => {
|
|
339
|
+
const errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
|
|
340
|
+
console.error(chalk.red(`Error handling command: ${errorMsg}`));
|
|
341
|
+
});
|
|
340
342
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
});
|
|
344
|
+
this.centrifuge.on("disconnected", (ctx) => {
|
|
345
|
+
if (!this.isShuttingDown) {
|
|
346
|
+
const isConnectionLimit = ctx.reason?.includes("connection limit") || ctx.code === 4503;
|
|
347
|
+
if (isConnectionLimit) {
|
|
348
|
+
console.error(chalk.red("❌ Connection limit reached. The server has too many active connections."));
|
|
349
|
+
console.error(chalk.yellow("Please try again later or contact support."));
|
|
350
|
+
this.cleanup().then(() => process.exit(1));
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
console.log(chalk.yellow(`⚠️ Disconnected from Centrifugo: ${ctx.reason}`));
|
|
354
|
+
}
|
|
345
355
|
}
|
|
346
356
|
});
|
|
357
|
+
this.centrifuge.on("connected", () => {
|
|
358
|
+
console.log(chalk.green("✓ Connected to command relay"));
|
|
359
|
+
});
|
|
360
|
+
this.subscription.subscribe();
|
|
361
|
+
this.centrifuge.connect();
|
|
347
362
|
}
|
|
348
|
-
async
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
363
|
+
async publishToChannel(data) {
|
|
364
|
+
if (!this.subscription) {
|
|
365
|
+
console.error(chalk.red("Cannot publish: no active subscription"));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
await this.subscription.publish(data);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const msg = err instanceof Error ? err.message : JSON.stringify(err);
|
|
373
|
+
console.error(chalk.red(`Publish failed: ${msg}`));
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async handleCommand(msg) {
|
|
378
|
+
const { commandId, command, env, cwd, timeout, background, displayName } = msg;
|
|
353
379
|
// Determine what to show in console:
|
|
354
|
-
// -
|
|
355
|
-
// -
|
|
356
|
-
// -
|
|
357
|
-
const shouldShow =
|
|
358
|
-
const displayText =
|
|
380
|
+
// - displayName === "" (empty string): hide command entirely
|
|
381
|
+
// - displayName === "something": show that instead of command
|
|
382
|
+
// - displayName === undefined: show actual command
|
|
383
|
+
const shouldShow = displayName !== "";
|
|
384
|
+
const displayText = displayName || command;
|
|
359
385
|
if (shouldShow) {
|
|
360
386
|
console.log(chalk.cyan(`▶ ${background ? "[BG] " : ""}${displayText}`));
|
|
361
387
|
}
|
|
362
388
|
try {
|
|
363
|
-
await this.convex.mutation(api.localSandbox.markCommandExecuting, {
|
|
364
|
-
token: this.config.token,
|
|
365
|
-
commandId: command_id,
|
|
366
|
-
});
|
|
367
389
|
let fullCommand = command;
|
|
368
390
|
if (cwd && cwd.trim() !== "") {
|
|
369
391
|
fullCommand = `cd "${cwd}" 2>/dev/null && ${fullCommand}`;
|
|
@@ -371,7 +393,6 @@ class LocalSandboxClient {
|
|
|
371
393
|
if (env) {
|
|
372
394
|
const envString = Object.entries(env)
|
|
373
395
|
.map(([k, v]) => {
|
|
374
|
-
// Escape quotes, backticks, and $ to prevent shell injection
|
|
375
396
|
const escaped = v
|
|
376
397
|
.replace(/\\/g, "\\\\")
|
|
377
398
|
.replace(/"/g, '\\"')
|
|
@@ -382,93 +403,152 @@ class LocalSandboxClient {
|
|
|
382
403
|
.join("; ");
|
|
383
404
|
fullCommand = `${envString}; ${fullCommand}`;
|
|
384
405
|
}
|
|
385
|
-
// Handle background mode - spawn and return immediately with PID
|
|
386
406
|
if (background) {
|
|
387
407
|
const pid = await this.spawnBackground(fullCommand);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
commandId
|
|
391
|
-
token: this.config.token,
|
|
392
|
-
stdout: "",
|
|
393
|
-
stderr: "",
|
|
408
|
+
await this.publishToChannel({
|
|
409
|
+
type: "exit",
|
|
410
|
+
commandId,
|
|
394
411
|
exitCode: 0,
|
|
395
412
|
pid,
|
|
396
|
-
duration,
|
|
397
413
|
});
|
|
398
414
|
console.log(chalk.green(`✓ Background process started with PID: ${pid}`));
|
|
399
415
|
return;
|
|
400
416
|
}
|
|
401
|
-
|
|
417
|
+
await this.streamCommand(commandId, fullCommand, timeout, shouldShow, displayText);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
421
|
+
await this.publishToChannel({
|
|
422
|
+
type: "error",
|
|
423
|
+
commandId,
|
|
424
|
+
message: (0, utils_1.truncateOutput)(message),
|
|
425
|
+
});
|
|
426
|
+
console.log(chalk.red(`✗ ${displayText}: ${message}`));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async streamCommand(commandId, fullCommand, timeout, shouldShow, displayText) {
|
|
430
|
+
const startTime = Date.now();
|
|
431
|
+
const commandTimeout = timeout ?? 30000;
|
|
432
|
+
return new Promise((resolve) => {
|
|
433
|
+
let killed = false;
|
|
434
|
+
let timeoutId;
|
|
435
|
+
let accumulatedStdout = "";
|
|
436
|
+
let accumulatedStderr = "";
|
|
437
|
+
let proc;
|
|
402
438
|
if (this.config.dangerous) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
// Use platform-appropriate shell (powershell on Windows, bash on Unix)
|
|
439
|
+
proc = (0, child_process_1.spawn)(DEFAULT_SHELL.shell, [DEFAULT_SHELL.shellFlag, fullCommand], {
|
|
440
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
406
441
|
});
|
|
407
442
|
}
|
|
408
443
|
else {
|
|
409
|
-
// Use single quotes to prevent host shell from interpreting $(), backticks, etc.
|
|
410
|
-
// This ensures ALL command execution happens inside the Docker container
|
|
411
444
|
const escapedCommand = fullCommand.replace(/'/g, "'\\''");
|
|
412
|
-
// Extract shell name (e.g., "bash" from "/bin/bash" or "/usr/bin/bash")
|
|
413
445
|
const shellName = this.containerShell.split("/").pop() || "sh";
|
|
414
|
-
|
|
446
|
+
proc = (0, child_process_1.spawn)("docker", ["exec", this.containerId, shellName, "-c", escapedCommand], { stdio: ["ignore", "pipe", "pipe"] });
|
|
415
447
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
448
|
+
if (commandTimeout > 0) {
|
|
449
|
+
timeoutId = setTimeout(() => {
|
|
450
|
+
killed = true;
|
|
451
|
+
proc.kill("SIGTERM");
|
|
452
|
+
setTimeout(() => {
|
|
453
|
+
if (!proc.killed) {
|
|
454
|
+
proc.kill("SIGKILL");
|
|
455
|
+
}
|
|
456
|
+
}, 2000);
|
|
457
|
+
}, commandTimeout);
|
|
458
|
+
}
|
|
459
|
+
proc.stdout?.on("data", (data) => {
|
|
460
|
+
const chunk = data.toString();
|
|
461
|
+
accumulatedStdout += chunk;
|
|
462
|
+
this.publishToChannel({
|
|
463
|
+
type: "stdout",
|
|
464
|
+
commandId,
|
|
465
|
+
data: chunk,
|
|
466
|
+
}).catch((err) => {
|
|
467
|
+
console.error(chalk.red(`[ERROR] Failed to publish stdout: ${err instanceof Error ? err.message : String(err)}`));
|
|
468
|
+
});
|
|
424
469
|
});
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
470
|
+
proc.stderr?.on("data", (data) => {
|
|
471
|
+
const chunk = data.toString();
|
|
472
|
+
accumulatedStderr += chunk;
|
|
473
|
+
this.publishToChannel({
|
|
474
|
+
type: "stderr",
|
|
475
|
+
commandId,
|
|
476
|
+
data: chunk,
|
|
477
|
+
}).catch((err) => {
|
|
478
|
+
console.error(chalk.red(`[ERROR] Failed to publish stderr: ${err instanceof Error ? err.message : String(err)}`));
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
proc.on("close", (code) => {
|
|
482
|
+
if (timeoutId)
|
|
483
|
+
clearTimeout(timeoutId);
|
|
484
|
+
const duration = Date.now() - startTime;
|
|
485
|
+
const exitCode = killed ? 124 : (code ?? 1);
|
|
486
|
+
if (killed) {
|
|
487
|
+
this.publishToChannel({
|
|
488
|
+
type: "stderr",
|
|
489
|
+
commandId,
|
|
490
|
+
data: "\n[Command timed out and was terminated]",
|
|
491
|
+
}).catch((err) => {
|
|
492
|
+
console.error(chalk.red(`[ERROR] Failed to publish timeout stderr: ${err instanceof Error ? err.message : String(err)}`));
|
|
493
|
+
});
|
|
428
494
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
console.log(chalk.
|
|
495
|
+
this.publishToChannel({
|
|
496
|
+
type: "exit",
|
|
497
|
+
commandId,
|
|
498
|
+
exitCode,
|
|
499
|
+
}).catch((err) => {
|
|
500
|
+
console.error(chalk.red(`[CRITICAL] Failed to publish EXIT message: ${err instanceof Error ? err.message : String(err)}`));
|
|
501
|
+
});
|
|
502
|
+
if (shouldShow) {
|
|
503
|
+
if (exitCode === 0) {
|
|
504
|
+
console.log(chalk.green(`✓ ${displayText} ${chalk.gray(`(${duration}ms)`)}`));
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
console.log(chalk.red(`✗ ${displayText} ${chalk.gray(`(exit ${exitCode}, ${duration}ms)`)}`));
|
|
508
|
+
if (accumulatedStderr.trim()) {
|
|
509
|
+
const indented = accumulatedStderr
|
|
510
|
+
.trim()
|
|
511
|
+
.split("\n")
|
|
512
|
+
.map((l) => ` ${l}`)
|
|
513
|
+
.join("\n");
|
|
514
|
+
console.log(chalk.red(indented));
|
|
515
|
+
}
|
|
439
516
|
}
|
|
440
517
|
}
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
catch (error) {
|
|
444
|
-
const duration = Date.now() - startTime;
|
|
445
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
446
|
-
await this.convex.mutation(api.localSandbox.submitResult, {
|
|
447
|
-
commandId: command_id,
|
|
448
|
-
token: this.config.token,
|
|
449
|
-
stdout: "",
|
|
450
|
-
stderr: (0, utils_1.truncateOutput)(message),
|
|
451
|
-
exitCode: 1,
|
|
452
|
-
duration,
|
|
518
|
+
resolve();
|
|
453
519
|
});
|
|
454
|
-
|
|
455
|
-
|
|
520
|
+
proc.on("error", (error) => {
|
|
521
|
+
if (timeoutId)
|
|
522
|
+
clearTimeout(timeoutId);
|
|
523
|
+
this.publishToChannel({
|
|
524
|
+
type: "error",
|
|
525
|
+
commandId,
|
|
526
|
+
message: error.message,
|
|
527
|
+
}).catch((err) => {
|
|
528
|
+
console.error(chalk.red(`[ERROR] Failed to publish error message: ${err instanceof Error ? err.message : String(err)}`));
|
|
529
|
+
});
|
|
530
|
+
this.publishToChannel({
|
|
531
|
+
type: "exit",
|
|
532
|
+
commandId,
|
|
533
|
+
exitCode: 1,
|
|
534
|
+
}).catch((err) => {
|
|
535
|
+
console.error(chalk.red(`[CRITICAL] Failed to publish EXIT after process error: ${err instanceof Error ? err.message : String(err)}`));
|
|
536
|
+
});
|
|
537
|
+
resolve();
|
|
538
|
+
});
|
|
539
|
+
});
|
|
456
540
|
}
|
|
457
541
|
async spawnBackground(fullCommand) {
|
|
458
542
|
if (this.config.dangerous) {
|
|
459
|
-
// Spawn directly on host in dangerous mode using platform-appropriate shell
|
|
460
543
|
const child = (0, child_process_1.spawn)(DEFAULT_SHELL.shell, [DEFAULT_SHELL.shellFlag, fullCommand], {
|
|
461
|
-
detached: os_1.default.platform() !== "win32",
|
|
544
|
+
detached: os_1.default.platform() !== "win32",
|
|
462
545
|
stdio: "ignore",
|
|
463
546
|
});
|
|
464
547
|
child.unref();
|
|
465
548
|
return child.pid ?? -1;
|
|
466
549
|
}
|
|
467
550
|
else {
|
|
468
|
-
// For Docker, start the process in background inside container and get its PID
|
|
469
|
-
// Using 'nohup command & echo $!' to get the container process PID
|
|
470
551
|
const escapedCommand = fullCommand.replace(/'/g, "'\\''");
|
|
471
|
-
// Extract shell name (e.g., "bash" from "/bin/bash" or "/usr/bin/bash")
|
|
472
552
|
const shellName = this.containerShell.split("/").pop() || "sh";
|
|
473
553
|
const result = await runShellCommand(`docker exec ${this.containerId} ${shellName} -c 'nohup ${escapedCommand} > /dev/null 2>&1 & echo $!'`, { timeout: 5000 });
|
|
474
554
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
@@ -478,74 +558,36 @@ class LocalSandboxClient {
|
|
|
478
558
|
return -1;
|
|
479
559
|
}
|
|
480
560
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const idleTime = Date.now() - this.lastActivityTime;
|
|
490
|
-
if (idleTime >= IDLE_TIMEOUT_MS) {
|
|
491
|
-
const idleMinutes = Math.floor(idleTime / 60000);
|
|
492
|
-
console.log(chalk.yellow(`\n⏰ Idle timeout: No commands received for ${idleMinutes} minutes`));
|
|
493
|
-
console.log(chalk.yellow("Auto-terminating to save resources..."));
|
|
494
|
-
await this.cleanup();
|
|
495
|
-
process.exit(0);
|
|
496
|
-
}
|
|
497
|
-
try {
|
|
498
|
-
const result = (await this.convex.mutation(api.localSandbox.heartbeat, {
|
|
499
|
-
token: this.config.token,
|
|
500
|
-
connectionId: this.connectionId,
|
|
501
|
-
}));
|
|
502
|
-
if (!result.success) {
|
|
503
|
-
console.log(chalk.red("\n❌ Connection invalidated (token may have been regenerated)"));
|
|
504
|
-
console.log(chalk.yellow("Shutting down..."));
|
|
505
|
-
await this.cleanup();
|
|
506
|
-
process.exit(1);
|
|
507
|
-
}
|
|
508
|
-
// Refresh session and restart subscription with new session
|
|
509
|
-
if (result.session) {
|
|
510
|
-
this.session = result.session;
|
|
511
|
-
this.restartCommandSubscription();
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
catch (error) {
|
|
515
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
516
|
-
console.debug(`Heartbeat error (will retry): ${message}`);
|
|
517
|
-
}
|
|
561
|
+
startIdleCheck() {
|
|
562
|
+
this.idleCheckInterval = setInterval(() => {
|
|
563
|
+
const idleTime = Date.now() - this.lastActivityTime;
|
|
564
|
+
if (idleTime >= IDLE_TIMEOUT_MS) {
|
|
565
|
+
const idleMinutes = Math.floor(idleTime / 60000);
|
|
566
|
+
console.log(chalk.yellow(`\n⏰ Idle timeout: No commands received for ${idleMinutes} minutes`));
|
|
567
|
+
console.log(chalk.yellow("Auto-terminating to save resources..."));
|
|
568
|
+
this.cleanup().then(() => process.exit(0));
|
|
518
569
|
}
|
|
519
|
-
|
|
520
|
-
if (!this.isShuttingDown) {
|
|
521
|
-
this.scheduleNextHeartbeat();
|
|
522
|
-
}
|
|
523
|
-
}, interval);
|
|
524
|
-
}
|
|
525
|
-
startHeartbeat() {
|
|
526
|
-
this.scheduleNextHeartbeat();
|
|
570
|
+
}, IDLE_CHECK_INTERVAL_MS);
|
|
527
571
|
}
|
|
528
|
-
|
|
529
|
-
if (this.
|
|
530
|
-
|
|
531
|
-
this.
|
|
572
|
+
stopIdleCheck() {
|
|
573
|
+
if (this.idleCheckInterval) {
|
|
574
|
+
clearInterval(this.idleCheckInterval);
|
|
575
|
+
this.idleCheckInterval = undefined;
|
|
532
576
|
}
|
|
533
577
|
}
|
|
534
|
-
stopCommandSubscription() {
|
|
535
|
-
if (this.commandSubscription) {
|
|
536
|
-
this.commandSubscription();
|
|
537
|
-
this.commandSubscription = undefined;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
restartCommandSubscription() {
|
|
541
|
-
this.stopCommandSubscription();
|
|
542
|
-
this.startCommandSubscription();
|
|
543
|
-
}
|
|
544
578
|
async cleanup() {
|
|
545
579
|
console.log(chalk.blue("\n🧹 Cleaning up..."));
|
|
546
580
|
this.isShuttingDown = true;
|
|
547
|
-
this.
|
|
548
|
-
|
|
581
|
+
this.stopIdleCheck();
|
|
582
|
+
// Disconnect Centrifugo
|
|
583
|
+
if (this.subscription) {
|
|
584
|
+
this.subscription.unsubscribe();
|
|
585
|
+
this.subscription = undefined;
|
|
586
|
+
}
|
|
587
|
+
if (this.centrifuge) {
|
|
588
|
+
this.centrifuge.disconnect();
|
|
589
|
+
this.centrifuge = undefined;
|
|
590
|
+
}
|
|
549
591
|
// Set up force-exit timeout (5 seconds)
|
|
550
592
|
const forceExitTimeout = setTimeout(() => {
|
|
551
593
|
console.log(chalk.yellow("⚠️ Force exiting after 5 second timeout..."));
|
|
@@ -554,7 +596,7 @@ class LocalSandboxClient {
|
|
|
554
596
|
try {
|
|
555
597
|
if (this.connectionId) {
|
|
556
598
|
try {
|
|
557
|
-
await this.
|
|
599
|
+
await this.convexHttp.mutation(api.localSandbox.disconnect, {
|
|
558
600
|
token: this.config.token,
|
|
559
601
|
connectionId: this.connectionId,
|
|
560
602
|
});
|
|
@@ -582,8 +624,6 @@ class LocalSandboxClient {
|
|
|
582
624
|
}
|
|
583
625
|
}
|
|
584
626
|
}
|
|
585
|
-
// Close the Convex client to clean up WebSocket connection
|
|
586
|
-
await this.convex.close();
|
|
587
627
|
}
|
|
588
628
|
finally {
|
|
589
629
|
clearTimeout(forceExitTimeout);
|