bortexcode 1.3.0 → 1.4.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 +15 -0
- package/bin/bortex.js +282 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,14 +94,26 @@ 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
|
+
|
|
99
110
|
Inside the REPL:
|
|
100
111
|
|
|
101
112
|
```text
|
|
102
113
|
/remote-control My Project
|
|
103
114
|
/remote-control --lan
|
|
104
115
|
/remote-control --cloud
|
|
116
|
+
/remote-control --cloud --permission full
|
|
105
117
|
/remote-control stop
|
|
106
118
|
```
|
|
107
119
|
|
|
@@ -129,6 +141,8 @@ Inside the REPL:
|
|
|
129
141
|
Use bortex.site relay instead of opening a local port
|
|
130
142
|
--remote-relay <url>
|
|
131
143
|
Remote relay base URL
|
|
144
|
+
--remote-permission <mode>
|
|
145
|
+
Remote permissions: read-only, balanced, full
|
|
132
146
|
--remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access
|
|
133
147
|
--remote-host <host>, --remote-port <port>
|
|
134
148
|
Remote control bind address
|
|
@@ -156,4 +170,5 @@ BORTEX_NO_UPDATE_CHECK=1
|
|
|
156
170
|
BORTEX_REMOTE_HOST
|
|
157
171
|
BORTEX_REMOTE_PORT
|
|
158
172
|
BORTEX_REMOTE_RELAY_URL
|
|
173
|
+
BORTEX_REMOTE_PERMISSION
|
|
159
174
|
```
|
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,30 @@ function runChild(command, args, { cwd, shell = false } = {}) {
|
|
|
3941
3981
|
});
|
|
3942
3982
|
let stdout = '';
|
|
3943
3983
|
let stderr = '';
|
|
3984
|
+
let settled = false;
|
|
3985
|
+
let canceled = false;
|
|
3986
|
+
const finish = (result) => {
|
|
3987
|
+
if (settled) return;
|
|
3988
|
+
settled = true;
|
|
3989
|
+
if (cancelTimer) clearInterval(cancelTimer);
|
|
3990
|
+
resolve(result);
|
|
3991
|
+
};
|
|
3992
|
+
const cancelTimer = cancelState ? setInterval(() => {
|
|
3993
|
+
if (cancelState.cancelRequested || cancelState.revoked || cancelState.active === false) {
|
|
3994
|
+
canceled = true;
|
|
3995
|
+
terminateChildProcessTree(child);
|
|
3996
|
+
}
|
|
3997
|
+
}, 250) : null;
|
|
3998
|
+
if (cancelTimer && typeof cancelTimer.unref === 'function') cancelTimer.unref();
|
|
3944
3999
|
child.stdout.on('data', (c) => { stdout += String(c || ''); });
|
|
3945
4000
|
child.stderr.on('data', (c) => { stderr += String(c || ''); });
|
|
3946
|
-
child.on('error', (err) =>
|
|
3947
|
-
child.on('close', (code) =>
|
|
4001
|
+
child.on('error', (err) => finish({ ok: false, code: null, stdout, stderr: err.message }));
|
|
4002
|
+
child.on('close', (code) => {
|
|
4003
|
+
const finalStderr = canceled
|
|
4004
|
+
? `${stderr || ''}${stderr && !stderr.endsWith('\n') ? '\n' : ''}Canceled by remote request.`
|
|
4005
|
+
: stderr;
|
|
4006
|
+
finish({ ok: !canceled && code === 0, code, stdout, stderr: finalStderr });
|
|
4007
|
+
});
|
|
3948
4008
|
});
|
|
3949
4009
|
}
|
|
3950
4010
|
|
|
@@ -4377,29 +4437,50 @@ async function captureRemoteControlOutput(fn) {
|
|
|
4377
4437
|
async function runRemoteControlPrompt(opts, text) {
|
|
4378
4438
|
const line = String(text || '').trim();
|
|
4379
4439
|
if (!line) return { ok: false, output: 'Empty prompt.' };
|
|
4440
|
+
const permission = assessRemoteControlPromptPermission(opts, line);
|
|
4441
|
+
if (!permission.ok) {
|
|
4442
|
+
return {
|
|
4443
|
+
ok: false,
|
|
4444
|
+
output: `Remote command blocked (${permission.mode}): ${permission.reason}.\nRestart with --remote-permission full only for a trusted session.`
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
4380
4447
|
pushCliHistory(opts, `[remote] ${line}`);
|
|
4381
4448
|
try { saveCliWorkspaceState(opts); } catch (_err) {}
|
|
4382
|
-
const
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
const
|
|
4392
|
-
if (
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4449
|
+
const previousRemoteExecution = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
|
|
4450
|
+
ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = opts._remoteControlExecutionState || null;
|
|
4451
|
+
let captured;
|
|
4452
|
+
try {
|
|
4453
|
+
captured = await captureRemoteControlOutput(async () => {
|
|
4454
|
+
if (line === '/exit' || line === '/quit') {
|
|
4455
|
+
console.log('Remote clients cannot exit the local terminal process. Stop it locally with Ctrl+C.');
|
|
4456
|
+
return;
|
|
4457
|
+
}
|
|
4458
|
+
const localResult = await handleLocalCommand(opts, line);
|
|
4459
|
+
if (localResult.handled) return;
|
|
4460
|
+
const localIntent = classifyLocalPromptIntent(line);
|
|
4461
|
+
if (localIntent?.command) {
|
|
4462
|
+
const intentPermission = assessRemoteControlPromptPermission(opts, localIntent.command);
|
|
4463
|
+
if (!intentPermission.ok) {
|
|
4464
|
+
console.log(`Remote inferred command blocked (${intentPermission.mode}): ${intentPermission.reason}.`);
|
|
4465
|
+
return;
|
|
4466
|
+
}
|
|
4467
|
+
const intentResult = await handleLocalCommand(opts, localIntent.command);
|
|
4468
|
+
if (intentResult.handled) return;
|
|
4469
|
+
}
|
|
4470
|
+
if (!permission.skipNaturalActions) {
|
|
4471
|
+
const naturalFileAction = await runNaturalLocalFileAction(opts, line);
|
|
4472
|
+
if (naturalFileAction.handled) return;
|
|
4473
|
+
}
|
|
4474
|
+
if (opts.offline) {
|
|
4475
|
+
console.log('Offline mode: use slash commands and local tools from this remote session.');
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
const data = await askServer(opts, line);
|
|
4479
|
+
printResponse(opts, data);
|
|
4480
|
+
});
|
|
4481
|
+
} finally {
|
|
4482
|
+
ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = previousRemoteExecution;
|
|
4483
|
+
}
|
|
4403
4484
|
const output = `${captured.stdout || ''}${captured.stderr || ''}`.trim();
|
|
4404
4485
|
return { ok: true, output: output || '(no output)' };
|
|
4405
4486
|
}
|
|
@@ -4412,6 +4493,7 @@ async function startRemoteControlServer(opts, overrides = {}) {
|
|
|
4412
4493
|
|
|
4413
4494
|
const http = require('http');
|
|
4414
4495
|
const crypto = require('crypto');
|
|
4496
|
+
opts.remoteControlPermission = getRemoteControlPermissionMode(opts, overrides);
|
|
4415
4497
|
const host = String(overrides.host || opts.remoteControlHost || '127.0.0.1').trim() || '127.0.0.1';
|
|
4416
4498
|
const port = Math.max(0, Math.min(65535, Number(overrides.port ?? opts.remoteControlPort ?? 0) || 0));
|
|
4417
4499
|
const token = crypto.randomBytes(24).toString('hex');
|
|
@@ -4545,6 +4627,81 @@ function getRemoteControlRelayBase(opts, overrides = {}) {
|
|
|
4545
4627
|
return String(overrides.relayUrl || opts.remoteControlRelayUrl || opts.url || 'https://bortex.site').replace(/\/+$/, '');
|
|
4546
4628
|
}
|
|
4547
4629
|
|
|
4630
|
+
function normalizeRemoteControlPermissionMode(value) {
|
|
4631
|
+
const v = String(value || 'balanced').trim().toLowerCase();
|
|
4632
|
+
if (['full', 'trusted', 'unsafe', 'yolo'].includes(v)) return 'full';
|
|
4633
|
+
if (['read-only', 'readonly', 'read', 'safe', 'ro'].includes(v)) return 'read-only';
|
|
4634
|
+
return 'balanced';
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
function getRemoteControlPermissionMode(opts, overrides = {}) {
|
|
4638
|
+
return normalizeRemoteControlPermissionMode(overrides.permission || opts.remoteControlPermission || 'balanced');
|
|
4639
|
+
}
|
|
4640
|
+
|
|
4641
|
+
function isReadOnlyRemoteSlashCommand(line) {
|
|
4642
|
+
const [cmd, ...rest] = parseWords(line);
|
|
4643
|
+
const lc = String(cmd || '').toLowerCase();
|
|
4644
|
+
if (!lc.startsWith('/')) return false;
|
|
4645
|
+
if ([
|
|
4646
|
+
'/help', '/commands', '/menu', '/status', '/pwd', '/ls', '/tree', '/read',
|
|
4647
|
+
'/diff', '/hunks', '/show-hunk', '/ssh-status', '/sys-status',
|
|
4648
|
+
'/process-status', '/port-status', '/history'
|
|
4649
|
+
].includes(lc)) return true;
|
|
4650
|
+
if (lc === '/llm-config') {
|
|
4651
|
+
const sub = String(rest[0] || 'show').toLowerCase();
|
|
4652
|
+
return !sub || ['show', 'status'].includes(sub);
|
|
4653
|
+
}
|
|
4654
|
+
if (lc === '/git') {
|
|
4655
|
+
const sub = String(rest[0] || '').toLowerCase();
|
|
4656
|
+
return ['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'remote'].includes(sub);
|
|
4657
|
+
}
|
|
4658
|
+
return false;
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
function isBalancedRemoteShellCommand(cmdText) {
|
|
4662
|
+
const text = String(cmdText || '').trim();
|
|
4663
|
+
const lower = text.toLowerCase();
|
|
4664
|
+
if (!text) return false;
|
|
4665
|
+
if (/[;&|`$<>]/.test(text)) return false;
|
|
4666
|
+
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;
|
|
4667
|
+
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;
|
|
4668
|
+
if (/^(npm|pnpm|yarn)\s+(test|run\s+(test|lint|build|check|typecheck)|exec\s+tsc)\b/i.test(text)) return true;
|
|
4669
|
+
if (/^node\s+--check\s+\S+/i.test(text)) return true;
|
|
4670
|
+
if (/^(git\s+(status|diff|log|show|branch|rev-parse)|rg|grep|findstr|ls|dir|pwd|cat|type)\b/i.test(text)) return true;
|
|
4671
|
+
return false;
|
|
4672
|
+
}
|
|
4673
|
+
|
|
4674
|
+
function isBalancedRemoteSlashCommand(line) {
|
|
4675
|
+
if (isReadOnlyRemoteSlashCommand(line)) return true;
|
|
4676
|
+
const [cmd, ...rest] = parseWords(line);
|
|
4677
|
+
const lc = String(cmd || '').toLowerCase();
|
|
4678
|
+
if (lc === '/sh') {
|
|
4679
|
+
const cmdText = line.slice(line.indexOf('/sh') + 3).trim();
|
|
4680
|
+
return isBalancedRemoteShellCommand(cmdText);
|
|
4681
|
+
}
|
|
4682
|
+
if (lc === '/agent') return true;
|
|
4683
|
+
if (lc === '/ux') return true;
|
|
4684
|
+
if (lc === '/remote-control' || lc === '/remote' || lc === '/rc') return true;
|
|
4685
|
+
if (lc === '/agent-run') {
|
|
4686
|
+
return rest.some((x) => String(x).toLowerCase() === '--dry');
|
|
4687
|
+
}
|
|
4688
|
+
return false;
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
function assessRemoteControlPromptPermission(opts, line) {
|
|
4692
|
+
const mode = getRemoteControlPermissionMode(opts);
|
|
4693
|
+
const text = String(line || '').trim();
|
|
4694
|
+
if (!text) return { ok: false, mode, reason: 'empty prompt' };
|
|
4695
|
+
if (mode === 'full') return { ok: true, mode };
|
|
4696
|
+
if (!text.startsWith('/')) return { ok: true, mode, skipNaturalActions: true };
|
|
4697
|
+
if (mode === 'read-only') {
|
|
4698
|
+
if (isReadOnlyRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
|
|
4699
|
+
return { ok: false, mode, reason: 'read-only mode allows inspection commands only' };
|
|
4700
|
+
}
|
|
4701
|
+
if (isBalancedRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
|
|
4702
|
+
return { ok: false, mode, reason: 'balanced mode blocks mutating or unsafe remote commands' };
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4548
4705
|
function remoteControlSleep(ms) {
|
|
4549
4706
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4550
4707
|
}
|
|
@@ -4590,7 +4747,7 @@ async function fetchRemoteControlJson(url, options = {}, timeoutMs = 30000) {
|
|
|
4590
4747
|
}
|
|
4591
4748
|
}
|
|
4592
4749
|
|
|
4593
|
-
async function postCloudRemoteHeartbeat(state) {
|
|
4750
|
+
async function postCloudRemoteHeartbeat(state, extra = {}) {
|
|
4594
4751
|
const heartbeatUrl = withRemoteControlQuery(
|
|
4595
4752
|
state.heartbeatUrl,
|
|
4596
4753
|
state.baseUrl,
|
|
@@ -4599,7 +4756,7 @@ async function postCloudRemoteHeartbeat(state) {
|
|
|
4599
4756
|
await fetchRemoteControlJson(heartbeatUrl, {
|
|
4600
4757
|
method: 'POST',
|
|
4601
4758
|
headers: { 'Content-Type': 'application/json' },
|
|
4602
|
-
body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION })
|
|
4759
|
+
body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION, ...extra })
|
|
4603
4760
|
}, 12000);
|
|
4604
4761
|
}
|
|
4605
4762
|
|
|
@@ -4620,6 +4777,40 @@ async function postCloudRemoteResult(state, commandId, result) {
|
|
|
4620
4777
|
}, 35000);
|
|
4621
4778
|
}
|
|
4622
4779
|
|
|
4780
|
+
async function pollCloudRemoteControlControlLoop(state) {
|
|
4781
|
+
while (state.active) {
|
|
4782
|
+
try {
|
|
4783
|
+
const controlUrl = withRemoteControlQuery(
|
|
4784
|
+
state.controlUrl,
|
|
4785
|
+
state.baseUrl,
|
|
4786
|
+
{ agentToken: state.agentToken }
|
|
4787
|
+
);
|
|
4788
|
+
const data = await fetchRemoteControlJson(controlUrl, { method: 'GET' }, 12000);
|
|
4789
|
+
if (data.revoked) {
|
|
4790
|
+
state.revoked = true;
|
|
4791
|
+
state.cancelRequested = true;
|
|
4792
|
+
state.active = false;
|
|
4793
|
+
console.log('Cloud Remote Control revoked by remote client.');
|
|
4794
|
+
break;
|
|
4795
|
+
}
|
|
4796
|
+
if (data.cancelRequested) {
|
|
4797
|
+
state.cancelRequested = true;
|
|
4798
|
+
state.cancelCommandId = data.cancelCommandId || null;
|
|
4799
|
+
}
|
|
4800
|
+
state.controlErrorCount = 0;
|
|
4801
|
+
} catch (err) {
|
|
4802
|
+
state.controlErrorCount = (state.controlErrorCount || 0) + 1;
|
|
4803
|
+
if (/revoked|410/i.test(String(err.message || err))) {
|
|
4804
|
+
state.revoked = true;
|
|
4805
|
+
state.cancelRequested = true;
|
|
4806
|
+
state.active = false;
|
|
4807
|
+
break;
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
await remoteControlSleep(Math.min(3000, 1000 + (state.controlErrorCount || 0) * 250));
|
|
4811
|
+
}
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4623
4814
|
async function pollCloudRemoteControlLoop(opts, state) {
|
|
4624
4815
|
let lastHeartbeatAt = 0;
|
|
4625
4816
|
while (state.active) {
|
|
@@ -4631,6 +4822,17 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4631
4822
|
{ agentToken: state.agentToken, after: state.lastCommandId || 0 }
|
|
4632
4823
|
);
|
|
4633
4824
|
const data = await fetchRemoteControlJson(pollUrl, { method: 'GET' }, 35000);
|
|
4825
|
+
if (data.revoked) {
|
|
4826
|
+
state.revoked = true;
|
|
4827
|
+
state.cancelRequested = true;
|
|
4828
|
+
state.active = false;
|
|
4829
|
+
console.log('Cloud Remote Control revoked by remote client.');
|
|
4830
|
+
break;
|
|
4831
|
+
}
|
|
4832
|
+
if (data.cancelRequested) {
|
|
4833
|
+
state.cancelRequested = true;
|
|
4834
|
+
state.cancelCommandId = data.cancelCommandId || null;
|
|
4835
|
+
}
|
|
4634
4836
|
const commands = Array.isArray(data.commands) ? data.commands : [];
|
|
4635
4837
|
state.lastSeenAt = Date.now();
|
|
4636
4838
|
state.errorCount = 0;
|
|
@@ -4641,7 +4843,10 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4641
4843
|
if (commandId > (state.lastCommandId || 0)) state.lastCommandId = commandId;
|
|
4642
4844
|
const text = String(command?.text || command?.prompt || '').trim();
|
|
4643
4845
|
if (!text) continue;
|
|
4846
|
+
state.cancelRequested = false;
|
|
4847
|
+
state.cancelCommandId = null;
|
|
4644
4848
|
state.busy = true;
|
|
4849
|
+
opts._remoteControlExecutionState = state;
|
|
4645
4850
|
let result;
|
|
4646
4851
|
try {
|
|
4647
4852
|
result = await runRemoteControlPrompt(opts, text);
|
|
@@ -4649,10 +4854,23 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4649
4854
|
result = { ok: false, output: err.stack || err.message || String(err) };
|
|
4650
4855
|
} finally {
|
|
4651
4856
|
state.busy = false;
|
|
4857
|
+
delete opts._remoteControlExecutionState;
|
|
4858
|
+
}
|
|
4859
|
+
if (state.cancelRequested && result.ok) {
|
|
4860
|
+
result = { ok: false, output: 'Canceled by remote request.' };
|
|
4652
4861
|
}
|
|
4653
4862
|
await postCloudRemoteResult(state, commandId, result);
|
|
4863
|
+
if (state.cancelRequested) {
|
|
4864
|
+
state.cancelRequested = false;
|
|
4865
|
+
state.cancelCommandId = null;
|
|
4866
|
+
}
|
|
4654
4867
|
}
|
|
4655
4868
|
|
|
4869
|
+
if (!commands.length && state.cancelRequested && !state.busy) {
|
|
4870
|
+
await postCloudRemoteHeartbeat(state, { cancelAck: true });
|
|
4871
|
+
state.cancelRequested = false;
|
|
4872
|
+
state.cancelCommandId = null;
|
|
4873
|
+
}
|
|
4656
4874
|
if (!commands.length && Date.now() - lastHeartbeatAt > 10000) {
|
|
4657
4875
|
lastHeartbeatAt = Date.now();
|
|
4658
4876
|
await postCloudRemoteHeartbeat(state);
|
|
@@ -4683,6 +4901,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4683
4901
|
|
|
4684
4902
|
const baseUrl = getRemoteControlRelayBase(opts, overrides);
|
|
4685
4903
|
const name = getRemoteControlDisplayName(opts, overrides);
|
|
4904
|
+
const permission = getRemoteControlPermissionMode(opts, overrides);
|
|
4905
|
+
opts.remoteControlPermission = permission;
|
|
4686
4906
|
const payload = {
|
|
4687
4907
|
name,
|
|
4688
4908
|
cwd: opts.cwd,
|
|
@@ -4690,7 +4910,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4690
4910
|
machine: os.hostname(),
|
|
4691
4911
|
platform: process.platform,
|
|
4692
4912
|
node: process.version,
|
|
4693
|
-
mode: opts.agent ? 'agent' : 'chat'
|
|
4913
|
+
mode: opts.agent ? 'agent' : 'chat',
|
|
4914
|
+
permission
|
|
4694
4915
|
};
|
|
4695
4916
|
const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
|
|
4696
4917
|
method: 'POST',
|
|
@@ -4712,11 +4933,13 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4712
4933
|
agentToken,
|
|
4713
4934
|
clientToken: data.clientToken || '',
|
|
4714
4935
|
name,
|
|
4936
|
+
permission,
|
|
4715
4937
|
cwd: opts.cwd,
|
|
4716
4938
|
url: data.url || `${baseUrl}/bortex-code/remote?session=${encodeURIComponent(sessionId)}`,
|
|
4717
4939
|
pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
|
|
4718
4940
|
resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
|
|
4719
4941
|
heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
|
|
4942
|
+
controlUrl: data.controlUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/control`,
|
|
4720
4943
|
lastCommandId: 0,
|
|
4721
4944
|
startedAt: Date.now(),
|
|
4722
4945
|
busy: false,
|
|
@@ -4729,9 +4952,13 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
4729
4952
|
state.pollPromise = pollCloudRemoteControlLoop(opts, state).catch((err) => {
|
|
4730
4953
|
if (state.active) console.error(`Cloud Remote Control stopped unexpectedly: ${err.message || String(err)}`);
|
|
4731
4954
|
});
|
|
4955
|
+
state.controlPromise = pollCloudRemoteControlControlLoop(state).catch((err) => {
|
|
4956
|
+
if (state.active) console.error(`Cloud Remote Control control loop stopped unexpectedly: ${err.message || String(err)}`);
|
|
4957
|
+
});
|
|
4732
4958
|
|
|
4733
4959
|
console.log(`Cloud Remote Control active: ${state.name}`);
|
|
4734
4960
|
console.log(`URL: ${state.url}`);
|
|
4961
|
+
console.log(`Permission: ${state.permission}`);
|
|
4735
4962
|
console.log('No inbound port required. Keep this process running.');
|
|
4736
4963
|
console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
|
|
4737
4964
|
return state;
|
|
@@ -4755,6 +4982,7 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
|
|
|
4755
4982
|
host: opts.remoteControlHost || '127.0.0.1',
|
|
4756
4983
|
port: Number(opts.remoteControlPort || 0) || 0,
|
|
4757
4984
|
relayUrl: opts.remoteControlRelayUrl || '',
|
|
4985
|
+
permission: opts.remoteControlPermission || 'balanced',
|
|
4758
4986
|
cloud: false
|
|
4759
4987
|
};
|
|
4760
4988
|
const nameParts = [];
|
|
@@ -4785,6 +5013,19 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
|
|
|
4785
5013
|
out.relayUrl = a.slice('--remote-relay='.length);
|
|
4786
5014
|
continue;
|
|
4787
5015
|
}
|
|
5016
|
+
if ((a === '--permission' || a === '--remote-permission') && rest[i + 1]) {
|
|
5017
|
+
out.permission = String(rest[i + 1]);
|
|
5018
|
+
i += 1;
|
|
5019
|
+
continue;
|
|
5020
|
+
}
|
|
5021
|
+
if (a.startsWith('--permission=')) {
|
|
5022
|
+
out.permission = a.slice('--permission='.length);
|
|
5023
|
+
continue;
|
|
5024
|
+
}
|
|
5025
|
+
if (a.startsWith('--remote-permission=')) {
|
|
5026
|
+
out.permission = a.slice('--remote-permission='.length);
|
|
5027
|
+
continue;
|
|
5028
|
+
}
|
|
4788
5029
|
if ((a === '--host' || a === '--remote-host') && rest[i + 1]) {
|
|
4789
5030
|
out.host = String(rest[i + 1]);
|
|
4790
5031
|
i += 1;
|
|
@@ -7605,20 +7846,21 @@ async function main() {
|
|
|
7605
7846
|
}
|
|
7606
7847
|
|
|
7607
7848
|
if (opts.remoteControlServerMode) {
|
|
7849
|
+
let remoteState = null;
|
|
7608
7850
|
if (opts.remoteControlCloud) {
|
|
7609
|
-
await startCloudRemoteControlSession(opts, {
|
|
7851
|
+
remoteState = await startCloudRemoteControlSession(opts, {
|
|
7610
7852
|
name: opts.remoteControlName,
|
|
7611
7853
|
relayUrl: opts.remoteControlRelayUrl
|
|
7612
7854
|
});
|
|
7613
7855
|
} else {
|
|
7614
|
-
await startRemoteControlServer(opts, {
|
|
7856
|
+
remoteState = await startRemoteControlServer(opts, {
|
|
7615
7857
|
name: opts.remoteControlName,
|
|
7616
7858
|
host: opts.remoteControlHost,
|
|
7617
7859
|
port: opts.remoteControlPort
|
|
7618
7860
|
});
|
|
7619
7861
|
}
|
|
7620
7862
|
console.log('Server mode: keep this process running. Press Ctrl+C to stop Remote Control.');
|
|
7621
|
-
|
|
7863
|
+
const signalPromise = new Promise((resolve) => {
|
|
7622
7864
|
let stopping = false;
|
|
7623
7865
|
const stop = async () => {
|
|
7624
7866
|
if (stopping) return;
|
|
@@ -7634,6 +7876,17 @@ async function main() {
|
|
|
7634
7876
|
process.once('SIGINT', stop);
|
|
7635
7877
|
process.once('SIGTERM', stop);
|
|
7636
7878
|
});
|
|
7879
|
+
const remoteDonePromises = [];
|
|
7880
|
+
if (remoteState?.pollPromise) remoteDonePromises.push(remoteState.pollPromise);
|
|
7881
|
+
if (remoteState?.controlPromise) remoteDonePromises.push(remoteState.controlPromise);
|
|
7882
|
+
if (remoteDonePromises.length) {
|
|
7883
|
+
await Promise.race([signalPromise, ...remoteDonePromises]);
|
|
7884
|
+
if (opts.remoteControlCloudSession?.active) {
|
|
7885
|
+
await stopCloudRemoteControlSession(opts);
|
|
7886
|
+
}
|
|
7887
|
+
} else {
|
|
7888
|
+
await signalPromise;
|
|
7889
|
+
}
|
|
7637
7890
|
return;
|
|
7638
7891
|
}
|
|
7639
7892
|
|