herdr-remote 0.2.25 → 0.2.26

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 CHANGED
@@ -32,11 +32,19 @@ herdr-remote start | stop | restart
32
32
  herdr-remote status [--json]
33
33
  herdr-remote pair [--json]
34
34
  herdr-remote url
35
+ herdr-remote admin-broker install | uninstall | status # Windows
35
36
  herdr-remote keepalive install | uninstall | restart | status
36
37
  herdr-remote plugin link | unlink | status
37
38
  herdr-remote --lang zh|en
38
39
  ```
39
40
 
41
+ On Windows, the TUI's **Keep-alive** page can register a per-user Task Scheduler broker with highest
42
+ privileges and start it for the signed-in account. Choose **Set up admin terminal access** and
43
+ approve the one-time Windows prompt under the same account as Herdr Remote; paired WebUI windows
44
+ can then open a standalone elevated PowerShell without another UAC prompt. The CLI
45
+ `admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
46
+ Use `admin-broker status` to check the broker.
47
+
40
48
  ## Herdr Plugin
41
49
 
42
50
  Register as a native Herdr plugin:
package/README.zh-CN.md CHANGED
@@ -32,11 +32,14 @@ herdr-remote start | stop | restart
32
32
  herdr-remote status [--json]
33
33
  herdr-remote pair [--json]
34
34
  herdr-remote url
35
+ herdr-remote admin-broker install | uninstall | status # Windows
35
36
  herdr-remote keepalive install | uninstall | restart | status
36
37
  herdr-remote plugin link | unlink | status
37
38
  herdr-remote --lang zh|en
38
39
  ```
39
40
 
41
+ 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,已配对的 WebUI 窗口可以新建独立的管理员 PowerShell,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
42
+
40
43
  ## Herdr 插件
41
44
 
42
45
  注册为 Herdr 插件:
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ Commands:
26
26
  pair [--json] Create pairing code
27
27
  url Print browser access URL
28
28
  run [--daemon] Run services in foreground (keep-alive)
29
+ admin-broker install | uninstall | status (Windows)
29
30
  keepalive <action> install | uninstall | restart | status
30
31
  plugin <action> link | unlink | status (Herdr plugin)
31
32
 
@@ -222,6 +223,25 @@ async function main(argv = process.argv.slice(2)) {
222
223
  await runForeground({ logToFiles: Boolean(flags.daemon) });
223
224
  return;
224
225
  }
226
+ case 'admin-broker': {
227
+ const broker = await import('./connector/admin-broker.js');
228
+ const action = positional[1] || 'status';
229
+ if (action === 'install') {
230
+ broker.installAdminBroker(t);
231
+ printJson({ installed: true, available: await broker.adminBrokerStatus() });
232
+ }
233
+ else if (action === 'uninstall') {
234
+ broker.uninstallAdminBroker(t);
235
+ printJson({ installed: false });
236
+ }
237
+ else if (action === 'status') {
238
+ printJson({ available: await broker.adminBrokerStatus() });
239
+ }
240
+ else {
241
+ throw new Error(`unknown administrator broker action: ${action}`);
242
+ }
243
+ return;
244
+ }
225
245
  case 'keepalive': {
226
246
  const keepalive = await import('./keepalive/index.js');
227
247
  const action = positional[1] || 'status';
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { cliEntryPoint } from '../keepalive/environment.js';
3
+ import { encodePowerShellCommand, escapePowerShellSingleQuoted, } from '../keepalive/windows-service.js';
4
+ function waitForChild(child) {
5
+ return new Promise((resolve, reject) => {
6
+ child.once('error', reject);
7
+ child.once('close', (code) => resolve(code ?? 1));
8
+ });
9
+ }
10
+ export function renderAdminBrokerElevationScript({ action, nodePath, entryPoint, }) {
11
+ const quote = escapePowerShellSingleQuoted;
12
+ const args = `"${entryPoint}" admin-broker ${action} --elevated`;
13
+ return `$ErrorActionPreference = 'Stop'
14
+ try {
15
+ $process = Start-Process -FilePath '${quote(nodePath)}' -ArgumentList '${quote(args)}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden
16
+ } catch {
17
+ if (($_.Exception.HResult -band 65535) -eq 1223) { exit 1223 }
18
+ exit 1
19
+ }
20
+ exit $process.ExitCode`;
21
+ }
22
+ function elevatedErrorCode(message, code) {
23
+ return Object.assign(new Error(message), { code });
24
+ }
25
+ /** Register the per-user task from the Windows TUI, requesting elevation once. */
26
+ export async function configureAdminBroker(action, t) {
27
+ if (process.platform !== 'win32')
28
+ throw new Error(t('adminBroker.windowsOnly'));
29
+ const script = renderAdminBrokerElevationScript({
30
+ action,
31
+ nodePath: process.execPath,
32
+ entryPoint: cliEntryPoint(),
33
+ });
34
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(script)], { windowsHide: true, stdio: 'ignore' });
35
+ const exitCode = await waitForChild(child);
36
+ if (exitCode === 1223)
37
+ throw elevatedErrorCode(t('adminBroker.elevationCancelled'), 'ELEVATION_CANCELLED');
38
+ if (exitCode !== 0)
39
+ throw new Error(t('adminBroker.setupFailed'));
40
+ }
@@ -0,0 +1,208 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import net from 'node:net';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PtySession } from '../pty-session.js';
6
+ import { PACKAGE_ROOT } from '../paths.js';
7
+ import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, adminBrokerTaskName, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
8
+ const BROKER_PING_TIMEOUT_MS = 1_000;
9
+ function powershellQuote(value) {
10
+ return `'${value.replaceAll("'", "''")}'`;
11
+ }
12
+ function runPowerShell(script, t) {
13
+ try {
14
+ execFileSync('powershell.exe', [
15
+ '-NoProfile',
16
+ '-NonInteractive',
17
+ '-EncodedCommand',
18
+ Buffer.from(script, 'utf16le').toString('base64'),
19
+ ], {
20
+ stdio: 'pipe',
21
+ windowsHide: true,
22
+ timeout: 15_000,
23
+ });
24
+ }
25
+ catch {
26
+ throw new Error(t('adminBroker.needsElevation'));
27
+ }
28
+ }
29
+ export function installAdminBroker(t) {
30
+ if (process.platform !== 'win32')
31
+ throw new Error(t('adminBroker.windowsOnly'));
32
+ const brokerPath = fileURLToPath(import.meta.url);
33
+ const brokerCommand = `& ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
34
+ const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
35
+ const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
36
+ const script = `
37
+ $ErrorActionPreference = 'Stop'
38
+ $taskName = ${powershellQuote(adminBrokerTaskName())}
39
+ $user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
40
+ $action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(PACKAGE_ROOT)}
41
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
42
+ $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
43
+ $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Seconds 0)
44
+ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
45
+ Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
46
+ Start-ScheduledTask -TaskName $taskName
47
+ `;
48
+ runPowerShell(script, t);
49
+ }
50
+ export function uninstallAdminBroker(t) {
51
+ if (process.platform !== 'win32')
52
+ throw new Error(t('adminBroker.windowsOnly'));
53
+ runPowerShell(`
54
+ $ErrorActionPreference = 'Stop'
55
+ $taskName = ${powershellQuote(adminBrokerTaskName())}
56
+ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
57
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
58
+ `, t);
59
+ }
60
+ export function adminBrokerStatus() {
61
+ if (process.platform !== 'win32')
62
+ return Promise.resolve(false);
63
+ return new Promise((resolve) => {
64
+ let settled = false;
65
+ let response = '';
66
+ const socket = net.createConnection(adminBrokerPipePath());
67
+ const finish = (available) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ socket.destroy();
72
+ resolve(available);
73
+ };
74
+ socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
75
+ socket.setEncoding('utf8');
76
+ socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping' }));
77
+ socket.on('data', (chunk) => {
78
+ response += chunk;
79
+ if (response.includes('\n'))
80
+ finish(response.includes('"type":"pong"'));
81
+ });
82
+ socket.on('error', () => finish(false));
83
+ });
84
+ }
85
+ function writeError(socket, error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ sendAdminBrokerMessage(socket, { type: 'error', message });
88
+ socket.end();
89
+ }
90
+ function startBrokerPty(socket, message) {
91
+ if (typeof message.command !== 'string' || !message.command || message.command.length > 4096)
92
+ throw new Error('Invalid administrator terminal command.');
93
+ if (path.basename(message.command).toLowerCase() !== 'powershell.exe')
94
+ throw new Error('The administrator broker only starts PowerShell.');
95
+ if (!Array.isArray(message.args) ||
96
+ message.args.length > 128 ||
97
+ !message.args.every((arg) => typeof arg === 'string'))
98
+ throw new Error('Invalid administrator terminal arguments.');
99
+ if (message.args.length !== 2 ||
100
+ message.args[0] !== '-NoLogo' ||
101
+ message.args[1] !== '-NoProfile')
102
+ throw new Error('The administrator broker only starts an interactive PowerShell prompt.');
103
+ if (typeof message.cwd !== 'string' || message.cwd.length > 4096)
104
+ throw new Error('Invalid administrator terminal working directory.');
105
+ if (message.socketPath !== null && message.socketPath !== undefined)
106
+ throw new Error('An administrator PowerShell session does not use a Herdr socket.');
107
+ const pty = new PtySession({
108
+ command: message.command,
109
+ args: message.args,
110
+ cwd: message.cwd,
111
+ socketPath: message.socketPath,
112
+ platform: 'win32',
113
+ // A plain PowerShell prompt does not need the Herdr UI's Kitty or mouse modes.
114
+ fastWindowsPty: true,
115
+ });
116
+ pty.start({
117
+ cols: PtySession.clampDimension(message.cols, PtySession.DEFAULT_COLS),
118
+ rows: PtySession.clampDimension(message.rows, PtySession.DEFAULT_ROWS),
119
+ onData: (data) => {
120
+ if (socket.writable)
121
+ sendAdminBrokerMessage(socket, {
122
+ type: 'data',
123
+ dataBase64: Buffer.from(data).toString('base64'),
124
+ });
125
+ },
126
+ onExit: ({ exitCode, signal }) => {
127
+ if (socket.writable)
128
+ sendAdminBrokerMessage(socket, { type: 'exit', exitCode, signal });
129
+ socket.end();
130
+ },
131
+ });
132
+ sendAdminBrokerMessage(socket, { type: 'started', pid: pty.terminal?.pid ?? 0 });
133
+ return pty;
134
+ }
135
+ function runAdminBroker() {
136
+ if (process.platform !== 'win32')
137
+ throw new Error('Administrator PTYs are only supported on Windows.');
138
+ const server = net.createServer((socket) => {
139
+ socket.setEncoding('utf8');
140
+ let pending = '';
141
+ let pty = null;
142
+ let firstMessage = true;
143
+ socket.on('data', (chunk) => {
144
+ pending += chunk;
145
+ if (Buffer.byteLength(pending, 'utf8') > ADMIN_BROKER_MAX_MESSAGE_BYTES) {
146
+ socket.destroy(new Error('Administrator terminal message exceeded the limit.'));
147
+ return;
148
+ }
149
+ while (true) {
150
+ const newline = pending.indexOf('\n');
151
+ if (newline < 0)
152
+ return;
153
+ const line = pending.slice(0, newline);
154
+ pending = pending.slice(newline + 1);
155
+ let message;
156
+ try {
157
+ message = JSON.parse(line);
158
+ }
159
+ catch {
160
+ writeError(socket, new Error('Invalid administrator terminal message.'));
161
+ return;
162
+ }
163
+ if (firstMessage) {
164
+ firstMessage = false;
165
+ if (message.type === 'ping') {
166
+ sendAdminBrokerMessage(socket, { type: 'pong' });
167
+ socket.end();
168
+ return;
169
+ }
170
+ if (message.type !== 'start') {
171
+ writeError(socket, new Error('Expected an administrator terminal start request.'));
172
+ return;
173
+ }
174
+ try {
175
+ pty = startBrokerPty(socket, message);
176
+ }
177
+ catch (error) {
178
+ writeError(socket, error);
179
+ return;
180
+ }
181
+ continue;
182
+ }
183
+ if (!pty)
184
+ return;
185
+ if (message.type === 'input' && typeof message.dataBase64 === 'string') {
186
+ if (message.dataBase64.length <= ADMIN_BROKER_MAX_MESSAGE_BYTES)
187
+ pty.write(Buffer.from(message.dataBase64, 'base64'));
188
+ }
189
+ else if (message.type === 'resize') {
190
+ pty.resize(message.cols, message.rows);
191
+ }
192
+ else if (message.type === 'kill') {
193
+ pty.kill();
194
+ socket.end();
195
+ return;
196
+ }
197
+ }
198
+ });
199
+ socket.on('close', () => pty?.kill());
200
+ socket.on('error', () => pty?.kill());
201
+ });
202
+ server.listen(adminBrokerPipePath());
203
+ server.on('error', (error) => {
204
+ process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
205
+ });
206
+ }
207
+ if (path.basename(process.argv[1] || '') === 'admin-broker.js')
208
+ runAdminBroker();
@@ -0,0 +1,16 @@
1
+ import { createHash } from 'node:crypto';
2
+ const ADMIN_BROKER_TASK_NAME = 'HerdrRemoteAdminBroker';
3
+ export const ADMIN_BROKER_MAX_MESSAGE_BYTES = 2 * 1_024 * 1_024;
4
+ function adminBrokerAccountId() {
5
+ const account = `${process.env.USERDOMAIN || ''}\\${process.env.USERNAME || process.env.USERPROFILE || 'default'}`;
6
+ return createHash('sha256').update(account.toLowerCase()).digest('hex').slice(0, 24);
7
+ }
8
+ export function adminBrokerPipePath() {
9
+ return `\\\\.\\pipe\\herdr-remote-admin-${adminBrokerAccountId()}`;
10
+ }
11
+ export function adminBrokerTaskName() {
12
+ return `${ADMIN_BROKER_TASK_NAME}-${adminBrokerAccountId()}`;
13
+ }
14
+ export function sendAdminBrokerMessage(socket, message) {
15
+ socket.write(`${JSON.stringify(message)}\n`);
16
+ }
@@ -0,0 +1,145 @@
1
+ import net from 'node:net';
2
+ import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
3
+ const BROKER_CONNECT_TIMEOUT_MS = 5_000;
4
+ export class ElevatedPty {
5
+ pid = 0;
6
+ cols;
7
+ rows;
8
+ socket = null;
9
+ input = '';
10
+ connected = false;
11
+ started = false;
12
+ finished = false;
13
+ killed = false;
14
+ dataListener = null;
15
+ exitListener = null;
16
+ readyListener = null;
17
+ errorListener = null;
18
+ options;
19
+ constructor(options, cols, rows) {
20
+ this.options = options;
21
+ this.cols = cols;
22
+ this.rows = rows;
23
+ }
24
+ onData(listener) {
25
+ this.dataListener = listener;
26
+ }
27
+ onExit(listener) {
28
+ this.exitListener = listener;
29
+ }
30
+ start({ cols, rows, onData, onExit, onReady, onError }) {
31
+ this.cols = cols;
32
+ this.rows = rows;
33
+ this.dataListener = onData;
34
+ this.exitListener = onExit;
35
+ this.readyListener = onReady || null;
36
+ this.errorListener = onError || null;
37
+ const socket = net.createConnection(adminBrokerPipePath());
38
+ this.socket = socket;
39
+ socket.setEncoding('utf8');
40
+ socket.setTimeout(BROKER_CONNECT_TIMEOUT_MS, () => {
41
+ socket.destroy(new Error('Administrator terminal broker connection timed out'));
42
+ });
43
+ socket.on('connect', () => {
44
+ this.connected = true;
45
+ socket.setTimeout(0);
46
+ sendAdminBrokerMessage(socket, {
47
+ type: 'start',
48
+ ...this.options,
49
+ cols: this.cols,
50
+ rows: this.rows,
51
+ });
52
+ });
53
+ socket.on('data', (chunk) => this.receive(chunk));
54
+ socket.on('error', (error) => this.fail(error));
55
+ socket.on('close', () => {
56
+ if (this.started && !this.finished && !this.killed) {
57
+ this.finished = true;
58
+ this.exitListener?.({ exitCode: 1 });
59
+ }
60
+ });
61
+ }
62
+ write(data) {
63
+ if (this.started && !this.finished && this.socket?.writable) {
64
+ sendAdminBrokerMessage(this.socket, {
65
+ type: 'input',
66
+ dataBase64: Buffer.from(data).toString('base64'),
67
+ });
68
+ }
69
+ }
70
+ resize(cols, rows) {
71
+ this.cols = cols;
72
+ this.rows = rows;
73
+ if (this.started && this.socket?.writable) {
74
+ sendAdminBrokerMessage(this.socket, { type: 'resize', cols, rows });
75
+ }
76
+ }
77
+ kill() {
78
+ this.killed = true;
79
+ this.pid = 0;
80
+ if (!this.socket)
81
+ return;
82
+ if (this.connected && this.socket.writable) {
83
+ sendAdminBrokerMessage(this.socket, { type: 'kill' });
84
+ this.socket.end();
85
+ }
86
+ else {
87
+ this.socket.destroy();
88
+ }
89
+ }
90
+ receive(chunk) {
91
+ this.input += chunk;
92
+ if (Buffer.byteLength(this.input, 'utf8') > ADMIN_BROKER_MAX_MESSAGE_BYTES) {
93
+ this.socket?.destroy(new Error('Administrator terminal broker message exceeded the limit'));
94
+ return;
95
+ }
96
+ while (true) {
97
+ const newline = this.input.indexOf('\n');
98
+ if (newline < 0)
99
+ return;
100
+ const line = this.input.slice(0, newline);
101
+ this.input = this.input.slice(newline + 1);
102
+ let message;
103
+ try {
104
+ message = JSON.parse(line);
105
+ }
106
+ catch {
107
+ this.socket?.destroy(new Error('Administrator terminal broker sent invalid JSON'));
108
+ return;
109
+ }
110
+ if (message.type === 'started') {
111
+ this.started = true;
112
+ this.pid = Number(message.pid) || 0;
113
+ this.readyListener?.();
114
+ }
115
+ else if (message.type === 'data' && typeof message.dataBase64 === 'string') {
116
+ this.dataListener?.(Buffer.from(message.dataBase64, 'base64').toString('utf8'));
117
+ }
118
+ else if (message.type === 'exit') {
119
+ this.finished = true;
120
+ this.pid = 0;
121
+ this.exitListener?.({
122
+ exitCode: Number(message.exitCode) || 0,
123
+ ...(typeof message.signal === 'number' ? { signal: message.signal } : {}),
124
+ });
125
+ this.socket?.end();
126
+ }
127
+ else if (message.type === 'error') {
128
+ this.fail(new Error(typeof message.message === 'string' ? message.message : 'Admin PTY failed'));
129
+ }
130
+ }
131
+ }
132
+ fail(error) {
133
+ if (this.started) {
134
+ if (!this.finished && !this.killed) {
135
+ this.finished = true;
136
+ this.exitListener?.({ exitCode: 1 });
137
+ }
138
+ return;
139
+ }
140
+ if (this.finished || this.killed)
141
+ return;
142
+ this.finished = true;
143
+ this.errorListener?.(error);
144
+ }
145
+ }
@@ -185,10 +185,19 @@ export class HostConnector {
185
185
  password: this.relayPassword || null,
186
186
  hostname: os.hostname(),
187
187
  platform: process.platform,
188
+ ...(process.platform === 'win32' &&
189
+ (process.env.MSYSTEM || /(?:^|[\\/])(?:bash|sh)(?:\.exe)?$/i.test(process.env.SHELL || ''))
190
+ ? { shellProfile: 'git-bash' }
191
+ : {}),
188
192
  arch: process.arch,
189
193
  terminalPalette: this.terminalPalette || null,
190
194
  terminalFont: this.font.publicFont(),
191
- capabilities: [CAPABILITY.hostHandoff, CAPABILITY.idleHeartbeat, CAPABILITY.binaryFrameV2],
195
+ capabilities: [
196
+ CAPABILITY.hostHandoff,
197
+ CAPABILITY.idleHeartbeat,
198
+ CAPABILITY.binaryFrameV2,
199
+ ...(process.platform === 'win32' ? [CAPABILITY.adminSessions] : []),
200
+ ],
192
201
  });
193
202
  // The relay sends host_ready with the current browser count. No business
194
203
  // heartbeat is started until that message says somebody is watching.
@@ -11,6 +11,8 @@ import { streamOf } from './wire.js';
11
11
  * session the user actually used and then left.
12
12
  */
13
13
  const FAST_FAILURE_MS = 1500;
14
+ /** Coalesce Windows ConPTY's many small output chunks before they reach the relay. */
15
+ const WINDOWS_OUTPUT_BATCH_MS = 4;
14
16
  /** How many broken starts in a row before we stop calling them exits. */
15
17
  export const FAST_FAILURE_LIMIT = 3;
16
18
  export class Sessions {
@@ -103,6 +105,10 @@ export class Sessions {
103
105
  if (!streamId)
104
106
  return undefined;
105
107
  this.stop(streamId);
108
+ if (message.adminTerminal) {
109
+ this.spawn(message);
110
+ return undefined;
111
+ }
106
112
  const missingHerdr = this.ensureHerdrCommand();
107
113
  if (missingHerdr) {
108
114
  process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
@@ -198,11 +204,15 @@ export class Sessions {
198
204
  spawn(message) {
199
205
  const streamId = streamOf(message);
200
206
  const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
207
+ const adminTerminal = message.adminTerminal === true;
208
+ // The normal Herdr server keeps its original token, so admin mode needs its own shell.
201
209
  const pty = new this.Pty({
202
- command: this.herdrCommand,
203
- args: this.herdrArgs,
210
+ command: adminTerminal ? 'powershell.exe' : this.herdrCommand,
211
+ args: adminTerminal ? ['-NoLogo', '-NoProfile'] : this.herdrArgs,
204
212
  cwd: this.cwd,
205
- socketPath: this.socketPath,
213
+ socketPath: adminTerminal ? null : this.socketPath,
214
+ adminTerminal,
215
+ fastWindowsPty: adminTerminal,
206
216
  });
207
217
  const session = {
208
218
  id: streamId,
@@ -214,12 +224,17 @@ export class Sessions {
214
224
  startedAtMs: Date.now(),
215
225
  pendingOutput: [],
216
226
  flushImmediate: null,
227
+ flushTimeout: null,
217
228
  };
218
229
  const flushOutput = () => {
219
230
  if (session.flushImmediate) {
220
231
  clearImmediate(session.flushImmediate);
221
232
  session.flushImmediate = null;
222
233
  }
234
+ if (session.flushTimeout) {
235
+ clearTimeout(session.flushTimeout);
236
+ session.flushTimeout = null;
237
+ }
223
238
  if (session.pendingOutput.length === 0)
224
239
  return;
225
240
  const payload = Buffer.concat(session.pendingOutput);
@@ -228,6 +243,29 @@ export class Sessions {
228
243
  ? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
229
244
  : packStreamFrame('output', streamId, payload));
230
245
  };
246
+ let readySent = false;
247
+ const reportReady = () => {
248
+ if (readySent || this.byStream.get(streamId) !== session)
249
+ return;
250
+ readySent = true;
251
+ this.link.send({ type: 'session_ready', clientId: streamId });
252
+ };
253
+ const reportStartError = (error) => {
254
+ if (this.byStream.get(streamId) !== session)
255
+ return;
256
+ this.forget(session);
257
+ session.pty.kill();
258
+ const code = error.code;
259
+ const brokerUnavailable = session.pty.adminTerminal &&
260
+ (code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT');
261
+ this.sendError(streamId, brokerUnavailable ? 'admin_broker_unavailable' : 'pty_start_failed', brokerUnavailable
262
+ ? 'Run herdr-remote admin-broker install once from an administrator terminal.'
263
+ : error.message);
264
+ this.link.changed();
265
+ };
266
+ this.byStream.set(streamId, session);
267
+ if (streamIndex !== null)
268
+ this.streamIndexToId.set(streamIndex, streamId);
231
269
  try {
232
270
  pty.start({
233
271
  cols: session.cols,
@@ -237,10 +275,14 @@ export class Sessions {
237
275
  if (chunk.length === 0)
238
276
  return;
239
277
  session.pendingOutput.push(chunk);
240
- // Coalesce burst output from the same tick into a single frame to reduce
241
- // packet overhead without introducing timer latency.
242
- if (!session.flushImmediate)
243
- session.flushImmediate = setImmediate(flushOutput);
278
+ if (!session.flushImmediate && !session.flushTimeout) {
279
+ if (process.platform === 'win32') {
280
+ session.flushTimeout = setTimeout(flushOutput, WINDOWS_OUTPUT_BATCH_MS);
281
+ }
282
+ else {
283
+ session.flushImmediate = setImmediate(flushOutput);
284
+ }
285
+ }
244
286
  },
245
287
  onExit: ({ exitCode }) => {
246
288
  if (this.byStream.get(streamId) !== session)
@@ -249,18 +291,16 @@ export class Sessions {
249
291
  this.reportExit(session, exitCode);
250
292
  this.link.changed();
251
293
  },
294
+ onReady: reportReady,
295
+ onError: reportStartError,
252
296
  });
297
+ if (!adminTerminal)
298
+ reportReady();
253
299
  }
254
300
  catch (error) {
255
- discardOutput(session);
256
- this.sendError(streamId, 'pty_start_failed', error.message);
257
- pty.kill();
301
+ reportStartError(error);
258
302
  return;
259
303
  }
260
- this.byStream.set(streamId, session);
261
- if (streamIndex !== null)
262
- this.streamIndexToId.set(streamIndex, streamId);
263
- this.link.send({ type: 'session_ready', clientId: streamId });
264
304
  this.link.changed();
265
305
  }
266
306
  /**
@@ -362,5 +402,9 @@ function discardOutput(session) {
362
402
  clearImmediate(session.flushImmediate);
363
403
  session.flushImmediate = null;
364
404
  }
405
+ if (session.flushTimeout) {
406
+ clearTimeout(session.flushTimeout);
407
+ session.flushTimeout = null;
408
+ }
365
409
  session.pendingOutput = [];
366
410
  }
package/dist/i18n/en.js CHANGED
@@ -84,6 +84,19 @@ const en = {
84
84
  'services.logEmpty': 'No logs.',
85
85
  'services.logRelay': 'Relay',
86
86
  'services.logHost': 'Host connector',
87
+ 'adminBroker.windowsOnly': 'The administrator terminal broker only runs on Windows.',
88
+ 'adminBroker.needsElevation': 'Open the Windows TUI and choose Set up admin terminal access. Approve the one-time Windows prompt under the same account as Herdr Remote.',
89
+ 'adminBroker.title': 'Admin terminal broker',
90
+ 'adminBroker.checking': 'Checking…',
91
+ 'adminBroker.ready': 'Ready',
92
+ 'adminBroker.notReady': 'Not configured or stopped',
93
+ 'adminBroker.install': 'Set up admin terminal access',
94
+ 'adminBroker.uninstall': 'Remove admin terminal access',
95
+ 'adminBroker.installDone': 'Admin terminal broker setup task registered.',
96
+ 'adminBroker.uninstallDone': 'Admin terminal broker setup task removed.',
97
+ 'adminBroker.elevationCancelled': 'Administrator approval was declined; no changes were made.',
98
+ 'adminBroker.setupFailed': 'Could not configure the administrator terminal broker.',
99
+ 'adminBroker.tuiHint': 'Set this up once. Windows asks for administrator approval; later WebUI admin terminals open without UAC.',
87
100
  'relay.title': 'Relay settings',
88
101
  'relay.settings': 'Settings',
89
102
  'relay.locked': 'fixed',
package/dist/i18n/zh.js CHANGED
@@ -83,6 +83,19 @@ const zh = {
83
83
  'services.logEmpty': '暂无日志。',
84
84
  'services.logRelay': 'Relay',
85
85
  'services.logHost': '主机连接器',
86
+ 'adminBroker.windowsOnly': '管理员终端 broker 只支持 Windows。',
87
+ 'adminBroker.needsElevation': '请打开 Windows TUI 并选择“设置管理员终端”,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示。',
88
+ 'adminBroker.title': '管理员终端 broker',
89
+ 'adminBroker.checking': '正在检查…',
90
+ 'adminBroker.ready': '已就绪',
91
+ 'adminBroker.notReady': '尚未设置或未运行',
92
+ 'adminBroker.install': '设置管理员终端',
93
+ 'adminBroker.uninstall': '移除管理员终端设置',
94
+ 'adminBroker.installDone': '管理员终端 broker 任务已注册。',
95
+ 'adminBroker.uninstallDone': '管理员终端 broker 任务已移除。',
96
+ 'adminBroker.elevationCancelled': '未批准管理员权限,未进行更改。',
97
+ 'adminBroker.setupFailed': '管理员终端 broker 设置失败。',
98
+ 'adminBroker.tuiHint': '只需设置一次。Windows 会请求管理员授权;之后 WebUI 新建管理员终端不再弹出 UAC。',
86
99
  'relay.title': 'Relay 设置',
87
100
  'relay.settings': '设置',
88
101
  'relay.locked': '固定',
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import pty from 'node-pty';
3
+ import { ElevatedPty } from './connector/elevatedPty.js';
3
4
  class PtySession {
4
5
  static DEFAULT_COLS = 100;
5
6
  static DEFAULT_ROWS = 30;
@@ -10,10 +11,12 @@ class PtySession {
10
11
  cwd;
11
12
  socketPath;
12
13
  platform;
14
+ adminTerminal;
15
+ fastWindowsPty;
13
16
  spawnPty;
14
17
  terminal;
15
18
  startedAt;
16
- constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, } = {}) {
19
+ constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, adminTerminal = false, fastWindowsPty = false, } = {}) {
17
20
  if (typeof command !== 'string' || command.length === 0)
18
21
  throw new TypeError('command must be a non-empty string');
19
22
  if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
@@ -23,6 +26,8 @@ class PtySession {
23
26
  this.cwd = cwd;
24
27
  this.socketPath = socketPath;
25
28
  this.platform = platform;
29
+ this.adminTerminal = adminTerminal;
30
+ this.fastWindowsPty = fastWindowsPty;
26
31
  this.spawnPty = spawnPty;
27
32
  this.terminal = null;
28
33
  this.startedAt = null;
@@ -45,19 +50,43 @@ class PtySession {
45
50
  return fallback;
46
51
  return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
47
52
  }
48
- start({ cols, rows, onData, onExit }) {
53
+ start({ cols, rows, onData, onExit, onReady, onError }) {
49
54
  if (this.terminal)
50
55
  throw new Error('PTY session already started');
51
- // The bundled ConPTY preserves Kitty sequences older system ConPTY drops,
52
- // which is why Herdr embeds it too. System ConPTY's kill() forks a
53
- // console-list helper without windowsHide, which flashes a window here.
56
+ if (this.adminTerminal) {
57
+ if (this.platform !== 'win32')
58
+ throw new Error('Administrator PTYs are only supported on Windows');
59
+ const terminal = new ElevatedPty({ command: this.command, args: this.args, cwd: this.cwd, socketPath: this.socketPath }, PtySession.clampDimension(cols, PtySession.DEFAULT_COLS), PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS));
60
+ this.terminal = terminal;
61
+ this.startedAt = new Date().toISOString();
62
+ terminal.start({
63
+ cols: terminal.cols,
64
+ rows: terminal.rows,
65
+ onData: onData || (() => { }),
66
+ onExit: (event) => {
67
+ this.terminal = null;
68
+ onExit?.(event);
69
+ },
70
+ onReady,
71
+ onError: (error) => {
72
+ this.terminal = null;
73
+ onError?.(error);
74
+ },
75
+ });
76
+ return this;
77
+ }
78
+ // The Herdr UI needs the bundled ConPTY's Kitty and mouse reporting.
54
79
  this.terminal = this.spawnPty(this.command, this.args, {
55
80
  name: 'xterm-256color',
56
81
  cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
57
82
  rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
58
83
  cwd: this.cwd,
59
84
  env: PtySession.childEnv(this.socketPath),
60
- ...(this.platform === 'win32' ? { useConptyDll: true } : {}),
85
+ ...(this.platform === 'win32'
86
+ ? this.fastWindowsPty
87
+ ? { useConpty: false }
88
+ : { useConptyDll: true }
89
+ : {}),
61
90
  });
62
91
  this.startedAt = new Date().toISOString();
63
92
  const terminal = this.terminal;
@@ -69,6 +98,7 @@ class PtySession {
69
98
  onExit(event);
70
99
  });
71
100
  }
101
+ onReady?.();
72
102
  return this;
73
103
  }
74
104
  write(data) {
package/dist/tui/api.js CHANGED
@@ -19,6 +19,16 @@ import * as keepalive from '../keepalive/index.js';
19
19
  import { canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
20
20
  import { checkForRelayUpdate, installedRelayVersion, performRelayUpdate, } from '../relay-updater.js';
21
21
  export { detectLocale, listReachableAddresses, MIN_HERDR_VERSION, OFFICIAL_RELAY_URL, isOfficialRelay, selectedMode, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, checkForRelayUpdate, installedRelayVersion, performRelayUpdate, runsLocalRelay, bindAddress, configExists, configPath, createDraft, createTranslator, ensureRuntime, extractPairingCode, fieldsForMode, fullStatus, getField, getFieldPlaceholder, herdrVersion, keepalive, loadConfig, pair, readLogTail, regenerateHostIdentity, requiresRestart, restartAll, saveDraft, setField, setRelayPassword, startAll, stateDir, stopAll, whileServicesStopped, };
22
+ export async function adminBrokerStatus() {
23
+ if (process.platform !== 'win32')
24
+ return false;
25
+ const broker = await import('../connector/admin-broker.js');
26
+ return broker.adminBrokerStatus();
27
+ }
28
+ export async function configureAdminBroker(action, t) {
29
+ const setup = await import('../connector/admin-broker-setup.js');
30
+ return setup.configureAdminBroker(action, t);
31
+ }
22
32
  /** The command that starts a matching relay, ready to paste on the server. */
23
33
  export function relayStartCommand(config, password) {
24
34
  const publicUrl = config.relay.remoteUrl
@@ -1,13 +1,31 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { theme } from '../theme.js';
5
5
  import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
6
6
  import { ChoiceList } from './Relay.js';
7
- import { KEEPALIVE_MANAGERS, keepalive, saveDraft, setField } from '../api.js';
7
+ import { KEEPALIVE_MANAGERS, adminBrokerStatus, configureAdminBroker, keepalive, saveDraft, setField, } from '../api.js';
8
8
  export function Keepalive({ ctx }) {
9
9
  const { t, status, draft, editingId } = ctx;
10
10
  const [selected, setSelected] = useState('install');
11
+ const [brokerReady, setBrokerReady] = useState(null);
12
+ useEffect(() => {
13
+ if (process.platform !== 'win32')
14
+ return undefined;
15
+ let cancelled = false;
16
+ adminBrokerStatus()
17
+ .then((available) => {
18
+ if (!cancelled)
19
+ setBrokerReady(available);
20
+ })
21
+ .catch(() => {
22
+ if (!cancelled)
23
+ setBrokerReady(false);
24
+ });
25
+ return () => {
26
+ cancelled = true;
27
+ };
28
+ }, []);
11
29
  const current = status?.keepalive;
12
30
  const entries = [
13
31
  { id: 'install', label: t('keepalive.install') },
@@ -17,12 +35,38 @@ export function Keepalive({ ctx }) {
17
35
  ...(process.platform === 'linux' && current?.manager === 'systemd' && !current?.linger
18
36
  ? [{ id: 'linger', label: t('keepalive.enableLinger') }]
19
37
  : []),
38
+ ...(process.platform === 'win32'
39
+ ? [
40
+ { id: 'adminBrokerInstall', label: t('adminBroker.install') },
41
+ { id: 'adminBrokerUninstall', label: t('adminBroker.uninstall') },
42
+ ]
43
+ : []),
20
44
  ];
21
45
  const activate = (id) => {
22
46
  if (id === 'manager') {
23
47
  ctx.setEditing('keepaliveManager');
24
48
  return;
25
49
  }
50
+ if (id === 'adminBrokerInstall' || id === 'adminBrokerUninstall') {
51
+ const action = id === 'adminBrokerInstall' ? 'install' : 'uninstall';
52
+ ctx.run(async () => {
53
+ let ready = false;
54
+ try {
55
+ await configureAdminBroker(action, t);
56
+ ready = action === 'install' && (await adminBrokerStatus());
57
+ }
58
+ catch (error) {
59
+ if (error.code === 'ELEVATION_CANCELLED') {
60
+ ctx.notify((t) => t('adminBroker.elevationCancelled'), 'info');
61
+ return;
62
+ }
63
+ throw error;
64
+ }
65
+ setBrokerReady(ready);
66
+ ctx.notify((t) => t(`adminBroker.${action}Done`), 'success');
67
+ });
68
+ return;
69
+ }
26
70
  ctx.run(async () => {
27
71
  if (id === 'install') {
28
72
  if (keepalive.detectManager(ctx.config.keepalive.manager) === 'windows-service')
@@ -88,7 +132,9 @@ export function Keepalive({ ctx }) {
88
132
  ? t('common.running')
89
133
  : current?.installed
90
134
  ? t('common.installed')
91
- : t('common.notInstalled')] })] }) }), current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
135
+ : t('common.notInstalled')] })] }) }), process.platform === 'win32' ? (_jsxs(_Fragment, { children: [_jsx(Row, { label: t('adminBroker.title'), children: _jsx(Text, { color: brokerReady ? theme.ok : theme.warn, children: brokerReady === null
136
+ ? t('adminBroker.checking')
137
+ : t(brokerReady ? 'adminBroker.ready' : 'adminBroker.notReady') }) }), _jsx(Text, { color: theme.muted, children: t('adminBroker.tuiHint') })] })) : null, current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
92
138
  setSelected(entry.id);
93
139
  activate(entry.id);
94
140
  }, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, current?.manager === 'windows-task' ? (_jsx(Text, { color: theme.muted, children: t('keepalive.windowsTaskNote') })) : null, current?.manager === 'windows-service' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.serviceNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.25"
3
+ version = "0.2.26"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos", "windows"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",