upfynai-code 2.6.6 → 2.7.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/bin/cli.js +1 -1
- package/package.json +1 -1
- package/src/connect.js +393 -12
- package/src/permissions.js +140 -0
- package/src/persistent-shell.js +261 -0
package/bin/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ const program = new Command();
|
|
|
13
13
|
program
|
|
14
14
|
.name('upfynai-code')
|
|
15
15
|
.description('Launch Upfyn AI coding environment from your terminal')
|
|
16
|
-
.version('2.6.
|
|
16
|
+
.version('2.6.7')
|
|
17
17
|
.option('--local', 'Start a local server instead of opening the hosted app')
|
|
18
18
|
.action(async (options) => {
|
|
19
19
|
if (options.local) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "upfynai-code",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Unified AI coding interface — access AI chat, terminal, file explorer, git, and visual canvas from any browser. Connect your local machine and code from anywhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/connect.js
CHANGED
|
@@ -6,6 +6,139 @@ import path from 'path';
|
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { readConfig, writeConfig, displayUrl } from './config.js';
|
|
8
8
|
import { getToken, validateToken } from './auth.js';
|
|
9
|
+
import { getPersistentShell } from './persistent-shell.js';
|
|
10
|
+
import { needsPermission, requestPermission, handlePermissionResponse } from './permissions.js';
|
|
11
|
+
|
|
12
|
+
// Active process tracking (opencode pattern: activeRequests sync.Map)
|
|
13
|
+
const activeProcesses = new Map(); // requestId → { proc, action }
|
|
14
|
+
|
|
15
|
+
// Active shell sessions for relay terminal (shellSessionId → { proc })
|
|
16
|
+
const activeShellSessions = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Start an interactive shell session on the local machine, relayed to the browser.
|
|
20
|
+
* Spawns a PTY-like process and streams I/O via WebSocket.
|
|
21
|
+
*/
|
|
22
|
+
function handleShellSessionStart(data, ws) {
|
|
23
|
+
const shellSessionId = data.requestId;
|
|
24
|
+
const projectPath = data.projectPath || process.cwd();
|
|
25
|
+
const isWin = process.platform === 'win32';
|
|
26
|
+
|
|
27
|
+
console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
|
|
28
|
+
|
|
29
|
+
// Build the shell command
|
|
30
|
+
let shellCmd, shellArgs;
|
|
31
|
+
const provider = data.provider || 'claude';
|
|
32
|
+
const isPlainShell = data.isPlainShell || (!!data.initialCommand && !data.hasSession) || provider === 'plain-shell';
|
|
33
|
+
|
|
34
|
+
if (isPlainShell && data.initialCommand) {
|
|
35
|
+
// Run a specific command
|
|
36
|
+
if (isWin) {
|
|
37
|
+
shellCmd = 'powershell.exe';
|
|
38
|
+
shellArgs = ['-Command', `Set-Location -Path "${projectPath}"; ${data.initialCommand}`];
|
|
39
|
+
} else {
|
|
40
|
+
shellCmd = 'bash';
|
|
41
|
+
shellArgs = ['-c', `cd "${projectPath}" && ${data.initialCommand}`];
|
|
42
|
+
}
|
|
43
|
+
} else if (isPlainShell) {
|
|
44
|
+
// Interactive shell
|
|
45
|
+
if (isWin) {
|
|
46
|
+
shellCmd = 'powershell.exe';
|
|
47
|
+
shellArgs = ['-NoExit', '-Command', `Set-Location -Path "${projectPath}"`];
|
|
48
|
+
} else {
|
|
49
|
+
shellCmd = process.env.SHELL || 'bash';
|
|
50
|
+
shellArgs = ['--login'];
|
|
51
|
+
}
|
|
52
|
+
} else if (provider === 'cursor') {
|
|
53
|
+
if (isWin) {
|
|
54
|
+
shellCmd = 'powershell.exe';
|
|
55
|
+
const cursorCmd = data.hasSession && data.sessionId
|
|
56
|
+
? `cursor-agent --resume="${data.sessionId}"`
|
|
57
|
+
: 'cursor-agent';
|
|
58
|
+
shellArgs = ['-Command', `Set-Location -Path "${projectPath}"; ${cursorCmd}`];
|
|
59
|
+
} else {
|
|
60
|
+
shellCmd = 'bash';
|
|
61
|
+
const cursorCmd = data.hasSession && data.sessionId
|
|
62
|
+
? `cursor-agent --resume="${data.sessionId}"`
|
|
63
|
+
: 'cursor-agent';
|
|
64
|
+
shellArgs = ['-c', `cd "${projectPath}" && ${cursorCmd}`];
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
// Claude (default)
|
|
68
|
+
const command = data.initialCommand || 'claude';
|
|
69
|
+
if (isWin) {
|
|
70
|
+
shellCmd = 'powershell.exe';
|
|
71
|
+
const claudeCmd = data.hasSession && data.sessionId
|
|
72
|
+
? `claude --resume ${data.sessionId}`
|
|
73
|
+
: command;
|
|
74
|
+
shellArgs = ['-Command', `Set-Location -Path "${projectPath}"; ${claudeCmd}`];
|
|
75
|
+
} else {
|
|
76
|
+
shellCmd = 'bash';
|
|
77
|
+
const claudeCmd = data.hasSession && data.sessionId
|
|
78
|
+
? `claude --resume ${data.sessionId} || claude`
|
|
79
|
+
: command;
|
|
80
|
+
shellArgs = ['-c', `cd "${projectPath}" && ${claudeCmd}`];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const proc = spawn(shellCmd, shellArgs, {
|
|
85
|
+
cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
TERM: 'xterm-256color',
|
|
89
|
+
COLORTERM: 'truecolor',
|
|
90
|
+
FORCE_COLOR: '3',
|
|
91
|
+
},
|
|
92
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
activeShellSessions.set(shellSessionId, { proc, projectPath });
|
|
96
|
+
|
|
97
|
+
// Stream stdout → browser via relay
|
|
98
|
+
proc.stdout.on('data', (chunk) => {
|
|
99
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
100
|
+
ws.send(JSON.stringify({
|
|
101
|
+
type: 'relay-shell-output',
|
|
102
|
+
shellSessionId,
|
|
103
|
+
data: chunk.toString(),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Stream stderr → browser via relay
|
|
109
|
+
proc.stderr.on('data', (chunk) => {
|
|
110
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
111
|
+
ws.send(JSON.stringify({
|
|
112
|
+
type: 'relay-shell-output',
|
|
113
|
+
shellSessionId,
|
|
114
|
+
data: chunk.toString(),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
proc.on('close', (code) => {
|
|
120
|
+
activeShellSessions.delete(shellSessionId);
|
|
121
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
122
|
+
ws.send(JSON.stringify({
|
|
123
|
+
type: 'relay-shell-exited',
|
|
124
|
+
shellSessionId,
|
|
125
|
+
exitCode: code,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
proc.on('error', (err) => {
|
|
132
|
+
activeShellSessions.delete(shellSessionId);
|
|
133
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
134
|
+
ws.send(JSON.stringify({
|
|
135
|
+
type: 'relay-shell-output',
|
|
136
|
+
shellSessionId,
|
|
137
|
+
data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
9
142
|
|
|
10
143
|
/**
|
|
11
144
|
* Execute a shell command and return stdout
|
|
@@ -71,12 +204,25 @@ async function handleRelayCommand(data, ws) {
|
|
|
71
204
|
const { requestId, action } = data;
|
|
72
205
|
|
|
73
206
|
try {
|
|
207
|
+
// Permission gate (opencode pattern: dangerous actions require browser approval)
|
|
208
|
+
if (needsPermission(action, data)) {
|
|
209
|
+
const approved = await requestPermission(ws, requestId, action, data);
|
|
210
|
+
if (!approved) {
|
|
211
|
+
ws.send(JSON.stringify({
|
|
212
|
+
type: 'relay-response', requestId,
|
|
213
|
+
error: 'Permission denied by user',
|
|
214
|
+
}));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
74
219
|
switch (action) {
|
|
75
220
|
case 'claude-query': {
|
|
76
221
|
const { command, options } = data;
|
|
77
222
|
console.log(chalk.cyan(' [relay] Processing Claude query...'));
|
|
78
223
|
|
|
79
|
-
|
|
224
|
+
// Stream-JSON mode for real-time token streaming (opencode pattern)
|
|
225
|
+
const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
|
|
80
226
|
if (options?.projectPath) args.push('--cwd', options.projectPath);
|
|
81
227
|
if (options?.sessionId) args.push('--continue', options.sessionId);
|
|
82
228
|
|
|
@@ -86,29 +232,75 @@ async function handleRelayCommand(data, ws) {
|
|
|
86
232
|
env: process.env,
|
|
87
233
|
});
|
|
88
234
|
|
|
235
|
+
// Track for abort support (opencode pattern: activeRequests)
|
|
236
|
+
activeProcesses.set(requestId, { proc, action: 'claude-query' });
|
|
237
|
+
|
|
238
|
+
let stdoutBuffer = '';
|
|
239
|
+
let capturedSessionId = null;
|
|
240
|
+
|
|
241
|
+
// Parse NDJSON line-by-line (same pattern as cursor-cli.js)
|
|
89
242
|
proc.stdout.on('data', (chunk) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
243
|
+
stdoutBuffer += chunk.toString();
|
|
244
|
+
const lines = stdoutBuffer.split('\n');
|
|
245
|
+
stdoutBuffer = lines.pop(); // keep incomplete last line in buffer
|
|
246
|
+
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
if (!line.trim()) continue;
|
|
249
|
+
try {
|
|
250
|
+
const evt = JSON.parse(line);
|
|
251
|
+
|
|
252
|
+
if (evt.type === 'system' && evt.subtype === 'init') {
|
|
253
|
+
// Capture session ID for resume support
|
|
254
|
+
if (evt.session_id) capturedSessionId = evt.session_id;
|
|
255
|
+
ws.send(JSON.stringify({
|
|
256
|
+
type: 'relay-stream', requestId,
|
|
257
|
+
data: { type: 'claude-system', sessionId: evt.session_id, model: evt.model, cwd: evt.cwd },
|
|
258
|
+
}));
|
|
259
|
+
} else if (evt.type === 'assistant' && evt.message?.content?.length) {
|
|
260
|
+
// Real-time text delta
|
|
261
|
+
const text = evt.message.content[0].text || '';
|
|
262
|
+
ws.send(JSON.stringify({
|
|
263
|
+
type: 'relay-stream', requestId,
|
|
264
|
+
data: { type: 'claude-response', content: text },
|
|
265
|
+
}));
|
|
266
|
+
} else if (evt.type === 'result') {
|
|
267
|
+
// Session complete — include captured session ID
|
|
268
|
+
ws.send(JSON.stringify({
|
|
269
|
+
type: 'relay-stream', requestId,
|
|
270
|
+
data: { type: 'claude-result', sessionId: capturedSessionId, subtype: evt.subtype },
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
// Non-JSON line — send as raw text
|
|
275
|
+
if (line.trim()) {
|
|
276
|
+
ws.send(JSON.stringify({
|
|
277
|
+
type: 'relay-stream', requestId,
|
|
278
|
+
data: { type: 'claude-response', content: line },
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
95
283
|
});
|
|
96
284
|
|
|
97
285
|
proc.stderr.on('data', (chunk) => {
|
|
98
286
|
ws.send(JSON.stringify({
|
|
99
|
-
type: 'relay-stream',
|
|
100
|
-
requestId,
|
|
287
|
+
type: 'relay-stream', requestId,
|
|
101
288
|
data: { type: 'claude-error', content: chunk.toString() },
|
|
102
289
|
}));
|
|
103
290
|
});
|
|
104
291
|
|
|
105
292
|
proc.on('close', (code) => {
|
|
293
|
+
activeProcesses.delete(requestId);
|
|
106
294
|
ws.send(JSON.stringify({
|
|
107
|
-
type: 'relay-complete',
|
|
108
|
-
requestId,
|
|
295
|
+
type: 'relay-complete', requestId,
|
|
109
296
|
exitCode: code,
|
|
297
|
+
sessionId: capturedSessionId,
|
|
110
298
|
}));
|
|
111
299
|
});
|
|
300
|
+
|
|
301
|
+
proc.on('error', () => {
|
|
302
|
+
activeProcesses.delete(requestId);
|
|
303
|
+
});
|
|
112
304
|
break;
|
|
113
305
|
}
|
|
114
306
|
|
|
@@ -123,8 +315,13 @@ async function handleRelayCommand(data, ws) {
|
|
|
123
315
|
];
|
|
124
316
|
if (dangerous.some(d => cmdLower.includes(d.toLowerCase()))) throw new Error('Command blocked for safety');
|
|
125
317
|
console.log(chalk.dim(' [relay] Executing shell command...'));
|
|
126
|
-
|
|
127
|
-
|
|
318
|
+
// Persistent shell singleton (opencode pattern: commands share one shell process)
|
|
319
|
+
const shell = getPersistentShell(cwd || process.cwd());
|
|
320
|
+
const result = await shell.exec(cmd, { timeoutMs: 60000 });
|
|
321
|
+
ws.send(JSON.stringify({
|
|
322
|
+
type: 'relay-response', requestId,
|
|
323
|
+
data: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, cwd: result.cwd },
|
|
324
|
+
}));
|
|
128
325
|
break;
|
|
129
326
|
}
|
|
130
327
|
|
|
@@ -166,6 +363,70 @@ async function handleRelayCommand(data, ws) {
|
|
|
166
363
|
break;
|
|
167
364
|
}
|
|
168
365
|
|
|
366
|
+
case 'browse-dirs': {
|
|
367
|
+
// Browse directories on user's local machine
|
|
368
|
+
const { dirPath: browsePath } = data;
|
|
369
|
+
const os = await import('os');
|
|
370
|
+
let targetDir = browsePath || os.default.homedir();
|
|
371
|
+
if (targetDir === '~') targetDir = os.default.homedir();
|
|
372
|
+
else if (targetDir.startsWith('~/') || targetDir.startsWith('~\\')) targetDir = path.join(os.default.homedir(), targetDir.slice(2));
|
|
373
|
+
targetDir = path.resolve(targetDir);
|
|
374
|
+
|
|
375
|
+
let dirs = [];
|
|
376
|
+
// On Windows, detect available drives to show alongside directory listing
|
|
377
|
+
let drives = [];
|
|
378
|
+
if (process.platform === 'win32') {
|
|
379
|
+
try {
|
|
380
|
+
const { execSync } = await import('child_process');
|
|
381
|
+
const wmicOut = execSync('wmic logicaldisk get name', { encoding: 'utf8', timeout: 5000 });
|
|
382
|
+
drives = wmicOut.split('\n')
|
|
383
|
+
.map(l => l.trim())
|
|
384
|
+
.filter(l => /^[A-Z]:$/.test(l))
|
|
385
|
+
.map(d => ({ name: d + '\\', path: d + '\\', type: 'drive' }));
|
|
386
|
+
} catch { /* ignore — wmic not available */ }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
|
|
390
|
+
dirs = entries
|
|
391
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
392
|
+
.map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
|
|
393
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
394
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, drives, homedir: os.default.homedir() } }));
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
case 'validate-path': {
|
|
399
|
+
// Check if a path exists on user's machine
|
|
400
|
+
const { targetPath } = data;
|
|
401
|
+
const os2 = await import('os');
|
|
402
|
+
let checkPath = targetPath || '';
|
|
403
|
+
if (checkPath === '~') checkPath = os2.default.homedir();
|
|
404
|
+
else if (checkPath.startsWith('~/') || checkPath.startsWith('~\\')) checkPath = path.join(os2.default.homedir(), checkPath.slice(2));
|
|
405
|
+
checkPath = path.resolve(checkPath);
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const stats = await fsPromises.stat(checkPath);
|
|
409
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: true, isDirectory: stats.isDirectory(), resolvedPath: checkPath } }));
|
|
410
|
+
} catch {
|
|
411
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: false, resolvedPath: checkPath } }));
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case 'create-folder': {
|
|
417
|
+
// Create a directory on user's machine
|
|
418
|
+
const { folderPath } = data;
|
|
419
|
+
const os3 = await import('os');
|
|
420
|
+
let mkPath = folderPath || '';
|
|
421
|
+
if (mkPath === '~') mkPath = os3.default.homedir();
|
|
422
|
+
else if (mkPath.startsWith('~/') || mkPath.startsWith('~\\')) mkPath = path.join(os3.default.homedir(), mkPath.slice(2));
|
|
423
|
+
mkPath = path.resolve(mkPath);
|
|
424
|
+
|
|
425
|
+
await fsPromises.mkdir(mkPath, { recursive: true });
|
|
426
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true, path: mkPath } }));
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
169
430
|
case 'git-operation': {
|
|
170
431
|
const { gitCommand, cwd: gitCwd } = data;
|
|
171
432
|
console.log(chalk.dim(' [relay] Running git operation...'));
|
|
@@ -175,6 +436,68 @@ async function handleRelayCommand(data, ws) {
|
|
|
175
436
|
break;
|
|
176
437
|
}
|
|
177
438
|
|
|
439
|
+
// Sub-agent: read-only research agent (opencode pattern: AgentTask)
|
|
440
|
+
// Spawns a separate claude process with --allowedTools limited to read-only
|
|
441
|
+
case 'claude-task-query': {
|
|
442
|
+
const { command: taskCmd, options: taskOpts } = data;
|
|
443
|
+
console.log(chalk.cyan(' [relay] Spawning sub-agent for research...'));
|
|
444
|
+
|
|
445
|
+
const taskArgs = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
|
|
446
|
+
// Sub-agents are read-only: use --allowedTools to restrict
|
|
447
|
+
taskArgs.push('--allowedTools', 'View,Glob,Grep,LS,Read');
|
|
448
|
+
if (taskOpts?.projectPath) taskArgs.push('--cwd', taskOpts.projectPath);
|
|
449
|
+
// Sub-agents always start fresh (no --continue)
|
|
450
|
+
|
|
451
|
+
const taskProc = spawn('claude', [...taskArgs, taskCmd || ''], {
|
|
452
|
+
shell: true,
|
|
453
|
+
cwd: taskOpts?.projectPath || os.homedir(),
|
|
454
|
+
env: process.env,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
activeProcesses.set(requestId, { proc: taskProc, action: 'claude-task-query' });
|
|
458
|
+
let taskBuffer = '';
|
|
459
|
+
|
|
460
|
+
taskProc.stdout.on('data', (chunk) => {
|
|
461
|
+
taskBuffer += chunk.toString();
|
|
462
|
+
const lines = taskBuffer.split('\n');
|
|
463
|
+
taskBuffer = lines.pop();
|
|
464
|
+
for (const line of lines) {
|
|
465
|
+
if (!line.trim()) continue;
|
|
466
|
+
try {
|
|
467
|
+
const evt = JSON.parse(line);
|
|
468
|
+
if (evt.type === 'assistant' && evt.message?.content?.length) {
|
|
469
|
+
ws.send(JSON.stringify({
|
|
470
|
+
type: 'relay-stream', requestId,
|
|
471
|
+
data: { type: 'claude-response', content: evt.message.content[0].text || '' },
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
if (line.trim()) {
|
|
476
|
+
ws.send(JSON.stringify({
|
|
477
|
+
type: 'relay-stream', requestId,
|
|
478
|
+
data: { type: 'claude-response', content: line },
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
taskProc.stderr.on('data', (chunk) => {
|
|
486
|
+
ws.send(JSON.stringify({
|
|
487
|
+
type: 'relay-stream', requestId,
|
|
488
|
+
data: { type: 'claude-error', content: chunk.toString() },
|
|
489
|
+
}));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
taskProc.on('close', (code) => {
|
|
493
|
+
activeProcesses.delete(requestId);
|
|
494
|
+
ws.send(JSON.stringify({ type: 'relay-complete', requestId, exitCode: code }));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
taskProc.on('error', () => { activeProcesses.delete(requestId); });
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
|
|
178
501
|
default:
|
|
179
502
|
ws.send(JSON.stringify({
|
|
180
503
|
type: 'relay-response',
|
|
@@ -250,6 +573,19 @@ export async function connect(options = {}) {
|
|
|
250
573
|
console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
|
|
251
574
|
console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
|
|
252
575
|
|
|
576
|
+
// Send initial working directory so it becomes the default project
|
|
577
|
+
const cwd = process.cwd();
|
|
578
|
+
const dirName = path.basename(cwd);
|
|
579
|
+
ws.send(JSON.stringify({
|
|
580
|
+
type: 'relay-init',
|
|
581
|
+
cwd,
|
|
582
|
+
dirName,
|
|
583
|
+
homedir: os.homedir(),
|
|
584
|
+
platform: process.platform,
|
|
585
|
+
hostname: os.hostname(),
|
|
586
|
+
}));
|
|
587
|
+
console.log(chalk.dim(` Default project: ${cwd}\n`));
|
|
588
|
+
|
|
253
589
|
const heartbeat = setInterval(() => {
|
|
254
590
|
if (ws.readyState === WebSocket.OPEN) {
|
|
255
591
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
@@ -271,6 +607,51 @@ export async function connect(options = {}) {
|
|
|
271
607
|
handleRelayCommand(data, ws);
|
|
272
608
|
return;
|
|
273
609
|
}
|
|
610
|
+
// Abort handler (opencode pattern: cancel via context propagation)
|
|
611
|
+
if (data.type === 'relay-abort') {
|
|
612
|
+
const entry = activeProcesses.get(data.requestId);
|
|
613
|
+
if (entry?.proc) {
|
|
614
|
+
entry.proc.kill('SIGTERM');
|
|
615
|
+
activeProcesses.delete(data.requestId);
|
|
616
|
+
ws.send(JSON.stringify({
|
|
617
|
+
type: 'relay-complete', requestId: data.requestId,
|
|
618
|
+
exitCode: -1, aborted: true,
|
|
619
|
+
}));
|
|
620
|
+
console.log(chalk.yellow(' [relay] Process aborted by user'));
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// Permission response from browser (opencode pattern: grant/deny flow)
|
|
625
|
+
if (data.type === 'relay-permission-response') {
|
|
626
|
+
handlePermissionResponse(data);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// ── Relay Shell: interactive terminal on local machine ──────────
|
|
630
|
+
if (data.type === 'relay-command' && data.action === 'shell-session-start') {
|
|
631
|
+
handleShellSessionStart(data, ws);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (data.type === 'relay-shell-input') {
|
|
635
|
+
const session = activeShellSessions.get(data.shellSessionId);
|
|
636
|
+
if (session?.proc?.stdin?.writable) {
|
|
637
|
+
session.proc.stdin.write(data.data);
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (data.type === 'relay-shell-resize') {
|
|
642
|
+
// PTY resize not available with basic spawn — ignored for non-pty
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (data.type === 'relay-shell-kill') {
|
|
646
|
+
const session = activeShellSessions.get(data.shellSessionId);
|
|
647
|
+
if (session?.proc) {
|
|
648
|
+
session.proc.kill('SIGTERM');
|
|
649
|
+
activeShellSessions.delete(data.shellSessionId);
|
|
650
|
+
console.log(chalk.dim(' [relay] Shell session killed'));
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
274
655
|
if (data.type === 'pong') return;
|
|
275
656
|
if (data.type === 'error') {
|
|
276
657
|
console.error(chalk.red(` Server error: ${data.error}`));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-Gated Tool Execution
|
|
3
|
+
* Ported from opencode-ai/opencode internal/permission/permission.go
|
|
4
|
+
*
|
|
5
|
+
* Splits relay actions into safe (auto-approve) and dangerous (require browser approval).
|
|
6
|
+
* Permission requests are sent to the server → browser, and we wait for the response.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Safe actions — auto-approved, read-only (opencode pattern: tools without permission flag)
|
|
10
|
+
const SAFE_ACTIONS = new Set([
|
|
11
|
+
'file-read',
|
|
12
|
+
'file-tree',
|
|
13
|
+
'browse-dirs',
|
|
14
|
+
'validate-path',
|
|
15
|
+
'detect-agents',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
// Dangerous actions — require user approval (opencode pattern: tools with permission flag)
|
|
19
|
+
const DANGEROUS_ACTIONS = new Set([
|
|
20
|
+
'shell-command',
|
|
21
|
+
'file-write',
|
|
22
|
+
'create-folder',
|
|
23
|
+
'git-operation',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Safe shell command prefixes — auto-approved even within shell-command
|
|
27
|
+
// (opencode pattern: safe bash commands bypass permission)
|
|
28
|
+
const SAFE_SHELL_PREFIXES = [
|
|
29
|
+
'ls', 'dir', 'pwd', 'echo', 'cat', 'head', 'tail', 'wc',
|
|
30
|
+
'git status', 'git log', 'git diff', 'git branch', 'git remote',
|
|
31
|
+
'node --version', 'npm --version', 'python --version',
|
|
32
|
+
'which', 'where', 'type', 'whoami', 'hostname',
|
|
33
|
+
'date', 'uptime', 'df', 'free',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Pending permission requests: requestId → { resolve }
|
|
37
|
+
const pendingPermissions = new Map();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if an action needs user permission.
|
|
41
|
+
* @param {string} action - Relay action type
|
|
42
|
+
* @param {object} payload - Action payload
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function needsPermission(action, payload) {
|
|
46
|
+
if (SAFE_ACTIONS.has(action)) return false;
|
|
47
|
+
if (!DANGEROUS_ACTIONS.has(action)) return false; // unknown actions handled elsewhere
|
|
48
|
+
|
|
49
|
+
// Shell commands: check if it's a safe read-only command
|
|
50
|
+
if (action === 'shell-command' && payload?.command) {
|
|
51
|
+
const cmd = payload.command.trim().toLowerCase();
|
|
52
|
+
if (SAFE_SHELL_PREFIXES.some(prefix => cmd.startsWith(prefix))) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Request permission from the browser via the relay WebSocket.
|
|
62
|
+
* Blocks until the user approves or denies.
|
|
63
|
+
*
|
|
64
|
+
* @param {WebSocket} ws - Relay WebSocket connection
|
|
65
|
+
* @param {string} requestId - Relay request ID
|
|
66
|
+
* @param {string} action - Action being requested
|
|
67
|
+
* @param {object} payload - Action payload
|
|
68
|
+
* @param {number} timeoutMs - Timeout for waiting (default 60s)
|
|
69
|
+
* @returns {Promise<boolean>} - true if approved, false if denied
|
|
70
|
+
*/
|
|
71
|
+
export function requestPermission(ws, requestId, action, payload, timeoutMs = 60000) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const permId = `perm-${requestId}`;
|
|
74
|
+
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
pendingPermissions.delete(permId);
|
|
77
|
+
resolve(false); // Timeout = deny
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
|
|
80
|
+
pendingPermissions.set(permId, {
|
|
81
|
+
resolve: (approved) => {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
pendingPermissions.delete(permId);
|
|
84
|
+
resolve(approved);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Send permission request to server → browser
|
|
89
|
+
ws.send(JSON.stringify({
|
|
90
|
+
type: 'relay-permission-request',
|
|
91
|
+
requestId,
|
|
92
|
+
permissionId: permId,
|
|
93
|
+
action,
|
|
94
|
+
description: describeAction(action, payload),
|
|
95
|
+
payload: sanitizePayload(action, payload),
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Handle a permission response from the server.
|
|
102
|
+
* @param {object} data - { permissionId, approved }
|
|
103
|
+
*/
|
|
104
|
+
export function handlePermissionResponse(data) {
|
|
105
|
+
const pending = pendingPermissions.get(data.permissionId);
|
|
106
|
+
if (pending) {
|
|
107
|
+
pending.resolve(Boolean(data.approved));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate a human-readable description of the action.
|
|
113
|
+
*/
|
|
114
|
+
function describeAction(action, payload) {
|
|
115
|
+
switch (action) {
|
|
116
|
+
case 'shell-command':
|
|
117
|
+
return `Execute command: ${payload?.command || '(unknown)'}`;
|
|
118
|
+
case 'file-write':
|
|
119
|
+
return `Write to file: ${payload?.filePath || '(unknown)'}`;
|
|
120
|
+
case 'create-folder':
|
|
121
|
+
return `Create folder: ${payload?.folderPath || '(unknown)'}`;
|
|
122
|
+
case 'git-operation':
|
|
123
|
+
return `Run git: ${payload?.gitCommand || '(unknown)'}`;
|
|
124
|
+
default:
|
|
125
|
+
return `${action}: ${JSON.stringify(payload || {}).slice(0, 200)}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sanitize payload for display — remove large content fields.
|
|
131
|
+
*/
|
|
132
|
+
function sanitizePayload(action, payload) {
|
|
133
|
+
if (!payload) return {};
|
|
134
|
+
const safe = { ...payload };
|
|
135
|
+
// Don't send file content to browser for permission display
|
|
136
|
+
if (safe.content && safe.content.length > 500) {
|
|
137
|
+
safe.content = safe.content.slice(0, 500) + `... (${safe.content.length} chars total)`;
|
|
138
|
+
}
|
|
139
|
+
return safe;
|
|
140
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent Shell Singleton
|
|
3
|
+
* Ported from opencode-ai/opencode internal/llm/tools/shell/shell.go
|
|
4
|
+
*
|
|
5
|
+
* Maintains a single long-running shell process across commands.
|
|
6
|
+
* - Commands are queued and executed sequentially
|
|
7
|
+
* - Environment and cwd persist between commands
|
|
8
|
+
* - Child processes can be killed on abort
|
|
9
|
+
* - Shell respawns automatically if it dies
|
|
10
|
+
*/
|
|
11
|
+
import { spawn, execSync } from 'child_process';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { promises as fs } from 'fs';
|
|
15
|
+
|
|
16
|
+
let shellInstance = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get or create the persistent shell singleton.
|
|
20
|
+
* @param {string} workingDir - Initial working directory
|
|
21
|
+
* @returns {PersistentShell}
|
|
22
|
+
*/
|
|
23
|
+
export function getPersistentShell(workingDir) {
|
|
24
|
+
if (!shellInstance || !shellInstance.isAlive) {
|
|
25
|
+
shellInstance = new PersistentShell(workingDir || os.homedir());
|
|
26
|
+
}
|
|
27
|
+
return shellInstance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class PersistentShell {
|
|
31
|
+
constructor(cwd) {
|
|
32
|
+
this.cwd = cwd;
|
|
33
|
+
this.isAlive = false;
|
|
34
|
+
this.commandQueue = [];
|
|
35
|
+
this.processing = false;
|
|
36
|
+
this.proc = null;
|
|
37
|
+
this.stdin = null;
|
|
38
|
+
this._start();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_start() {
|
|
42
|
+
const isWin = process.platform === 'win32';
|
|
43
|
+
const shellPath = isWin
|
|
44
|
+
? process.env.COMSPEC || 'cmd.exe'
|
|
45
|
+
: process.env.SHELL || '/bin/bash';
|
|
46
|
+
const shellArgs = isWin ? ['/Q'] : ['-l'];
|
|
47
|
+
|
|
48
|
+
this.proc = spawn(shellPath, shellArgs, {
|
|
49
|
+
cwd: this.cwd,
|
|
50
|
+
env: { ...process.env, GIT_EDITOR: 'true' },
|
|
51
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
|
+
shell: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.stdin = this.proc.stdin;
|
|
56
|
+
this.isAlive = true;
|
|
57
|
+
|
|
58
|
+
this.proc.on('close', () => {
|
|
59
|
+
this.isAlive = false;
|
|
60
|
+
// Reject any queued commands
|
|
61
|
+
for (const queued of this.commandQueue) {
|
|
62
|
+
queued.reject(new Error('Shell process died'));
|
|
63
|
+
}
|
|
64
|
+
this.commandQueue = [];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.proc.on('error', () => {
|
|
68
|
+
this.isAlive = false;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Execute a command in the persistent shell.
|
|
74
|
+
* Queued and executed sequentially (opencode pattern: commandQueue channel).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} command - Shell command to execute
|
|
77
|
+
* @param {object} opts - { timeoutMs, abortSignal }
|
|
78
|
+
* @returns {Promise<{ stdout, stderr, exitCode, interrupted, cwd }>}
|
|
79
|
+
*/
|
|
80
|
+
exec(command, opts = {}) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
this.commandQueue.push({ command, opts, resolve, reject });
|
|
83
|
+
this._processNext();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _processNext() {
|
|
88
|
+
if (this.processing || this.commandQueue.length === 0) return;
|
|
89
|
+
this.processing = true;
|
|
90
|
+
|
|
91
|
+
const { command, opts, resolve, reject } = this.commandQueue.shift();
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const result = await this._execCommand(command, opts);
|
|
95
|
+
resolve(result);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
reject(err);
|
|
98
|
+
} finally {
|
|
99
|
+
this.processing = false;
|
|
100
|
+
// Process next in queue
|
|
101
|
+
if (this.commandQueue.length > 0) {
|
|
102
|
+
this._processNext();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async _execCommand(command, { timeoutMs = 60000, abortSignal } = {}) {
|
|
108
|
+
if (!this.isAlive) {
|
|
109
|
+
throw new Error('Shell is not alive');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isWin = process.platform === 'win32';
|
|
113
|
+
const tmpDir = os.tmpdir();
|
|
114
|
+
const ts = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
115
|
+
const stdoutFile = path.join(tmpDir, `uc-stdout-${ts}`);
|
|
116
|
+
const stderrFile = path.join(tmpDir, `uc-stderr-${ts}`);
|
|
117
|
+
const statusFile = path.join(tmpDir, `uc-status-${ts}`);
|
|
118
|
+
const cwdFile = path.join(tmpDir, `uc-cwd-${ts}`);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// Build the shell command that redirects output to temp files
|
|
122
|
+
// Exact same pattern as opencode's shell.go execCommand
|
|
123
|
+
let fullCommand;
|
|
124
|
+
if (isWin) {
|
|
125
|
+
// Windows cmd.exe variant
|
|
126
|
+
fullCommand = [
|
|
127
|
+
`${command} > "${stdoutFile}" 2> "${stderrFile}"`,
|
|
128
|
+
`echo %ERRORLEVEL% > "${statusFile}"`,
|
|
129
|
+
`cd > "${cwdFile}"`,
|
|
130
|
+
].join(' & ');
|
|
131
|
+
} else {
|
|
132
|
+
// Unix bash variant (identical to opencode)
|
|
133
|
+
const escaped = command.replace(/'/g, "'\\''");
|
|
134
|
+
fullCommand = [
|
|
135
|
+
`eval '${escaped}' < /dev/null > '${stdoutFile}' 2> '${stderrFile}'`,
|
|
136
|
+
`EXEC_EXIT_CODE=$?`,
|
|
137
|
+
`pwd > '${cwdFile}'`,
|
|
138
|
+
`echo $EXEC_EXIT_CODE > '${statusFile}'`,
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.stdin.write(fullCommand + '\n');
|
|
143
|
+
|
|
144
|
+
// Poll for status file (same polling pattern as opencode)
|
|
145
|
+
let interrupted = false;
|
|
146
|
+
const startTime = Date.now();
|
|
147
|
+
|
|
148
|
+
await new Promise((done, fail) => {
|
|
149
|
+
const poll = setInterval(async () => {
|
|
150
|
+
// Check abort
|
|
151
|
+
if (abortSignal?.aborted) {
|
|
152
|
+
this.killChildren();
|
|
153
|
+
interrupted = true;
|
|
154
|
+
clearInterval(poll);
|
|
155
|
+
done();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check timeout
|
|
160
|
+
if (timeoutMs > 0 && Date.now() - startTime > timeoutMs) {
|
|
161
|
+
this.killChildren();
|
|
162
|
+
interrupted = true;
|
|
163
|
+
clearInterval(poll);
|
|
164
|
+
done();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if status file exists and has content
|
|
169
|
+
try {
|
|
170
|
+
const stat = await fs.stat(statusFile);
|
|
171
|
+
if (stat.size > 0) {
|
|
172
|
+
clearInterval(poll);
|
|
173
|
+
done();
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// File doesn't exist yet
|
|
177
|
+
}
|
|
178
|
+
}, 50);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Read results from temp files
|
|
182
|
+
const stdout = await this._readFileOrEmpty(stdoutFile);
|
|
183
|
+
const stderr = await this._readFileOrEmpty(stderrFile);
|
|
184
|
+
const exitCodeStr = await this._readFileOrEmpty(statusFile);
|
|
185
|
+
const newCwd = await this._readFileOrEmpty(cwdFile);
|
|
186
|
+
|
|
187
|
+
let exitCode = 0;
|
|
188
|
+
if (exitCodeStr.trim()) {
|
|
189
|
+
exitCode = parseInt(exitCodeStr.trim(), 10) || 0;
|
|
190
|
+
} else if (interrupted) {
|
|
191
|
+
exitCode = 143;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (newCwd.trim()) {
|
|
195
|
+
this.cwd = newCwd.trim();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
stdout: stdout,
|
|
200
|
+
stderr: interrupted ? stderr + '\nCommand execution timed out or was interrupted' : stderr,
|
|
201
|
+
exitCode,
|
|
202
|
+
interrupted,
|
|
203
|
+
cwd: this.cwd,
|
|
204
|
+
};
|
|
205
|
+
} finally {
|
|
206
|
+
// Cleanup temp files
|
|
207
|
+
await Promise.all([
|
|
208
|
+
fs.unlink(stdoutFile).catch(() => {}),
|
|
209
|
+
fs.unlink(stderrFile).catch(() => {}),
|
|
210
|
+
fs.unlink(statusFile).catch(() => {}),
|
|
211
|
+
fs.unlink(cwdFile).catch(() => {}),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Kill child processes of the shell (opencode pattern: killChildren).
|
|
218
|
+
* On Unix: pgrep -P <pid> → SIGTERM each child.
|
|
219
|
+
* On Windows: taskkill /PID /T.
|
|
220
|
+
*/
|
|
221
|
+
killChildren() {
|
|
222
|
+
if (!this.proc?.pid) return;
|
|
223
|
+
try {
|
|
224
|
+
if (process.platform === 'win32') {
|
|
225
|
+
execSync(`taskkill /PID ${this.proc.pid} /T /F`, { stdio: 'ignore', timeout: 5000 });
|
|
226
|
+
// Respawn since taskkill kills the parent too on Windows
|
|
227
|
+
this._start();
|
|
228
|
+
} else {
|
|
229
|
+
const output = execSync(`pgrep -P ${this.proc.pid}`, { encoding: 'utf8', timeout: 5000 });
|
|
230
|
+
for (const line of output.split('\n')) {
|
|
231
|
+
const pid = parseInt(line.trim(), 10);
|
|
232
|
+
if (pid > 0) {
|
|
233
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// pgrep/taskkill failed — no children to kill
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Close the persistent shell.
|
|
244
|
+
*/
|
|
245
|
+
close() {
|
|
246
|
+
if (!this.isAlive) return;
|
|
247
|
+
try {
|
|
248
|
+
this.stdin.write('exit\n');
|
|
249
|
+
this.proc.kill('SIGTERM');
|
|
250
|
+
} catch { /* ignore */ }
|
|
251
|
+
this.isAlive = false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async _readFileOrEmpty(filePath) {
|
|
255
|
+
try {
|
|
256
|
+
return await fs.readFile(filePath, 'utf8');
|
|
257
|
+
} catch {
|
|
258
|
+
return '';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|