bortexcode 1.3.0 → 1.5.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/README.md +19 -0
- package/bin/bortex.js +426 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,14 +94,30 @@ For remote access without opening an inbound port, use the bortex.site relay:
|
|
|
94
94
|
```bash
|
|
95
95
|
bortexcode remote-control --cloud --name "My Project"
|
|
96
96
|
bortexcode --remote-cloud "My Project"
|
|
97
|
+
bortexcode remote-control --cloud --remote-permission full
|
|
97
98
|
```
|
|
98
99
|
|
|
100
|
+
Cloud Remote Control defaults to `balanced` permissions. It allows inspection,
|
|
101
|
+
status commands, and common test/lint/build shell commands while blocking
|
|
102
|
+
mutating commands such as file writes, patch apply, git stage/commit/discard,
|
|
103
|
+
and arbitrary shell mutations. Use `--remote-permission read-only` for
|
|
104
|
+
inspection-only sessions or `--remote-permission full` only for trusted sessions.
|
|
105
|
+
|
|
106
|
+
The browser UI includes Cancel and Revoke controls. Cancel requests termination
|
|
107
|
+
of the running command. Revoke invalidates the tokenized URL and stops the
|
|
108
|
+
server-mode cloud session.
|
|
109
|
+
|
|
110
|
+
Remote output streams live for shell commands and CLI output: stdout/stderr
|
|
111
|
+
chunks appear in the browser while the command is still running, then the final
|
|
112
|
+
result replaces the streaming buffer when the command completes.
|
|
113
|
+
|
|
99
114
|
Inside the REPL:
|
|
100
115
|
|
|
101
116
|
```text
|
|
102
117
|
/remote-control My Project
|
|
103
118
|
/remote-control --lan
|
|
104
119
|
/remote-control --cloud
|
|
120
|
+
/remote-control --cloud --permission full
|
|
105
121
|
/remote-control stop
|
|
106
122
|
```
|
|
107
123
|
|
|
@@ -129,6 +145,8 @@ Inside the REPL:
|
|
|
129
145
|
Use bortex.site relay instead of opening a local port
|
|
130
146
|
--remote-relay <url>
|
|
131
147
|
Remote relay base URL
|
|
148
|
+
--remote-permission <mode>
|
|
149
|
+
Remote permissions: read-only, balanced, full
|
|
132
150
|
--remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access
|
|
133
151
|
--remote-host <host>, --remote-port <port>
|
|
134
152
|
Remote control bind address
|
|
@@ -156,4 +174,5 @@ BORTEX_NO_UPDATE_CHECK=1
|
|
|
156
174
|
BORTEX_REMOTE_HOST
|
|
157
175
|
BORTEX_REMOTE_PORT
|
|
158
176
|
BORTEX_REMOTE_RELAY_URL
|
|
177
|
+
BORTEX_REMOTE_PERMISSION
|
|
159
178
|
```
|
package/bin/bortex.js
CHANGED
|
@@ -35,6 +35,7 @@ function parseArgs(argv) {
|
|
|
35
35
|
remoteControlServerMode: false,
|
|
36
36
|
remoteControlCloud: false,
|
|
37
37
|
remoteControlRelayUrl: process.env.BORTEX_REMOTE_RELAY_URL || '',
|
|
38
|
+
remoteControlPermission: process.env.BORTEX_REMOTE_PERMISSION || 'balanced',
|
|
38
39
|
remoteControlName: '',
|
|
39
40
|
remoteControlHost: process.env.BORTEX_REMOTE_HOST || '127.0.0.1',
|
|
40
41
|
remoteControlPort: Number(process.env.BORTEX_REMOTE_PORT || 0) || 0,
|
|
@@ -178,6 +179,19 @@ function parseArgs(argv) {
|
|
|
178
179
|
opts.remoteControlRelayUrl = a.slice('--relay-url='.length);
|
|
179
180
|
continue;
|
|
180
181
|
}
|
|
182
|
+
if ((a === '--remote-permission' || a === '--permission') && argv[i + 1]) {
|
|
183
|
+
opts.remoteControlPermission = argv[i + 1];
|
|
184
|
+
i += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (a.startsWith('--remote-permission=')) {
|
|
188
|
+
opts.remoteControlPermission = a.slice('--remote-permission='.length);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (a.startsWith('--permission=')) {
|
|
192
|
+
opts.remoteControlPermission = a.slice('--permission='.length);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
181
195
|
if (a === '--remote-lan') {
|
|
182
196
|
opts.remoteControlHost = '0.0.0.0';
|
|
183
197
|
continue;
|
|
@@ -251,6 +265,8 @@ function usage() {
|
|
|
251
265
|
console.log(' Use bortex.site relay instead of opening a local port');
|
|
252
266
|
console.log(' --remote-relay <url>');
|
|
253
267
|
console.log(' Remote relay base URL (default: current --url)');
|
|
268
|
+
console.log(' --remote-permission <mode>');
|
|
269
|
+
console.log(' Remote permissions: read-only, balanced, full');
|
|
254
270
|
console.log(' --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access');
|
|
255
271
|
console.log(' --remote-host <host>, --remote-port <port>');
|
|
256
272
|
console.log(' Remote control bind address');
|
|
@@ -264,6 +280,7 @@ function usage() {
|
|
|
264
280
|
console.log(' BORTEX_URL Server URL');
|
|
265
281
|
console.log(' BORTEX_API_KEY API key');
|
|
266
282
|
console.log(' BORTEX_REMOTE_RELAY_URL Remote Control cloud relay URL');
|
|
283
|
+
console.log(' BORTEX_REMOTE_PERMISSION Remote permissions: read-only, balanced, full');
|
|
267
284
|
}
|
|
268
285
|
|
|
269
286
|
function formatMs(ms) {
|
|
@@ -3931,8 +3948,31 @@ async function runToolRunCommand(opts, line) {
|
|
|
3931
3948
|
});
|
|
3932
3949
|
}
|
|
3933
3950
|
|
|
3951
|
+
let ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = null;
|
|
3952
|
+
|
|
3953
|
+
function terminateChildProcessTree(child) {
|
|
3954
|
+
if (!child || !child.pid) return;
|
|
3955
|
+
if (process.platform === 'win32') {
|
|
3956
|
+
try {
|
|
3957
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
3958
|
+
stdio: 'ignore',
|
|
3959
|
+
windowsHide: true
|
|
3960
|
+
});
|
|
3961
|
+
return;
|
|
3962
|
+
} catch (_err) {}
|
|
3963
|
+
}
|
|
3964
|
+
try { child.kill('SIGTERM'); } catch (_err) {}
|
|
3965
|
+
setTimeout(() => {
|
|
3966
|
+
try { child.kill('SIGKILL'); } catch (_err) {}
|
|
3967
|
+
}, 1500).unref?.();
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3934
3970
|
function runChild(command, args, { cwd, shell = false } = {}) {
|
|
3935
3971
|
return new Promise((resolve) => {
|
|
3972
|
+
const cancelState = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
|
|
3973
|
+
if (cancelState?.cancelRequested || cancelState?.revoked || cancelState?.active === false) {
|
|
3974
|
+
return resolve({ ok: false, code: null, stdout: '', stderr: 'Canceled by remote request.' });
|
|
3975
|
+
}
|
|
3936
3976
|
const child = spawn(command, args, {
|
|
3937
3977
|
cwd: cwd || process.cwd(),
|
|
3938
3978
|
shell,
|
|
@@ -3941,10 +3981,45 @@ function runChild(command, args, { cwd, shell = false } = {}) {
|
|
|
3941
3981
|
});
|
|
3942
3982
|
let stdout = '';
|
|
3943
3983
|
let stderr = '';
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3984
|
+
let settled = false;
|
|
3985
|
+
let canceled = false;
|
|
3986
|
+
const emitChunk = (stream, text) => {
|
|
3987
|
+
try {
|
|
3988
|
+
if (cancelState && typeof cancelState.onChunk === 'function') {
|
|
3989
|
+
cancelState.onChunk(stream, text);
|
|
3990
|
+
}
|
|
3991
|
+
} catch (_err) {}
|
|
3992
|
+
};
|
|
3993
|
+
const finish = (result) => {
|
|
3994
|
+
if (settled) return;
|
|
3995
|
+
settled = true;
|
|
3996
|
+
if (cancelTimer) clearInterval(cancelTimer);
|
|
3997
|
+
resolve(result);
|
|
3998
|
+
};
|
|
3999
|
+
const cancelTimer = cancelState ? setInterval(() => {
|
|
4000
|
+
if (cancelState.cancelRequested || cancelState.revoked || cancelState.active === false) {
|
|
4001
|
+
canceled = true;
|
|
4002
|
+
terminateChildProcessTree(child);
|
|
4003
|
+
}
|
|
4004
|
+
}, 250) : null;
|
|
4005
|
+
if (cancelTimer && typeof cancelTimer.unref === 'function') cancelTimer.unref();
|
|
4006
|
+
child.stdout.on('data', (c) => {
|
|
4007
|
+
const text = String(c || '');
|
|
4008
|
+
stdout += text;
|
|
4009
|
+
emitChunk('stdout', text);
|
|
4010
|
+
});
|
|
4011
|
+
child.stderr.on('data', (c) => {
|
|
4012
|
+
const text = String(c || '');
|
|
4013
|
+
stderr += text;
|
|
4014
|
+
emitChunk('stderr', text);
|
|
4015
|
+
});
|
|
4016
|
+
child.on('error', (err) => finish({ ok: false, code: null, stdout, stderr: err.message }));
|
|
4017
|
+
child.on('close', (code) => {
|
|
4018
|
+
const finalStderr = canceled
|
|
4019
|
+
? `${stderr || ''}${stderr && !stderr.endsWith('\n') ? '\n' : ''}Canceled by remote request.`
|
|
4020
|
+
: stderr;
|
|
4021
|
+
finish({ ok: !canceled && code === 0, code, stdout, stderr: finalStderr });
|
|
4022
|
+
});
|
|
3948
4023
|
});
|
|
3949
4024
|
}
|
|
3950
4025
|
|
|
@@ -4303,10 +4378,12 @@ function buildRemoteControlHtml(state) {
|
|
|
4303
4378
|
const statusEl = document.getElementById('status');
|
|
4304
4379
|
const promptEl = document.getElementById('prompt');
|
|
4305
4380
|
const sendEl = document.getElementById('send');
|
|
4306
|
-
let
|
|
4381
|
+
let lastSignature = '';
|
|
4307
4382
|
function renderLog(items) {
|
|
4308
|
-
if (!Array.isArray(items)
|
|
4309
|
-
|
|
4383
|
+
if (!Array.isArray(items)) return;
|
|
4384
|
+
const signature = items.map((item, index) => String(item.id || index) + ':' + String(item.ts || '') + ':' + String(item.text || '').length).join('|');
|
|
4385
|
+
if (signature === lastSignature) return;
|
|
4386
|
+
lastSignature = signature;
|
|
4310
4387
|
logEl.innerHTML = items.map((item) => {
|
|
4311
4388
|
const role = String(item.role || 'system');
|
|
4312
4389
|
const text = String(item.text || '');
|
|
@@ -4352,17 +4429,21 @@ function buildRemoteControlHtml(state) {
|
|
|
4352
4429
|
</html>`;
|
|
4353
4430
|
}
|
|
4354
4431
|
|
|
4355
|
-
async function captureRemoteControlOutput(fn) {
|
|
4432
|
+
async function captureRemoteControlOutput(fn, onChunk) {
|
|
4356
4433
|
let stdout = '';
|
|
4357
4434
|
let stderr = '';
|
|
4358
4435
|
const originalStdoutWrite = process.stdout.write;
|
|
4359
4436
|
const originalStderrWrite = process.stderr.write;
|
|
4360
4437
|
process.stdout.write = function patchedStdoutWrite(chunk, encoding, cb) {
|
|
4361
|
-
|
|
4438
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
|
|
4439
|
+
stdout += text;
|
|
4440
|
+
try { if (typeof onChunk === 'function') onChunk('stdout', text); } catch (_err) {}
|
|
4362
4441
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
|
4363
4442
|
};
|
|
4364
4443
|
process.stderr.write = function patchedStderrWrite(chunk, encoding, cb) {
|
|
4365
|
-
|
|
4444
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
|
|
4445
|
+
stderr += text;
|
|
4446
|
+
try { if (typeof onChunk === 'function') onChunk('stderr', text); } catch (_err) {}
|
|
4366
4447
|
return originalStderrWrite.apply(process.stderr, arguments);
|
|
4367
4448
|
};
|
|
4368
4449
|
try {
|
|
@@ -4374,32 +4455,56 @@ async function captureRemoteControlOutput(fn) {
|
|
|
4374
4455
|
return { stdout, stderr };
|
|
4375
4456
|
}
|
|
4376
4457
|
|
|
4377
|
-
async function runRemoteControlPrompt(opts, text) {
|
|
4458
|
+
async function runRemoteControlPrompt(opts, text, onChunk) {
|
|
4378
4459
|
const line = String(text || '').trim();
|
|
4379
4460
|
if (!line) return { ok: false, output: 'Empty prompt.' };
|
|
4461
|
+
const permission = assessRemoteControlPromptPermission(opts, line);
|
|
4462
|
+
if (!permission.ok) {
|
|
4463
|
+
return {
|
|
4464
|
+
ok: false,
|
|
4465
|
+
output: `Remote command blocked (${permission.mode}): ${permission.reason}.\nRestart with --remote-permission full only for a trusted session.`
|
|
4466
|
+
};
|
|
4467
|
+
}
|
|
4380
4468
|
pushCliHistory(opts, `[remote] ${line}`);
|
|
4381
4469
|
try { saveCliWorkspaceState(opts); } catch (_err) {}
|
|
4382
|
-
const
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4470
|
+
const previousRemoteExecution = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
|
|
4471
|
+
ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = {
|
|
4472
|
+
...(opts._remoteControlExecutionState || {}),
|
|
4473
|
+
onChunk
|
|
4474
|
+
};
|
|
4475
|
+
let captured;
|
|
4476
|
+
try {
|
|
4477
|
+
captured = await captureRemoteControlOutput(async () => {
|
|
4478
|
+
if (line === '/exit' || line === '/quit') {
|
|
4479
|
+
console.log('Remote clients cannot exit the local terminal process. Stop it locally with Ctrl+C.');
|
|
4480
|
+
return;
|
|
4481
|
+
}
|
|
4482
|
+
const localResult = await handleLocalCommand(opts, line);
|
|
4483
|
+
if (localResult.handled) return;
|
|
4484
|
+
const localIntent = classifyLocalPromptIntent(line);
|
|
4485
|
+
if (localIntent?.command) {
|
|
4486
|
+
const intentPermission = assessRemoteControlPromptPermission(opts, localIntent.command);
|
|
4487
|
+
if (!intentPermission.ok) {
|
|
4488
|
+
console.log(`Remote inferred command blocked (${intentPermission.mode}): ${intentPermission.reason}.`);
|
|
4489
|
+
return;
|
|
4490
|
+
}
|
|
4491
|
+
const intentResult = await handleLocalCommand(opts, localIntent.command);
|
|
4492
|
+
if (intentResult.handled) return;
|
|
4493
|
+
}
|
|
4494
|
+
if (!permission.skipNaturalActions) {
|
|
4495
|
+
const naturalFileAction = await runNaturalLocalFileAction(opts, line);
|
|
4496
|
+
if (naturalFileAction.handled) return;
|
|
4497
|
+
}
|
|
4498
|
+
if (opts.offline) {
|
|
4499
|
+
console.log('Offline mode: use slash commands and local tools from this remote session.');
|
|
4500
|
+
return;
|
|
4501
|
+
}
|
|
4502
|
+
const data = await askServer(opts, line);
|
|
4503
|
+
printResponse(opts, data);
|
|
4504
|
+
}, onChunk);
|
|
4505
|
+
} finally {
|
|
4506
|
+
ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = previousRemoteExecution;
|
|
4507
|
+
}
|
|
4403
4508
|
const output = `${captured.stdout || ''}${captured.stderr || ''}`.trim();
|
|
4404
4509
|
return { ok: true, output: output || '(no output)' };
|
|
4405
4510
|
}
|
|
@@ -4412,6 +4517,7 @@ async function startRemoteControlServer(opts, overrides = {}) {
|
|
|
4412
4517
|
|
|
4413
4518
|
const http = require('http');
|
|
4414
4519
|
const crypto = require('crypto');
|
|
4520
|
+
opts.remoteControlPermission = getRemoteControlPermissionMode(opts, overrides);
|
|
4415
4521
|
const host = String(overrides.host || opts.remoteControlHost || '127.0.0.1').trim() || '127.0.0.1';
|
|
4416
4522
|
const port = Math.max(0, Math.min(65535, Number(overrides.port ?? opts.remoteControlPort ?? 0) || 0));
|
|
4417
4523
|
const token = crypto.randomBytes(24).toString('hex');
|
|
@@ -4424,10 +4530,37 @@ async function startRemoteControlServer(opts, overrides = {}) {
|
|
|
4424
4530
|
cwd: opts.cwd,
|
|
4425
4531
|
startedAt: Date.now(),
|
|
4426
4532
|
busy: false,
|
|
4427
|
-
log: []
|
|
4533
|
+
log: [],
|
|
4534
|
+
nextLogId: 1
|
|
4428
4535
|
};
|
|
4429
4536
|
const appendLog = (role, text) => {
|
|
4430
|
-
state.log.push({ role, text: String(text || ''), ts: Date.now() });
|
|
4537
|
+
state.log.push({ id: state.nextLogId++, role, text: String(text || ''), ts: Date.now() });
|
|
4538
|
+
if (state.log.length > 200) state.log.splice(0, state.log.length - 200);
|
|
4539
|
+
};
|
|
4540
|
+
const appendStreamChunk = (streamKey, text, commandId) => {
|
|
4541
|
+
const chunk = String(text || '');
|
|
4542
|
+
if (!chunk) return;
|
|
4543
|
+
let item = null;
|
|
4544
|
+
for (let i = state.log.length - 1; i >= 0; i -= 1) {
|
|
4545
|
+
const candidate = state.log[i];
|
|
4546
|
+
if (candidate?.streaming && candidate.commandId === commandId) {
|
|
4547
|
+
item = candidate;
|
|
4548
|
+
break;
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
if (!item) {
|
|
4552
|
+
item = {
|
|
4553
|
+
id: state.nextLogId++,
|
|
4554
|
+
role: streamKey === 'stderr' ? 'system' : 'assistant',
|
|
4555
|
+
text: '',
|
|
4556
|
+
ts: Date.now(),
|
|
4557
|
+
streaming: true,
|
|
4558
|
+
commandId
|
|
4559
|
+
};
|
|
4560
|
+
state.log.push(item);
|
|
4561
|
+
}
|
|
4562
|
+
item.text = `${item.text || ''}${chunk}`.slice(-20000);
|
|
4563
|
+
item.ts = Date.now();
|
|
4431
4564
|
if (state.log.length > 200) state.log.splice(0, state.log.length - 200);
|
|
4432
4565
|
};
|
|
4433
4566
|
appendLog('system', `Remote Control started for ${state.cwd}`);
|
|
@@ -4480,8 +4613,17 @@ async function startRemoteControlServer(opts, overrides = {}) {
|
|
|
4480
4613
|
const text = String(payload.text || payload.prompt || '').trim();
|
|
4481
4614
|
if (!text) return sendJson(res, 400, { ok: false, error: 'empty prompt' });
|
|
4482
4615
|
appendLog('user', text);
|
|
4483
|
-
const
|
|
4484
|
-
|
|
4616
|
+
const commandId = state.nextLogId;
|
|
4617
|
+
const result = await runRemoteControlPrompt(opts, text, (stream, chunk) => appendStreamChunk(stream, chunk, commandId));
|
|
4618
|
+
const streamLog = state.log.find((item) => item?.streaming && item.commandId === commandId);
|
|
4619
|
+
if (streamLog) {
|
|
4620
|
+
streamLog.streaming = false;
|
|
4621
|
+
streamLog.role = result.ok ? 'assistant' : 'system';
|
|
4622
|
+
streamLog.text = result.output || streamLog.text || '(no output)';
|
|
4623
|
+
streamLog.ts = Date.now();
|
|
4624
|
+
} else {
|
|
4625
|
+
appendLog('assistant', result.output || '(no output)');
|
|
4626
|
+
}
|
|
4485
4627
|
return sendJson(res, result.ok ? 200 : 500, { ok: !!result.ok, output: result.output || '' });
|
|
4486
4628
|
} catch (err) {
|
|
4487
4629
|
appendLog('system', `Error: ${err.message}`);
|
|
@@ -4545,6 +4687,81 @@ function getRemoteControlRelayBase(opts, overrides = {}) {
|
|
|
4545
4687
|
return String(overrides.relayUrl || opts.remoteControlRelayUrl || opts.url || 'https://bortex.site').replace(/\/+$/, '');
|
|
4546
4688
|
}
|
|
4547
4689
|
|
|
4690
|
+
function normalizeRemoteControlPermissionMode(value) {
|
|
4691
|
+
const v = String(value || 'balanced').trim().toLowerCase();
|
|
4692
|
+
if (['full', 'trusted', 'unsafe', 'yolo'].includes(v)) return 'full';
|
|
4693
|
+
if (['read-only', 'readonly', 'read', 'safe', 'ro'].includes(v)) return 'read-only';
|
|
4694
|
+
return 'balanced';
|
|
4695
|
+
}
|
|
4696
|
+
|
|
4697
|
+
function getRemoteControlPermissionMode(opts, overrides = {}) {
|
|
4698
|
+
return normalizeRemoteControlPermissionMode(overrides.permission || opts.remoteControlPermission || 'balanced');
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4701
|
+
function isReadOnlyRemoteSlashCommand(line) {
|
|
4702
|
+
const [cmd, ...rest] = parseWords(line);
|
|
4703
|
+
const lc = String(cmd || '').toLowerCase();
|
|
4704
|
+
if (!lc.startsWith('/')) return false;
|
|
4705
|
+
if ([
|
|
4706
|
+
'/help', '/commands', '/menu', '/status', '/pwd', '/ls', '/tree', '/read',
|
|
4707
|
+
'/diff', '/hunks', '/show-hunk', '/ssh-status', '/sys-status',
|
|
4708
|
+
'/process-status', '/port-status', '/history'
|
|
4709
|
+
].includes(lc)) return true;
|
|
4710
|
+
if (lc === '/llm-config') {
|
|
4711
|
+
const sub = String(rest[0] || 'show').toLowerCase();
|
|
4712
|
+
return !sub || ['show', 'status'].includes(sub);
|
|
4713
|
+
}
|
|
4714
|
+
if (lc === '/git') {
|
|
4715
|
+
const sub = String(rest[0] || '').toLowerCase();
|
|
4716
|
+
return ['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'remote'].includes(sub);
|
|
4717
|
+
}
|
|
4718
|
+
return false;
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
function isBalancedRemoteShellCommand(cmdText) {
|
|
4722
|
+
const text = String(cmdText || '').trim();
|
|
4723
|
+
const lower = text.toLowerCase();
|
|
4724
|
+
if (!text) return false;
|
|
4725
|
+
if (/[;&|`$<>]/.test(text)) return false;
|
|
4726
|
+
if (/\b(rm|del|erase|rmdir|rd|move|mv|copy|cp|curl|wget|scp|ssh|chmod|chown|sudo|su|kill|pkill|taskkill|format|shutdown|reboot)\b/i.test(text)) return false;
|
|
4727
|
+
if (/\b(git\s+(add|commit|reset|restore|checkout|switch|clean|stash|push|pull|merge|rebase|apply|am|cherry-pick))\b/i.test(text)) return false;
|
|
4728
|
+
if (/^(npm|pnpm|yarn)\s+(test|run\s+(test|lint|build|check|typecheck)|exec\s+tsc)\b/i.test(text)) return true;
|
|
4729
|
+
if (/^node\s+--check\s+\S+/i.test(text)) return true;
|
|
4730
|
+
if (/^(git\s+(status|diff|log|show|branch|rev-parse)|rg|grep|findstr|ls|dir|pwd|cat|type)\b/i.test(text)) return true;
|
|
4731
|
+
return false;
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
function isBalancedRemoteSlashCommand(line) {
|
|
4735
|
+
if (isReadOnlyRemoteSlashCommand(line)) return true;
|
|
4736
|
+
const [cmd, ...rest] = parseWords(line);
|
|
4737
|
+
const lc = String(cmd || '').toLowerCase();
|
|
4738
|
+
if (lc === '/sh') {
|
|
4739
|
+
const cmdText = line.slice(line.indexOf('/sh') + 3).trim();
|
|
4740
|
+
return isBalancedRemoteShellCommand(cmdText);
|
|
4741
|
+
}
|
|
4742
|
+
if (lc === '/agent') return true;
|
|
4743
|
+
if (lc === '/ux') return true;
|
|
4744
|
+
if (lc === '/remote-control' || lc === '/remote' || lc === '/rc') return true;
|
|
4745
|
+
if (lc === '/agent-run') {
|
|
4746
|
+
return rest.some((x) => String(x).toLowerCase() === '--dry');
|
|
4747
|
+
}
|
|
4748
|
+
return false;
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4751
|
+
function assessRemoteControlPromptPermission(opts, line) {
|
|
4752
|
+
const mode = getRemoteControlPermissionMode(opts);
|
|
4753
|
+
const text = String(line || '').trim();
|
|
4754
|
+
if (!text) return { ok: false, mode, reason: 'empty prompt' };
|
|
4755
|
+
if (mode === 'full') return { ok: true, mode };
|
|
4756
|
+
if (!text.startsWith('/')) return { ok: true, mode, skipNaturalActions: true };
|
|
4757
|
+
if (mode === 'read-only') {
|
|
4758
|
+
if (isReadOnlyRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
|
|
4759
|
+
return { ok: false, mode, reason: 'read-only mode allows inspection commands only' };
|
|
4760
|
+
}
|
|
4761
|
+
if (isBalancedRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
|
|
4762
|
+
return { ok: false, mode, reason: 'balanced mode blocks mutating or unsafe remote commands' };
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4548
4765
|
function remoteControlSleep(ms) {
|
|
4549
4766
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4550
4767
|
}
|
|
@@ -4590,7 +4807,7 @@ async function fetchRemoteControlJson(url, options = {}, timeoutMs = 30000) {
|
|
|
4590
4807
|
}
|
|
4591
4808
|
}
|
|
4592
4809
|
|
|
4593
|
-
async function postCloudRemoteHeartbeat(state) {
|
|
4810
|
+
async function postCloudRemoteHeartbeat(state, extra = {}) {
|
|
4594
4811
|
const heartbeatUrl = withRemoteControlQuery(
|
|
4595
4812
|
state.heartbeatUrl,
|
|
4596
4813
|
state.baseUrl,
|
|
@@ -4599,7 +4816,7 @@ async function postCloudRemoteHeartbeat(state) {
|
|
|
4599
4816
|
await fetchRemoteControlJson(heartbeatUrl, {
|
|
4600
4817
|
method: 'POST',
|
|
4601
4818
|
headers: { 'Content-Type': 'application/json' },
|
|
4602
|
-
body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION })
|
|
4819
|
+
body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION, ...extra })
|
|
4603
4820
|
}, 12000);
|
|
4604
4821
|
}
|
|
4605
4822
|
|
|
@@ -4620,6 +4837,107 @@ async function postCloudRemoteResult(state, commandId, result) {
|
|
|
4620
4837
|
}, 35000);
|
|
4621
4838
|
}
|
|
4622
4839
|
|
|
4840
|
+
async function postCloudRemoteChunk(state, commandId, stream, text) {
|
|
4841
|
+
if (!state?.chunkUrl || !text) return;
|
|
4842
|
+
const chunkUrl = withRemoteControlQuery(
|
|
4843
|
+
state.chunkUrl,
|
|
4844
|
+
state.baseUrl,
|
|
4845
|
+
{ agentToken: state.agentToken }
|
|
4846
|
+
);
|
|
4847
|
+
await fetchRemoteControlJson(chunkUrl, {
|
|
4848
|
+
method: 'POST',
|
|
4849
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4850
|
+
body: JSON.stringify({
|
|
4851
|
+
commandId,
|
|
4852
|
+
stream: stream === 'stderr' ? 'stderr' : 'stdout',
|
|
4853
|
+
text: String(text || '')
|
|
4854
|
+
})
|
|
4855
|
+
}, 12000);
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
function createCloudRemoteChunkStreamer(state, commandId) {
|
|
4859
|
+
const queue = [];
|
|
4860
|
+
let timer = null;
|
|
4861
|
+
let flushing = false;
|
|
4862
|
+
const schedule = (ms = 250) => {
|
|
4863
|
+
if (timer) return;
|
|
4864
|
+
timer = setTimeout(() => {
|
|
4865
|
+
timer = null;
|
|
4866
|
+
flush().catch(() => {});
|
|
4867
|
+
}, ms);
|
|
4868
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
4869
|
+
};
|
|
4870
|
+
const flush = async () => {
|
|
4871
|
+
if (flushing) return;
|
|
4872
|
+
flushing = true;
|
|
4873
|
+
if (timer) {
|
|
4874
|
+
clearTimeout(timer);
|
|
4875
|
+
timer = null;
|
|
4876
|
+
}
|
|
4877
|
+
try {
|
|
4878
|
+
while (queue.length) {
|
|
4879
|
+
const pending = queue.splice(0, queue.length);
|
|
4880
|
+
const grouped = new Map();
|
|
4881
|
+
for (const item of pending) {
|
|
4882
|
+
const stream = item.stream === 'stderr' ? 'stderr' : 'stdout';
|
|
4883
|
+
grouped.set(stream, `${grouped.get(stream) || ''}${item.text || ''}`);
|
|
4884
|
+
}
|
|
4885
|
+
for (const [stream, text] of grouped.entries()) {
|
|
4886
|
+
if (!text) continue;
|
|
4887
|
+
await postCloudRemoteChunk(state, commandId, stream, text).catch((err) => {
|
|
4888
|
+
state.lastChunkError = err.message || String(err);
|
|
4889
|
+
});
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
} finally {
|
|
4893
|
+
flushing = false;
|
|
4894
|
+
}
|
|
4895
|
+
};
|
|
4896
|
+
return {
|
|
4897
|
+
push(stream, text) {
|
|
4898
|
+
if (!text || !state.active) return;
|
|
4899
|
+
queue.push({ stream, text: String(text || '') });
|
|
4900
|
+
const queuedSize = queue.reduce((n, item) => n + String(item.text || '').length, 0);
|
|
4901
|
+
schedule(queuedSize > 3000 ? 25 : 250);
|
|
4902
|
+
},
|
|
4903
|
+
flush
|
|
4904
|
+
};
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
async function pollCloudRemoteControlControlLoop(state) {
|
|
4908
|
+
while (state.active) {
|
|
4909
|
+
try {
|
|
4910
|
+
const controlUrl = withRemoteControlQuery(
|
|
4911
|
+
state.controlUrl,
|
|
4912
|
+
state.baseUrl,
|
|
4913
|
+
{ agentToken: state.agentToken }
|
|
4914
|
+
);
|
|
4915
|
+
const data = await fetchRemoteControlJson(controlUrl, { method: 'GET' }, 12000);
|
|
4916
|
+
if (data.revoked) {
|
|
4917
|
+
state.revoked = true;
|
|
4918
|
+
state.cancelRequested = true;
|
|
4919
|
+
state.active = false;
|
|
4920
|
+
console.log('Cloud Remote Control revoked by remote client.');
|
|
4921
|
+
break;
|
|
4922
|
+
}
|
|
4923
|
+
if (data.cancelRequested) {
|
|
4924
|
+
state.cancelRequested = true;
|
|
4925
|
+
state.cancelCommandId = data.cancelCommandId || null;
|
|
4926
|
+
}
|
|
4927
|
+
state.controlErrorCount = 0;
|
|
4928
|
+
} catch (err) {
|
|
4929
|
+
state.controlErrorCount = (state.controlErrorCount || 0) + 1;
|
|
4930
|
+
if (/revoked|410/i.test(String(err.message || err))) {
|
|
4931
|
+
state.revoked = true;
|
|
4932
|
+
state.cancelRequested = true;
|
|
4933
|
+
state.active = false;
|
|
4934
|
+
break;
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
await remoteControlSleep(Math.min(3000, 1000 + (state.controlErrorCount || 0) * 250));
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4623
4941
|
async function pollCloudRemoteControlLoop(opts, state) {
|
|
4624
4942
|
let lastHeartbeatAt = 0;
|
|
4625
4943
|
while (state.active) {
|
|
@@ -4631,6 +4949,17 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4631
4949
|
{ agentToken: state.agentToken, after: state.lastCommandId || 0 }
|
|
4632
4950
|
);
|
|
4633
4951
|
const data = await fetchRemoteControlJson(pollUrl, { method: 'GET' }, 35000);
|
|
4952
|
+
if (data.revoked) {
|
|
4953
|
+
state.revoked = true;
|
|
4954
|
+
state.cancelRequested = true;
|
|
4955
|
+
state.active = false;
|
|
4956
|
+
console.log('Cloud Remote Control revoked by remote client.');
|
|
4957
|
+
break;
|
|
4958
|
+
}
|
|
4959
|
+
if (data.cancelRequested) {
|
|
4960
|
+
state.cancelRequested = true;
|
|
4961
|
+
state.cancelCommandId = data.cancelCommandId || null;
|
|
4962
|
+
}
|
|
4634
4963
|
const commands = Array.isArray(data.commands) ? data.commands : [];
|
|
4635
4964
|
state.lastSeenAt = Date.now();
|
|
4636
4965
|
state.errorCount = 0;
|
|
@@ -4641,18 +4970,36 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4641
4970
|
if (commandId > (state.lastCommandId || 0)) state.lastCommandId = commandId;
|
|
4642
4971
|
const text = String(command?.text || command?.prompt || '').trim();
|
|
4643
4972
|
if (!text) continue;
|
|
4973
|
+
state.cancelRequested = false;
|
|
4974
|
+
state.cancelCommandId = null;
|
|
4644
4975
|
state.busy = true;
|
|
4976
|
+
opts._remoteControlExecutionState = state;
|
|
4977
|
+
const streamer = createCloudRemoteChunkStreamer(state, commandId);
|
|
4645
4978
|
let result;
|
|
4646
4979
|
try {
|
|
4647
|
-
result = await runRemoteControlPrompt(opts, text);
|
|
4980
|
+
result = await runRemoteControlPrompt(opts, text, (stream, chunk) => streamer.push(stream, chunk));
|
|
4648
4981
|
} catch (err) {
|
|
4649
4982
|
result = { ok: false, output: err.stack || err.message || String(err) };
|
|
4650
4983
|
} finally {
|
|
4984
|
+
await streamer.flush().catch(() => {});
|
|
4651
4985
|
state.busy = false;
|
|
4986
|
+
delete opts._remoteControlExecutionState;
|
|
4987
|
+
}
|
|
4988
|
+
if (state.cancelRequested && result.ok) {
|
|
4989
|
+
result = { ok: false, output: 'Canceled by remote request.' };
|
|
4652
4990
|
}
|
|
4653
4991
|
await postCloudRemoteResult(state, commandId, result);
|
|
4992
|
+
if (state.cancelRequested) {
|
|
4993
|
+
state.cancelRequested = false;
|
|
4994
|
+
state.cancelCommandId = null;
|
|
4995
|
+
}
|
|
4654
4996
|
}
|
|
4655
4997
|
|
|
4998
|
+
if (!commands.length && state.cancelRequested && !state.busy) {
|
|
4999
|
+
await postCloudRemoteHeartbeat(state, { cancelAck: true });
|
|
5000
|
+
state.cancelRequested = false;
|
|
5001
|
+
state.cancelCommandId = null;
|
|
5002
|
+
}
|
|
4656
5003
|
if (!commands.length && Date.now() - lastHeartbeatAt > 10000) {
|
|
4657
5004
|
lastHeartbeatAt = Date.now();
|
|
4658
5005
|
await postCloudRemoteHeartbeat(state);
|
|
@@ -4683,6 +5030,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4683
5030
|
|
|
4684
5031
|
const baseUrl = getRemoteControlRelayBase(opts, overrides);
|
|
4685
5032
|
const name = getRemoteControlDisplayName(opts, overrides);
|
|
5033
|
+
const permission = getRemoteControlPermissionMode(opts, overrides);
|
|
5034
|
+
opts.remoteControlPermission = permission;
|
|
4686
5035
|
const payload = {
|
|
4687
5036
|
name,
|
|
4688
5037
|
cwd: opts.cwd,
|
|
@@ -4690,7 +5039,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4690
5039
|
machine: os.hostname(),
|
|
4691
5040
|
platform: process.platform,
|
|
4692
5041
|
node: process.version,
|
|
4693
|
-
mode: opts.agent ? 'agent' : 'chat'
|
|
5042
|
+
mode: opts.agent ? 'agent' : 'chat',
|
|
5043
|
+
permission
|
|
4694
5044
|
};
|
|
4695
5045
|
const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
|
|
4696
5046
|
method: 'POST',
|
|
@@ -4712,11 +5062,14 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4712
5062
|
agentToken,
|
|
4713
5063
|
clientToken: data.clientToken || '',
|
|
4714
5064
|
name,
|
|
5065
|
+
permission,
|
|
4715
5066
|
cwd: opts.cwd,
|
|
4716
5067
|
url: data.url || `${baseUrl}/bortex-code/remote?session=${encodeURIComponent(sessionId)}`,
|
|
4717
5068
|
pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
|
|
4718
5069
|
resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
|
|
5070
|
+
chunkUrl: data.chunkUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/chunk`,
|
|
4719
5071
|
heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
|
|
5072
|
+
controlUrl: data.controlUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/control`,
|
|
4720
5073
|
lastCommandId: 0,
|
|
4721
5074
|
startedAt: Date.now(),
|
|
4722
5075
|
busy: false,
|
|
@@ -4729,9 +5082,13 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4729
5082
|
state.pollPromise = pollCloudRemoteControlLoop(opts, state).catch((err) => {
|
|
4730
5083
|
if (state.active) console.error(`Cloud Remote Control stopped unexpectedly: ${err.message || String(err)}`);
|
|
4731
5084
|
});
|
|
5085
|
+
state.controlPromise = pollCloudRemoteControlControlLoop(state).catch((err) => {
|
|
5086
|
+
if (state.active) console.error(`Cloud Remote Control control loop stopped unexpectedly: ${err.message || String(err)}`);
|
|
5087
|
+
});
|
|
4732
5088
|
|
|
4733
5089
|
console.log(`Cloud Remote Control active: ${state.name}`);
|
|
4734
5090
|
console.log(`URL: ${state.url}`);
|
|
5091
|
+
console.log(`Permission: ${state.permission}`);
|
|
4735
5092
|
console.log('No inbound port required. Keep this process running.');
|
|
4736
5093
|
console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
|
|
4737
5094
|
return state;
|
|
@@ -4755,6 +5112,7 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
|
|
|
4755
5112
|
host: opts.remoteControlHost || '127.0.0.1',
|
|
4756
5113
|
port: Number(opts.remoteControlPort || 0) || 0,
|
|
4757
5114
|
relayUrl: opts.remoteControlRelayUrl || '',
|
|
5115
|
+
permission: opts.remoteControlPermission || 'balanced',
|
|
4758
5116
|
cloud: false
|
|
4759
5117
|
};
|
|
4760
5118
|
const nameParts = [];
|
|
@@ -4785,6 +5143,19 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
|
|
|
4785
5143
|
out.relayUrl = a.slice('--remote-relay='.length);
|
|
4786
5144
|
continue;
|
|
4787
5145
|
}
|
|
5146
|
+
if ((a === '--permission' || a === '--remote-permission') && rest[i + 1]) {
|
|
5147
|
+
out.permission = String(rest[i + 1]);
|
|
5148
|
+
i += 1;
|
|
5149
|
+
continue;
|
|
5150
|
+
}
|
|
5151
|
+
if (a.startsWith('--permission=')) {
|
|
5152
|
+
out.permission = a.slice('--permission='.length);
|
|
5153
|
+
continue;
|
|
5154
|
+
}
|
|
5155
|
+
if (a.startsWith('--remote-permission=')) {
|
|
5156
|
+
out.permission = a.slice('--remote-permission='.length);
|
|
5157
|
+
continue;
|
|
5158
|
+
}
|
|
4788
5159
|
if ((a === '--host' || a === '--remote-host') && rest[i + 1]) {
|
|
4789
5160
|
out.host = String(rest[i + 1]);
|
|
4790
5161
|
i += 1;
|
|
@@ -7605,20 +7976,21 @@ async function main() {
|
|
|
7605
7976
|
}
|
|
7606
7977
|
|
|
7607
7978
|
if (opts.remoteControlServerMode) {
|
|
7979
|
+
let remoteState = null;
|
|
7608
7980
|
if (opts.remoteControlCloud) {
|
|
7609
|
-
await startCloudRemoteControlSession(opts, {
|
|
7981
|
+
remoteState = await startCloudRemoteControlSession(opts, {
|
|
7610
7982
|
name: opts.remoteControlName,
|
|
7611
7983
|
relayUrl: opts.remoteControlRelayUrl
|
|
7612
7984
|
});
|
|
7613
7985
|
} else {
|
|
7614
|
-
await startRemoteControlServer(opts, {
|
|
7986
|
+
remoteState = await startRemoteControlServer(opts, {
|
|
7615
7987
|
name: opts.remoteControlName,
|
|
7616
7988
|
host: opts.remoteControlHost,
|
|
7617
7989
|
port: opts.remoteControlPort
|
|
7618
7990
|
});
|
|
7619
7991
|
}
|
|
7620
7992
|
console.log('Server mode: keep this process running. Press Ctrl+C to stop Remote Control.');
|
|
7621
|
-
|
|
7993
|
+
const signalPromise = new Promise((resolve) => {
|
|
7622
7994
|
let stopping = false;
|
|
7623
7995
|
const stop = async () => {
|
|
7624
7996
|
if (stopping) return;
|
|
@@ -7634,6 +8006,17 @@ async function main() {
|
|
|
7634
8006
|
process.once('SIGINT', stop);
|
|
7635
8007
|
process.once('SIGTERM', stop);
|
|
7636
8008
|
});
|
|
8009
|
+
const remoteDonePromises = [];
|
|
8010
|
+
if (remoteState?.pollPromise) remoteDonePromises.push(remoteState.pollPromise);
|
|
8011
|
+
if (remoteState?.controlPromise) remoteDonePromises.push(remoteState.controlPromise);
|
|
8012
|
+
if (remoteDonePromises.length) {
|
|
8013
|
+
await Promise.race([signalPromise, ...remoteDonePromises]);
|
|
8014
|
+
if (opts.remoteControlCloudSession?.active) {
|
|
8015
|
+
await stopCloudRemoteControlSession(opts);
|
|
8016
|
+
}
|
|
8017
|
+
} else {
|
|
8018
|
+
await signalPromise;
|
|
8019
|
+
}
|
|
7637
8020
|
return;
|
|
7638
8021
|
}
|
|
7639
8022
|
|