@hackerai/local 0.5.1 → 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/__tests__/utils.test.js +3 -3
- package/dist/__tests__/utils.test.js.map +1 -1
- 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 +81 -73
- package/dist/pty-manager.js +403 -386
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +3 -2
- package/dist/utils.js.map +1 -1
- package/package.json +2 -1
package/dist/pty-manager.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Cross-platform: macOS (forkpty), Linux (forkpty), Windows (conpty).
|
|
9
9
|
*/
|
|
10
|
-
var __importDefault =
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
var __importDefault =
|
|
11
|
+
(this && this.__importDefault) ||
|
|
12
|
+
function (mod) {
|
|
13
|
+
return mod && mod.__esModule ? mod : { default: mod };
|
|
14
|
+
};
|
|
13
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
16
|
exports.PtyManager = void 0;
|
|
15
17
|
const os_1 = __importDefault(require("os"));
|
|
@@ -18,418 +20,433 @@ const path_1 = __importDefault(require("path"));
|
|
|
18
20
|
const utils_1 = require("./utils");
|
|
19
21
|
// Convex function references
|
|
20
22
|
const api = {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
localSandbox: {
|
|
24
|
+
activatePtySession: "localSandbox:activatePtySession",
|
|
25
|
+
submitPtyOutput: "localSandbox:submitPtyOutput",
|
|
26
|
+
updatePtySessionStatus: "localSandbox:updatePtySessionStatus",
|
|
27
|
+
markPtyInputConsumed: "localSandbox:markPtyInputConsumed",
|
|
28
|
+
subscribeToPtyInput: "localSandbox:subscribeToPtyInput",
|
|
29
|
+
getPendingPtySessions: "localSandbox:getPendingPtySessions",
|
|
30
|
+
},
|
|
29
31
|
};
|
|
30
32
|
// ANSI color codes
|
|
31
33
|
const chalk = {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
blue: (s) => `\x1b[34m${s}\x1b[0m`,
|
|
35
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
36
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
37
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
38
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
39
|
+
gray: (s) => `\x1b[90m${s}\x1b[0m`,
|
|
38
40
|
};
|
|
39
41
|
// Output batching: buffer for ~75ms before sending to reduce Convex write frequency
|
|
40
42
|
const OUTPUT_BATCH_INTERVAL_MS = 75;
|
|
41
43
|
// Max size of a single output chunk (base64 encoded) to stay under Convex limits
|
|
42
44
|
const MAX_CHUNK_SIZE = 64 * 1024; // 64KB
|
|
43
45
|
class PtyManager {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
46
|
+
convex;
|
|
47
|
+
token;
|
|
48
|
+
connectionId;
|
|
49
|
+
session;
|
|
50
|
+
mode;
|
|
51
|
+
containerId;
|
|
52
|
+
containerShell;
|
|
53
|
+
sessions = new Map();
|
|
54
|
+
sessionSubscription = null;
|
|
55
|
+
ptyAvailable;
|
|
56
|
+
constructor(
|
|
57
|
+
convex,
|
|
58
|
+
token,
|
|
59
|
+
connectionId,
|
|
60
|
+
session,
|
|
61
|
+
mode,
|
|
62
|
+
containerId,
|
|
63
|
+
containerShell,
|
|
64
|
+
) {
|
|
65
|
+
this.convex = convex;
|
|
66
|
+
this.token = token;
|
|
67
|
+
this.connectionId = connectionId;
|
|
68
|
+
this.session = session;
|
|
69
|
+
this.mode = mode;
|
|
70
|
+
this.containerId = containerId;
|
|
71
|
+
this.containerShell = containerShell;
|
|
72
|
+
// Ensure node-pty's spawn-helper binary is executable (npm may strip the +x bit)
|
|
73
|
+
this.fixSpawnHelperPermissions();
|
|
74
|
+
this.ptyAvailable = (0, utils_1.isNodePtyAvailable)();
|
|
75
|
+
if (!this.ptyAvailable) {
|
|
76
|
+
console.log(
|
|
77
|
+
chalk.yellow(
|
|
78
|
+
"⚠️ node-pty not available - PTY sessions will not be supported",
|
|
79
|
+
),
|
|
80
|
+
);
|
|
68
81
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fix node-pty spawn-helper permissions.
|
|
85
|
+
* npm/pnpm can strip the execute bit from prebuilt binaries during install.
|
|
86
|
+
* Without +x, posix_spawnp fails on macOS/Linux.
|
|
87
|
+
*/
|
|
88
|
+
fixSpawnHelperPermissions() {
|
|
89
|
+
try {
|
|
90
|
+
// Resolve node-pty's prebuilds directory
|
|
91
|
+
const nodePtyDir = path_1.default.dirname(
|
|
92
|
+
require.resolve("node-pty/package.json"),
|
|
93
|
+
);
|
|
94
|
+
const prebuildsDir = path_1.default.join(nodePtyDir, "prebuilds");
|
|
95
|
+
if (!fs_1.default.existsSync(prebuildsDir)) return;
|
|
96
|
+
for (const platformDir of fs_1.default.readdirSync(prebuildsDir)) {
|
|
97
|
+
const helperPath = path_1.default.join(
|
|
98
|
+
prebuildsDir,
|
|
99
|
+
platformDir,
|
|
100
|
+
"spawn-helper",
|
|
101
|
+
);
|
|
102
|
+
if (!fs_1.default.existsSync(helperPath)) continue;
|
|
75
103
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const stat = fs_1.default.statSync(helperPath);
|
|
87
|
-
// Check if execute bit is missing for owner
|
|
88
|
-
if ((stat.mode & 0o100) === 0) {
|
|
89
|
-
fs_1.default.chmodSync(helperPath, 0o755);
|
|
90
|
-
console.debug(`Fixed spawn-helper permissions: ${platformDir}/spawn-helper`);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
// Non-critical — may not have permission to chmod
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// Non-critical — node-pty might not be installed
|
|
104
|
+
const stat = fs_1.default.statSync(helperPath);
|
|
105
|
+
// Check if execute bit is missing for owner
|
|
106
|
+
if ((stat.mode & 0o100) === 0) {
|
|
107
|
+
fs_1.default.chmodSync(helperPath, 0o755);
|
|
108
|
+
console.debug(
|
|
109
|
+
`Fixed spawn-helper permissions: ${platformDir}/spawn-helper`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Non-critical — may not have permission to chmod
|
|
100
114
|
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Non-critical — node-pty might not be installed
|
|
101
118
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await this.handleCreateSession(req);
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Stop the session subscription.
|
|
141
|
-
*/
|
|
142
|
-
stopSessionSubscription() {
|
|
143
|
-
if (this.sessionSubscription) {
|
|
144
|
-
this.sessionSubscription();
|
|
145
|
-
this.sessionSubscription = null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Update the signed session (called after heartbeat refresh).
|
|
122
|
+
*/
|
|
123
|
+
updateSession(session) {
|
|
124
|
+
this.session = session;
|
|
125
|
+
// Restart subscription with new session
|
|
126
|
+
this.stopSessionSubscription();
|
|
127
|
+
this.startSessionSubscription();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Start listening for new PTY session requests from the backend.
|
|
131
|
+
*/
|
|
132
|
+
startSessionSubscription() {
|
|
133
|
+
if (!this.ptyAvailable) return;
|
|
134
|
+
if (this.sessionSubscription) return;
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
|
+
this.sessionSubscription = this.convex.onUpdate(
|
|
137
|
+
api.localSandbox.getPendingPtySessions,
|
|
138
|
+
{
|
|
139
|
+
connectionId: this.connectionId,
|
|
140
|
+
session: {
|
|
141
|
+
userId: this.session.userId,
|
|
142
|
+
expiresAt: this.session.expiresAt,
|
|
143
|
+
signature: this.session.signature,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
async (data) => {
|
|
147
|
+
if (data?.authError) {
|
|
148
|
+
console.debug(
|
|
149
|
+
"PTY session subscription: auth error, will refresh on next heartbeat",
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
146
152
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*/
|
|
151
|
-
async handleCreateSession(req) {
|
|
152
|
-
if (this.sessions.has(req.session_id))
|
|
153
|
-
return; // Already created
|
|
154
|
-
console.log(chalk.cyan(`▶ PTY session: ${req.session_id.slice(0, 8)}...`));
|
|
155
|
-
try {
|
|
156
|
-
// Dynamic import of node-pty to handle cases where it's not available
|
|
157
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
158
|
-
const nodePty = require("node-pty");
|
|
159
|
-
// Validate cwd exists on the host for dangerous mode.
|
|
160
|
-
// E2B PTY sessions pass cwd="/home/user" which doesn't exist on macOS/Windows.
|
|
161
|
-
// For Docker mode, cwd is passed to `docker exec -w` so it's inside the container.
|
|
162
|
-
let effectiveCwd = req.cwd;
|
|
163
|
-
if (this.mode === "dangerous" && effectiveCwd) {
|
|
164
|
-
try {
|
|
165
|
-
if (!fs_1.default.existsSync(effectiveCwd)) {
|
|
166
|
-
console.debug(`PTY cwd "${effectiveCwd}" not found, falling back to home dir`);
|
|
167
|
-
effectiveCwd = os_1.default.homedir();
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
effectiveCwd = os_1.default.homedir();
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
const spawnConfig = (0, utils_1.getPtySpawnConfig)({
|
|
175
|
-
platform: os_1.default.platform(),
|
|
176
|
-
mode: this.mode,
|
|
177
|
-
containerId: this.containerId,
|
|
178
|
-
containerShell: this.containerShell,
|
|
179
|
-
cols: req.cols,
|
|
180
|
-
rows: req.rows,
|
|
181
|
-
cwd: effectiveCwd,
|
|
182
|
-
env: req.env,
|
|
183
|
-
});
|
|
184
|
-
const ptyProcess = nodePty.spawn(spawnConfig.file, spawnConfig.args, {
|
|
185
|
-
name: "xterm-256color",
|
|
186
|
-
...spawnConfig.options,
|
|
187
|
-
});
|
|
188
|
-
const activeSession = {
|
|
189
|
-
sessionId: req.session_id,
|
|
190
|
-
process: ptyProcess,
|
|
191
|
-
outputBuffer: "",
|
|
192
|
-
outputSequence: 0,
|
|
193
|
-
batchTimer: null,
|
|
194
|
-
inputSubscription: null,
|
|
195
|
-
};
|
|
196
|
-
this.sessions.set(req.session_id, activeSession);
|
|
197
|
-
// Report PID and activate session
|
|
198
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
199
|
-
await this.convex.mutation(api.localSandbox.activatePtySession, {
|
|
200
|
-
token: this.token,
|
|
201
|
-
sessionId: req.session_id,
|
|
202
|
-
pid: ptyProcess.pid,
|
|
203
|
-
});
|
|
204
|
-
// Set up output handler with batching
|
|
205
|
-
ptyProcess.onData((data) => {
|
|
206
|
-
this.bufferOutput(activeSession, data);
|
|
207
|
-
});
|
|
208
|
-
// Set up exit handler
|
|
209
|
-
ptyProcess.onExit(async (e) => {
|
|
210
|
-
// Flush any remaining output
|
|
211
|
-
await this.flushOutput(activeSession);
|
|
212
|
-
// Report exit
|
|
213
|
-
try {
|
|
214
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
215
|
-
await this.convex.mutation(api.localSandbox.updatePtySessionStatus, {
|
|
216
|
-
token: this.token,
|
|
217
|
-
sessionId: req.session_id,
|
|
218
|
-
status: "exited",
|
|
219
|
-
exitCode: e.exitCode,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
console.debug(`Failed to report PTY exit: ${err}`);
|
|
224
|
-
}
|
|
225
|
-
this.cleanupSession(req.session_id);
|
|
226
|
-
console.log(chalk.green(`✓ PTY session ${req.session_id.slice(0, 8)}... exited (code: ${e.exitCode})`));
|
|
227
|
-
});
|
|
228
|
-
// Start listening for stdin input
|
|
229
|
-
this.startInputSubscription(activeSession);
|
|
230
|
-
console.log(chalk.green(`✓ PTY session ${req.session_id.slice(0, 8)}... active (PID: ${ptyProcess.pid})`));
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
console.error(chalk.red(`✗ Failed to create PTY session: ${err}`));
|
|
234
|
-
// Report failure
|
|
235
|
-
try {
|
|
236
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
237
|
-
await this.convex.mutation(api.localSandbox.updatePtySessionStatus, {
|
|
238
|
-
token: this.token,
|
|
239
|
-
sessionId: req.session_id,
|
|
240
|
-
status: "exited",
|
|
241
|
-
exitCode: -1,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
/* ignore */
|
|
246
|
-
}
|
|
153
|
+
if (!data?.sessions) return;
|
|
154
|
+
for (const req of data.sessions) {
|
|
155
|
+
await this.handleCreateSession(req);
|
|
247
156
|
}
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Stop the session subscription.
|
|
162
|
+
*/
|
|
163
|
+
stopSessionSubscription() {
|
|
164
|
+
if (this.sessionSubscription) {
|
|
165
|
+
this.sessionSubscription();
|
|
166
|
+
this.sessionSubscription = null;
|
|
248
167
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Flush buffered output to Convex.
|
|
269
|
-
*/
|
|
270
|
-
async flushOutput(session) {
|
|
271
|
-
if (session.batchTimer) {
|
|
272
|
-
clearTimeout(session.batchTimer);
|
|
273
|
-
session.batchTimer = null;
|
|
274
|
-
}
|
|
275
|
-
if (!session.outputBuffer)
|
|
276
|
-
return;
|
|
277
|
-
const data = session.outputBuffer;
|
|
278
|
-
session.outputBuffer = "";
|
|
279
|
-
// Split into chunks if necessary
|
|
280
|
-
const chunks = [];
|
|
281
|
-
for (let i = 0; i < data.length; i += MAX_CHUNK_SIZE) {
|
|
282
|
-
const chunk = data.slice(i, i + MAX_CHUNK_SIZE);
|
|
283
|
-
// Encode as base64 to safely transport binary PTY data through Convex
|
|
284
|
-
const encoded = Buffer.from(chunk, "utf-8").toString("base64");
|
|
285
|
-
chunks.push({
|
|
286
|
-
data: encoded,
|
|
287
|
-
sequence: ++session.outputSequence,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Handle a request to create a new PTY session.
|
|
171
|
+
*/
|
|
172
|
+
async handleCreateSession(req) {
|
|
173
|
+
if (this.sessions.has(req.session_id)) return; // Already created
|
|
174
|
+
console.log(chalk.cyan(`▶ PTY session: ${req.session_id.slice(0, 8)}...`));
|
|
175
|
+
try {
|
|
176
|
+
// Dynamic import of node-pty to handle cases where it's not available
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
178
|
+
const nodePty = require("node-pty");
|
|
179
|
+
// Validate cwd exists on the host for dangerous mode.
|
|
180
|
+
// E2B PTY sessions pass cwd="/home/user" which doesn't exist on macOS/Windows.
|
|
181
|
+
// For Docker mode, cwd is passed to `docker exec -w` so it's inside the container.
|
|
182
|
+
let effectiveCwd = req.cwd;
|
|
183
|
+
if (this.mode === "dangerous" && effectiveCwd) {
|
|
290
184
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
185
|
+
if (!fs_1.default.existsSync(effectiveCwd)) {
|
|
186
|
+
console.debug(
|
|
187
|
+
`PTY cwd "${effectiveCwd}" not found, falling back to home dir`,
|
|
188
|
+
);
|
|
189
|
+
effectiveCwd = os_1.default.homedir();
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
effectiveCwd = os_1.default.homedir();
|
|
297
193
|
}
|
|
298
|
-
|
|
299
|
-
|
|
194
|
+
}
|
|
195
|
+
const spawnConfig = (0, utils_1.getPtySpawnConfig)({
|
|
196
|
+
platform: os_1.default.platform(),
|
|
197
|
+
mode: this.mode,
|
|
198
|
+
containerId: this.containerId,
|
|
199
|
+
containerShell: this.containerShell,
|
|
200
|
+
cols: req.cols,
|
|
201
|
+
rows: req.rows,
|
|
202
|
+
cwd: effectiveCwd,
|
|
203
|
+
env: req.env,
|
|
204
|
+
});
|
|
205
|
+
const ptyProcess = nodePty.spawn(spawnConfig.file, spawnConfig.args, {
|
|
206
|
+
name: "xterm-256color",
|
|
207
|
+
...spawnConfig.options,
|
|
208
|
+
});
|
|
209
|
+
const activeSession = {
|
|
210
|
+
sessionId: req.session_id,
|
|
211
|
+
process: ptyProcess,
|
|
212
|
+
outputBuffer: "",
|
|
213
|
+
outputSequence: 0,
|
|
214
|
+
batchTimer: null,
|
|
215
|
+
inputSubscription: null,
|
|
216
|
+
};
|
|
217
|
+
this.sessions.set(req.session_id, activeSession);
|
|
218
|
+
// Report PID and activate session
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
await this.convex.mutation(api.localSandbox.activatePtySession, {
|
|
221
|
+
token: this.token,
|
|
222
|
+
sessionId: req.session_id,
|
|
223
|
+
pid: ptyProcess.pid,
|
|
224
|
+
});
|
|
225
|
+
// Set up output handler with batching
|
|
226
|
+
ptyProcess.onData((data) => {
|
|
227
|
+
this.bufferOutput(activeSession, data);
|
|
228
|
+
});
|
|
229
|
+
// Set up exit handler
|
|
230
|
+
ptyProcess.onExit(async (e) => {
|
|
231
|
+
// Flush any remaining output
|
|
232
|
+
await this.flushOutput(activeSession);
|
|
233
|
+
// Report exit
|
|
234
|
+
try {
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
236
|
+
await this.convex.mutation(api.localSandbox.updatePtySessionStatus, {
|
|
237
|
+
token: this.token,
|
|
238
|
+
sessionId: req.session_id,
|
|
239
|
+
status: "exited",
|
|
240
|
+
exitCode: e.exitCode,
|
|
241
|
+
});
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.debug(`Failed to report PTY exit: ${err}`);
|
|
300
244
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
245
|
+
this.cleanupSession(req.session_id);
|
|
246
|
+
console.log(
|
|
247
|
+
chalk.green(
|
|
248
|
+
`✓ PTY session ${req.session_id.slice(0, 8)}... exited (code: ${e.exitCode})`,
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
// Start listening for stdin input
|
|
253
|
+
this.startInputSubscription(activeSession);
|
|
254
|
+
console.log(
|
|
255
|
+
chalk.green(
|
|
256
|
+
`✓ PTY session ${req.session_id.slice(0, 8)}... active (PID: ${ptyProcess.pid})`,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(chalk.red(`✗ Failed to create PTY session: ${err}`));
|
|
261
|
+
// Report failure
|
|
262
|
+
try {
|
|
306
263
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
signature: this.session.signature,
|
|
313
|
-
},
|
|
314
|
-
connectionId: this.connectionId,
|
|
315
|
-
}, async (data) => {
|
|
316
|
-
if (data?.authError)
|
|
317
|
-
return;
|
|
318
|
-
if (!data?.inputs?.length)
|
|
319
|
-
return;
|
|
320
|
-
const inputIds = [];
|
|
321
|
-
for (const input of data.inputs) {
|
|
322
|
-
// Decode base64 data and write to PTY
|
|
323
|
-
const decoded = Buffer.from(input.data, "base64").toString("utf-8");
|
|
324
|
-
try {
|
|
325
|
-
session.process.write(decoded);
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
// Process may have exited
|
|
329
|
-
}
|
|
330
|
-
inputIds.push(input._id);
|
|
331
|
-
}
|
|
332
|
-
// Mark inputs as consumed
|
|
333
|
-
if (inputIds.length > 0) {
|
|
334
|
-
try {
|
|
335
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
336
|
-
await this.convex.mutation(api.localSandbox.markPtyInputConsumed, {
|
|
337
|
-
token: this.token,
|
|
338
|
-
inputIds,
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
catch {
|
|
342
|
-
/* ignore */
|
|
343
|
-
}
|
|
344
|
-
}
|
|
264
|
+
await this.convex.mutation(api.localSandbox.updatePtySessionStatus, {
|
|
265
|
+
token: this.token,
|
|
266
|
+
sessionId: req.session_id,
|
|
267
|
+
status: "exited",
|
|
268
|
+
exitCode: -1,
|
|
345
269
|
});
|
|
270
|
+
} catch {
|
|
271
|
+
/* ignore */
|
|
272
|
+
}
|
|
346
273
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
catch {
|
|
358
|
-
/* may already be dead */
|
|
359
|
-
}
|
|
360
|
-
this.cleanupSession(sessionId);
|
|
361
|
-
return true;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Buffer PTY output and flush periodically to reduce Convex writes.
|
|
277
|
+
*/
|
|
278
|
+
bufferOutput(session, data) {
|
|
279
|
+
session.outputBuffer += data;
|
|
280
|
+
// If buffer is getting large, flush immediately
|
|
281
|
+
if (session.outputBuffer.length >= MAX_CHUNK_SIZE) {
|
|
282
|
+
this.flushOutput(session);
|
|
283
|
+
return;
|
|
362
284
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
285
|
+
// Otherwise, batch with a short timer
|
|
286
|
+
if (!session.batchTimer) {
|
|
287
|
+
session.batchTimer = setTimeout(() => {
|
|
288
|
+
session.batchTimer = null;
|
|
289
|
+
this.flushOutput(session);
|
|
290
|
+
}, OUTPUT_BATCH_INTERVAL_MS);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Flush buffered output to Convex.
|
|
295
|
+
*/
|
|
296
|
+
async flushOutput(session) {
|
|
297
|
+
if (session.batchTimer) {
|
|
298
|
+
clearTimeout(session.batchTimer);
|
|
299
|
+
session.batchTimer = null;
|
|
300
|
+
}
|
|
301
|
+
if (!session.outputBuffer) return;
|
|
302
|
+
const data = session.outputBuffer;
|
|
303
|
+
session.outputBuffer = "";
|
|
304
|
+
// Split into chunks if necessary
|
|
305
|
+
const chunks = [];
|
|
306
|
+
for (let i = 0; i < data.length; i += MAX_CHUNK_SIZE) {
|
|
307
|
+
const chunk = data.slice(i, i + MAX_CHUNK_SIZE);
|
|
308
|
+
// Encode as base64 to safely transport binary PTY data through Convex
|
|
309
|
+
const encoded = Buffer.from(chunk, "utf-8").toString("base64");
|
|
310
|
+
chunks.push({
|
|
311
|
+
data: encoded,
|
|
312
|
+
sequence: ++session.outputSequence,
|
|
313
|
+
});
|
|
377
314
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
315
|
+
try {
|
|
316
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
317
|
+
await this.convex.mutation(api.localSandbox.submitPtyOutput, {
|
|
318
|
+
token: this.token,
|
|
319
|
+
sessionId: session.sessionId,
|
|
320
|
+
chunks,
|
|
321
|
+
});
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.debug(`Failed to submit PTY output: ${err}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Subscribe to stdin input from the backend for a session.
|
|
328
|
+
*/
|
|
329
|
+
startInputSubscription(session) {
|
|
330
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
331
|
+
session.inputSubscription = this.convex.onUpdate(
|
|
332
|
+
api.localSandbox.subscribeToPtyInput,
|
|
333
|
+
{
|
|
334
|
+
sessionId: session.sessionId,
|
|
335
|
+
session: {
|
|
336
|
+
userId: this.session.userId,
|
|
337
|
+
expiresAt: this.session.expiresAt,
|
|
338
|
+
signature: this.session.signature,
|
|
339
|
+
},
|
|
340
|
+
connectionId: this.connectionId,
|
|
341
|
+
},
|
|
342
|
+
async (data) => {
|
|
343
|
+
if (data?.authError) return;
|
|
344
|
+
if (!data?.inputs?.length) return;
|
|
345
|
+
const inputIds = [];
|
|
346
|
+
for (const input of data.inputs) {
|
|
347
|
+
// Decode base64 data and write to PTY
|
|
348
|
+
const decoded = Buffer.from(input.data, "base64").toString("utf-8");
|
|
349
|
+
try {
|
|
350
|
+
session.process.write(decoded);
|
|
351
|
+
} catch {
|
|
352
|
+
// Process may have exited
|
|
353
|
+
}
|
|
354
|
+
inputIds.push(input._id);
|
|
387
355
|
}
|
|
388
|
-
|
|
389
|
-
|
|
356
|
+
// Mark inputs as consumed
|
|
357
|
+
if (inputIds.length > 0) {
|
|
358
|
+
try {
|
|
359
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
360
|
+
await this.convex.mutation(api.localSandbox.markPtyInputConsumed, {
|
|
361
|
+
token: this.token,
|
|
362
|
+
inputIds,
|
|
363
|
+
});
|
|
364
|
+
} catch {
|
|
365
|
+
/* ignore */
|
|
366
|
+
}
|
|
390
367
|
}
|
|
391
|
-
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Handle a kill request for a session (called when backend detects killed status).
|
|
373
|
+
*/
|
|
374
|
+
killSession(sessionId) {
|
|
375
|
+
const session = this.sessions.get(sessionId);
|
|
376
|
+
if (!session) return false;
|
|
377
|
+
try {
|
|
378
|
+
session.process.kill();
|
|
379
|
+
} catch {
|
|
380
|
+
/* may already be dead */
|
|
392
381
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
this.cleanupSession(sessionId);
|
|
419
|
-
}
|
|
382
|
+
this.cleanupSession(sessionId);
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Resize a PTY session.
|
|
387
|
+
*/
|
|
388
|
+
resizeSession(sessionId, cols, rows) {
|
|
389
|
+
const session = this.sessions.get(sessionId);
|
|
390
|
+
if (!session) return false;
|
|
391
|
+
try {
|
|
392
|
+
session.process.resize(cols, rows);
|
|
393
|
+
return true;
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Clean up a session's resources.
|
|
400
|
+
*/
|
|
401
|
+
cleanupSession(sessionId) {
|
|
402
|
+
const session = this.sessions.get(sessionId);
|
|
403
|
+
if (!session) return;
|
|
404
|
+
if (session.batchTimer) {
|
|
405
|
+
clearTimeout(session.batchTimer);
|
|
420
406
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
*/
|
|
424
|
-
get isAvailable() {
|
|
425
|
-
return this.ptyAvailable;
|
|
407
|
+
if (session.inputSubscription) {
|
|
408
|
+
session.inputSubscription();
|
|
426
409
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
410
|
+
this.sessions.delete(sessionId);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Clean up all sessions and stop subscriptions.
|
|
414
|
+
*/
|
|
415
|
+
async cleanup() {
|
|
416
|
+
this.stopSessionSubscription();
|
|
417
|
+
for (const [sessionId, session] of this.sessions) {
|
|
418
|
+
try {
|
|
419
|
+
session.process.kill();
|
|
420
|
+
} catch {
|
|
421
|
+
/* ignore */
|
|
422
|
+
}
|
|
423
|
+
// Report exit
|
|
424
|
+
try {
|
|
425
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
426
|
+
await this.convex.mutation(api.localSandbox.updatePtySessionStatus, {
|
|
427
|
+
token: this.token,
|
|
428
|
+
sessionId,
|
|
429
|
+
status: "exited",
|
|
430
|
+
exitCode: -1,
|
|
431
|
+
});
|
|
432
|
+
} catch {
|
|
433
|
+
/* ignore */
|
|
434
|
+
}
|
|
435
|
+
this.cleanupSession(sessionId);
|
|
432
436
|
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Check if PTY support is available.
|
|
440
|
+
*/
|
|
441
|
+
get isAvailable() {
|
|
442
|
+
return this.ptyAvailable;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Get the number of active sessions.
|
|
446
|
+
*/
|
|
447
|
+
get activeSessionCount() {
|
|
448
|
+
return this.sessions.size;
|
|
449
|
+
}
|
|
433
450
|
}
|
|
434
451
|
exports.PtyManager = PtyManager;
|
|
435
|
-
//# sourceMappingURL=pty-manager.js.map
|
|
452
|
+
//# sourceMappingURL=pty-manager.js.map
|