herdr-remote 0.2.26 → 0.2.27

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
@@ -40,8 +40,8 @@ herdr-remote --lang zh|en
40
40
 
41
41
  On Windows, the TUI's **Keep-alive** page can register a per-user Task Scheduler broker with highest
42
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
43
+ approve the one-time Windows prompt under the same account as Herdr Remote; the WebUI's Admin
44
+ button then opens an elevated PowerShell as a new Herdr tab without another UAC prompt. The CLI
45
45
  `admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
46
46
  Use `admin-broker status` to check the broker.
47
47
 
package/README.zh-CN.md CHANGED
@@ -38,7 +38,7 @@ herdr-remote plugin link | unlink | status
38
38
  herdr-remote --lang zh|en
39
39
  ```
40
40
 
41
- 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,已配对的 WebUI 窗口可以新建独立的管理员 PowerShell,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
41
+ 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,WebUI 的“管理员”按钮会在 Herdr 中新建一个管理员 PowerShell 标签页,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
42
42
 
43
43
  ## Herdr 插件
44
44
 
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ Commands:
27
27
  url Print browser access URL
28
28
  run [--daemon] Run services in foreground (keep-alive)
29
29
  admin-broker install | uninstall | status (Windows)
30
+ admin-shell Elevated PowerShell in this terminal (Windows)
30
31
  keepalive <action> install | uninstall | restart | status
31
32
  plugin <action> link | unlink | status (Herdr plugin)
32
33
 
@@ -228,7 +229,7 @@ async function main(argv = process.argv.slice(2)) {
228
229
  const action = positional[1] || 'status';
229
230
  if (action === 'install') {
230
231
  broker.installAdminBroker(t);
231
- printJson({ installed: true, available: await broker.adminBrokerStatus() });
232
+ printJson({ installed: true, available: await broker.waitForAdminBroker() });
232
233
  }
233
234
  else if (action === 'uninstall') {
234
235
  broker.uninstallAdminBroker(t);
@@ -242,6 +243,11 @@ async function main(argv = process.argv.slice(2)) {
242
243
  }
243
244
  return;
244
245
  }
246
+ case 'admin-shell': {
247
+ const { runAdminShell } = await import('./connector/admin-shell.js');
248
+ process.exitCode = await runAdminShell(t);
249
+ return;
250
+ }
245
251
  case 'keepalive': {
246
252
  const keepalive = await import('./keepalive/index.js');
247
253
  const action = positional[1] || 'status';
@@ -3,9 +3,11 @@ import net from 'node:net';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
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';
6
+ import { PACKAGE_ROOT, stateDir } from '../paths.js';
7
+ import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerTaskName, adminBrokerTokenMatches, connectAdminBroker, listenAdminBroker, publishAdminBrokerEndpoint, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
8
8
  const BROKER_PING_TIMEOUT_MS = 1_000;
9
+ const BROKER_START_TIMEOUT_MS = 5_000;
10
+ const BROKER_START_POLL_MS = 250;
9
11
  function powershellQuote(value) {
10
12
  return `'${value.replaceAll("'", "''")}'`;
11
13
  }
@@ -30,7 +32,8 @@ export function installAdminBroker(t) {
30
32
  if (process.platform !== 'win32')
31
33
  throw new Error(t('adminBroker.windowsOnly'));
32
34
  const brokerPath = fileURLToPath(import.meta.url);
33
- const brokerCommand = `& ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
35
+ // Pinned so the broker publishes its endpoint where this install's callers look.
36
+ const brokerCommand = `$env:HERDR_REMOTE_STATE_DIR = ${powershellQuote(stateDir())}; & ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
34
37
  const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
35
38
  const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
36
39
  const script = `
@@ -58,12 +61,17 @@ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction Silent
58
61
  `, t);
59
62
  }
60
63
  export function adminBrokerStatus() {
61
- if (process.platform !== 'win32')
64
+ let broker;
65
+ try {
66
+ broker = connectAdminBroker();
67
+ }
68
+ catch {
62
69
  return Promise.resolve(false);
70
+ }
71
+ const { socket, token } = broker;
63
72
  return new Promise((resolve) => {
64
73
  let settled = false;
65
74
  let response = '';
66
- const socket = net.createConnection(adminBrokerPipePath());
67
75
  const finish = (available) => {
68
76
  if (settled)
69
77
  return;
@@ -73,7 +81,7 @@ export function adminBrokerStatus() {
73
81
  };
74
82
  socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
75
83
  socket.setEncoding('utf8');
76
- socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping' }));
84
+ socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping', token }));
77
85
  socket.on('data', (chunk) => {
78
86
  response += chunk;
79
87
  if (response.includes('\n'))
@@ -82,6 +90,16 @@ export function adminBrokerStatus() {
82
90
  socket.on('error', () => finish(false));
83
91
  });
84
92
  }
93
+ /** The task starts the broker a moment after it is registered; wait for it to answer. */
94
+ export async function waitForAdminBroker() {
95
+ const deadline = Date.now() + BROKER_START_TIMEOUT_MS;
96
+ while (!(await adminBrokerStatus())) {
97
+ if (Date.now() >= deadline)
98
+ return false;
99
+ await new Promise((resolve) => setTimeout(resolve, BROKER_START_POLL_MS));
100
+ }
101
+ return true;
102
+ }
85
103
  function writeError(socket, error) {
86
104
  const message = error instanceof Error ? error.message : String(error);
87
105
  sendAdminBrokerMessage(socket, { type: 'error', message });
@@ -132,9 +150,9 @@ function startBrokerPty(socket, message) {
132
150
  sendAdminBrokerMessage(socket, { type: 'started', pid: pty.terminal?.pid ?? 0 });
133
151
  return pty;
134
152
  }
135
- function runAdminBroker() {
136
- if (process.platform !== 'win32')
137
- throw new Error('Administrator PTYs are only supported on Windows.');
153
+ /** Listen on loopback and publish the endpoint; every request must carry its token. */
154
+ export function serveAdminBroker() {
155
+ let token = '';
138
156
  const server = net.createServer((socket) => {
139
157
  socket.setEncoding('utf8');
140
158
  let pending = '';
@@ -162,6 +180,10 @@ function runAdminBroker() {
162
180
  }
163
181
  if (firstMessage) {
164
182
  firstMessage = false;
183
+ if (!adminBrokerTokenMatches(token, message.token)) {
184
+ writeError(socket, new Error('Administrator terminal request was not authorized.'));
185
+ return;
186
+ }
165
187
  if (message.type === 'ping') {
166
188
  sendAdminBrokerMessage(socket, { type: 'pong' });
167
189
  socket.end();
@@ -199,8 +221,15 @@ function runAdminBroker() {
199
221
  socket.on('close', () => pty?.kill());
200
222
  socket.on('error', () => pty?.kill());
201
223
  });
202
- server.listen(adminBrokerPipePath());
203
- server.on('error', (error) => {
224
+ listenAdminBroker(server, (port) => {
225
+ token = publishAdminBrokerEndpoint(port);
226
+ });
227
+ return server;
228
+ }
229
+ function runAdminBroker() {
230
+ if (process.platform !== 'win32')
231
+ throw new Error('Administrator PTYs are only supported on Windows.');
232
+ serveAdminBroker().on('error', (error) => {
204
233
  process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
205
234
  });
206
235
  }
@@ -0,0 +1,65 @@
1
+ // `herdr-remote admin-shell`: the administrator terminal inside a Herdr tab.
2
+ //
3
+ // This process runs in the tab's pane with the pane's standard rights and
4
+ // relays its console to an elevated PowerShell that the admin broker runs. On
5
+ // Windows, Node's raw mode reads keys as VT input and writes VT output through
6
+ // unchanged, so the relay is byte for byte.
7
+ import { requestHerdr } from '../herdr-api.js';
8
+ import { ADMIN_TAB_ENV } from './admin-tab.js';
9
+ import { ElevatedPty } from './elevatedPty.js';
10
+ const BROKER_UNAVAILABLE_CODES = ['ENOENT', 'ECONNREFUSED', 'ETIMEDOUT'];
11
+ /** Close the pane this runs in, which closes the tab `openAdminTab` made for it. */
12
+ async function closeOwnPane() {
13
+ const socketPath = process.env.HERDR_SOCKET_PATH;
14
+ const paneId = process.env.HERDR_PANE_ID;
15
+ if (process.env[ADMIN_TAB_ENV] !== '1' || !socketPath || !paneId)
16
+ return;
17
+ try {
18
+ await requestHerdr(socketPath, 'pane.close', { pane_id: paneId });
19
+ }
20
+ catch {
21
+ // The shell's prompt comes back instead; the user can close the tab.
22
+ }
23
+ }
24
+ export async function runAdminShell(t) {
25
+ const { stdin, stdout } = process;
26
+ if (process.platform !== 'win32')
27
+ throw new Error(t('adminBroker.windowsOnly'));
28
+ if (!stdin.isTTY || !stdout.isTTY)
29
+ throw new Error(t('adminShell.needsTerminal'));
30
+ const pty = new ElevatedPty({
31
+ command: 'powershell.exe',
32
+ args: ['-NoLogo', '-NoProfile'],
33
+ cwd: process.cwd(),
34
+ });
35
+ const relayInput = (data) => pty.write(data);
36
+ const relayResize = () => pty.resize(stdout.columns, stdout.rows);
37
+ stdin.setRawMode(true);
38
+ stdin.on('data', relayInput);
39
+ stdout.on('resize', relayResize);
40
+ try {
41
+ const exitCode = await new Promise((resolve, reject) => {
42
+ pty.start({
43
+ cols: stdout.columns,
44
+ rows: stdout.rows,
45
+ onData: (data) => stdout.write(data),
46
+ onExit: (event) => resolve(event.exitCode),
47
+ onError: reject,
48
+ });
49
+ });
50
+ await closeOwnPane();
51
+ return exitCode;
52
+ }
53
+ catch (error) {
54
+ const { code } = error;
55
+ if (code && BROKER_UNAVAILABLE_CODES.includes(code))
56
+ throw new Error(t('adminShell.brokerUnavailable'));
57
+ throw error;
58
+ }
59
+ finally {
60
+ stdin.off('data', relayInput);
61
+ stdout.off('resize', relayResize);
62
+ stdin.setRawMode(false);
63
+ stdin.pause();
64
+ }
65
+ }
@@ -0,0 +1,90 @@
1
+ // An administrator terminal as a tab of the workstation's own Herdr, opened at
2
+ // a browser's request.
3
+ //
4
+ // Herdr's API cannot start a tab running a command of our choosing, so the tab
5
+ // starts with the user's shell and `herdr-remote admin-shell` is typed into it
6
+ // once that shell is running. Nor can the API focus a tab for one client:
7
+ // `tab.focus` moves every client attached to this Herdr, which is the price of
8
+ // the requesting window showing the new tab at once.
9
+ import path from 'node:path';
10
+ import { requestHerdr } from '../herdr-api.js';
11
+ import { createTranslator, detectLocale } from '../i18n/index.js';
12
+ import { cliEntryPoint } from '../keepalive/environment.js';
13
+ import { adminBrokerStatus } from './admin-broker.js';
14
+ const SHELL_START_TIMEOUT_MS = 5_000;
15
+ const SHELL_START_POLL_MS = 100;
16
+ /** Marks the tab's shell, so `admin-shell` knows it may close the pane when done. */
17
+ export const ADMIN_TAB_ENV = 'HERDR_REMOTE_ADMIN_TAB';
18
+ function powershellQuote(value) {
19
+ return `'${value.replaceAll("'", "''")}'`;
20
+ }
21
+ function posixQuote(value) {
22
+ return `'${value.replaceAll("'", "'\\''")}'`;
23
+ }
24
+ /** The command line that starts `admin-shell`, in the syntax of the tab's shell. */
25
+ export function adminShellCommand(shell, node, entry) {
26
+ const name = path.win32
27
+ .basename(shell)
28
+ .toLowerCase()
29
+ .replace(/\.exe$/, '');
30
+ if (name === 'powershell' || name === 'pwsh') {
31
+ return `& ${powershellQuote(node)} ${powershellQuote(entry)} admin-shell`;
32
+ }
33
+ if (name === 'cmd')
34
+ return `"${node}" "${entry}" admin-shell`;
35
+ // Git Bash and other POSIX shells: forward slashes, or C:\ reads as escapes.
36
+ const posix = (file) => posixQuote(file.replaceAll('\\', '/'));
37
+ return `${posix(node)} ${posix(entry)} admin-shell`;
38
+ }
39
+ /** The new tab's shell, once Herdr reports it running. */
40
+ async function waitForShell(request, socketPath, paneId) {
41
+ const deadline = Date.now() + SHELL_START_TIMEOUT_MS;
42
+ while (true) {
43
+ const info = await request(socketPath, 'pane.process_info', {
44
+ pane_id: paneId,
45
+ });
46
+ const shell = info.process_info?.foreground_processes?.[0]?.name;
47
+ if (shell)
48
+ return shell;
49
+ if (Date.now() >= deadline)
50
+ throw new Error('The new tab did not start a shell in time.');
51
+ await new Promise((resolve) => setTimeout(resolve, SHELL_START_POLL_MS));
52
+ }
53
+ }
54
+ export async function openAdminTab({ socketPath, label, request = requestHerdr, brokerReady = adminBrokerStatus, }) {
55
+ if (!(await brokerReady())) {
56
+ throw Object.assign(new Error('The administrator terminal broker is not running.'), {
57
+ code: 'admin_broker_unavailable',
58
+ });
59
+ }
60
+ const created = await request(socketPath, 'tab.create', {
61
+ label,
62
+ focus: false,
63
+ env: { [ADMIN_TAB_ENV]: '1' },
64
+ });
65
+ const paneId = created.root_pane.pane_id;
66
+ await request(socketPath, 'tab.focus', { tab_id: created.tab.tab_id });
67
+ const shell = await waitForShell(request, socketPath, paneId);
68
+ await request(socketPath, 'pane.send_input', {
69
+ pane_id: paneId,
70
+ text: adminShellCommand(shell, process.execPath, cliEntryPoint()),
71
+ keys: ['enter'],
72
+ });
73
+ }
74
+ /** Open the tab for a browser window, and tell that window when it could not. */
75
+ export async function openAdminTabFor(streamId, options) {
76
+ const t = createTranslator(detectLocale({ preference: options.language }));
77
+ try {
78
+ await openAdminTab({ socketPath: options.socketPath, label: t('adminShell.tabLabel') });
79
+ }
80
+ catch (error) {
81
+ const { code, message } = error;
82
+ process.stderr.write(`herdr-remote host connector: could not open an admin tab: ${message}\n`);
83
+ options.send({
84
+ type: 'error',
85
+ clientId: streamId,
86
+ code: code === 'admin_broker_unavailable' ? code : 'admin_tab_failed',
87
+ message,
88
+ });
89
+ }
90
+ }
@@ -0,0 +1,68 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { stateDir } from '../paths.js';
6
+ const ADMIN_BROKER_TASK_NAME = 'HerdrRemoteAdminBroker';
7
+ const ADMIN_BROKER_HOST = '127.0.0.1';
8
+ export const ADMIN_BROKER_MAX_MESSAGE_BYTES = 2 * 1_024 * 1_024;
9
+ function adminBrokerAccountId() {
10
+ const account = `${process.env.USERDOMAIN || ''}\\${process.env.USERNAME || process.env.USERPROFILE || 'default'}`;
11
+ return createHash('sha256').update(account.toLowerCase()).digest('hex').slice(0, 24);
12
+ }
13
+ export function adminBrokerTaskName() {
14
+ return `${ADMIN_BROKER_TASK_NAME}-${adminBrokerAccountId()}`;
15
+ }
16
+ // Not a named pipe: one created by an elevated process admits only
17
+ // Administrators, so the connector's standard token gets EPERM, and opening it
18
+ // to everyone would let any account add an instance of the same name and sit
19
+ // in the middle. A loopback port cannot be taken over by another account, and
20
+ // the token lives in the state directory, which only this account can read.
21
+ function endpointPath() {
22
+ return path.join(stateDir(), 'admin-broker.json');
23
+ }
24
+ /** Record where the broker listens, with a fresh token callers must present. */
25
+ export function publishAdminBrokerEndpoint(port) {
26
+ const token = randomBytes(32).toString('hex');
27
+ const file = endpointPath();
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ const temporary = `${file}.${process.pid}.tmp`;
30
+ fs.writeFileSync(temporary, JSON.stringify({ port, token }), { mode: 0o600 });
31
+ fs.renameSync(temporary, file);
32
+ return token;
33
+ }
34
+ export function adminBrokerTokenMatches(expected, received) {
35
+ if (!expected || typeof received !== 'string')
36
+ return false;
37
+ const want = Buffer.from(expected);
38
+ const got = Buffer.from(received);
39
+ return want.length === got.length && timingSafeEqual(want, got);
40
+ }
41
+ function brokerUnavailable() {
42
+ return Object.assign(new Error('The administrator terminal broker is not running.'), {
43
+ code: 'ENOENT',
44
+ });
45
+ }
46
+ /** Connect to the running broker; the token goes in the first message. */
47
+ export function connectAdminBroker() {
48
+ let endpoint;
49
+ try {
50
+ endpoint = JSON.parse(fs.readFileSync(endpointPath(), 'utf8'));
51
+ }
52
+ catch {
53
+ // Missing or half-written: either way no broker has published itself.
54
+ throw brokerUnavailable();
55
+ }
56
+ const { port, token } = endpoint;
57
+ if (!Number.isInteger(port) || typeof token !== 'string')
58
+ throw brokerUnavailable();
59
+ return { socket: net.createConnection({ host: ADMIN_BROKER_HOST, port: port }), token };
60
+ }
61
+ export function listenAdminBroker(server, onListening) {
62
+ server.listen(0, ADMIN_BROKER_HOST, () => {
63
+ onListening(server.address().port);
64
+ });
65
+ }
66
+ export function sendAdminBrokerMessage(socket, message) {
67
+ socket.write(`${JSON.stringify(message)}\n`);
68
+ }
@@ -1,54 +1,46 @@
1
- import net from 'node:net';
2
- import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
1
+ import { ADMIN_BROKER_MAX_MESSAGE_BYTES, connectAdminBroker, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
3
2
  const BROKER_CONNECT_TIMEOUT_MS = 5_000;
3
+ /** A PTY the admin broker runs elevated, driven over its loopback socket. */
4
4
  export class ElevatedPty {
5
- pid = 0;
6
- cols;
7
- rows;
8
5
  socket = null;
9
6
  input = '';
10
7
  connected = false;
11
8
  started = false;
12
9
  finished = false;
13
10
  killed = false;
11
+ /** Keystrokes typed while the elevated shell was still starting. */
12
+ early = [];
14
13
  dataListener = null;
15
14
  exitListener = null;
16
- readyListener = null;
17
15
  errorListener = null;
18
16
  options;
19
- constructor(options, cols, rows) {
17
+ constructor(options) {
20
18
  this.options = options;
21
- this.cols = cols;
22
- this.rows = rows;
23
19
  }
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;
20
+ start({ cols, rows, onData, onExit, onError }) {
33
21
  this.dataListener = onData;
34
22
  this.exitListener = onExit;
35
- this.readyListener = onReady || null;
36
- this.errorListener = onError || null;
37
- const socket = net.createConnection(adminBrokerPipePath());
23
+ this.errorListener = onError;
24
+ let broker;
25
+ try {
26
+ broker = connectAdminBroker();
27
+ }
28
+ catch (error) {
29
+ this.fail(error);
30
+ return;
31
+ }
32
+ const { socket, token } = broker;
38
33
  this.socket = socket;
39
34
  socket.setEncoding('utf8');
40
35
  socket.setTimeout(BROKER_CONNECT_TIMEOUT_MS, () => {
41
- socket.destroy(new Error('Administrator terminal broker connection timed out'));
36
+ socket.destroy(Object.assign(new Error('Administrator terminal broker connection timed out'), {
37
+ code: 'ETIMEDOUT',
38
+ }));
42
39
  });
43
40
  socket.on('connect', () => {
44
41
  this.connected = true;
45
42
  socket.setTimeout(0);
46
- sendAdminBrokerMessage(socket, {
47
- type: 'start',
48
- ...this.options,
49
- cols: this.cols,
50
- rows: this.rows,
51
- });
43
+ sendAdminBrokerMessage(socket, { type: 'start', token, ...this.options, cols, rows });
52
44
  });
53
45
  socket.on('data', (chunk) => this.receive(chunk));
54
46
  socket.on('error', (error) => this.fail(error));
@@ -60,7 +52,13 @@ export class ElevatedPty {
60
52
  });
61
53
  }
62
54
  write(data) {
63
- if (this.started && !this.finished && this.socket?.writable) {
55
+ if (this.finished)
56
+ return;
57
+ if (!this.started) {
58
+ this.early.push(Buffer.from(data));
59
+ return;
60
+ }
61
+ if (this.socket?.writable) {
64
62
  sendAdminBrokerMessage(this.socket, {
65
63
  type: 'input',
66
64
  dataBase64: Buffer.from(data).toString('base64'),
@@ -68,15 +66,12 @@ export class ElevatedPty {
68
66
  }
69
67
  }
70
68
  resize(cols, rows) {
71
- this.cols = cols;
72
- this.rows = rows;
73
69
  if (this.started && this.socket?.writable) {
74
70
  sendAdminBrokerMessage(this.socket, { type: 'resize', cols, rows });
75
71
  }
76
72
  }
77
73
  kill() {
78
74
  this.killed = true;
79
- this.pid = 0;
80
75
  if (!this.socket)
81
76
  return;
82
77
  if (this.connected && this.socket.writable) {
@@ -109,15 +104,16 @@ export class ElevatedPty {
109
104
  }
110
105
  if (message.type === 'started') {
111
106
  this.started = true;
112
- this.pid = Number(message.pid) || 0;
113
- this.readyListener?.();
107
+ const early = Buffer.concat(this.early);
108
+ this.early = [];
109
+ if (early.length > 0)
110
+ this.write(early);
114
111
  }
115
112
  else if (message.type === 'data' && typeof message.dataBase64 === 'string') {
116
113
  this.dataListener?.(Buffer.from(message.dataBase64, 'base64').toString('utf8'));
117
114
  }
118
115
  else if (message.type === 'exit') {
119
116
  this.finished = true;
120
- this.pid = 0;
121
117
  this.exitListener?.({
122
118
  exitCode: Number(message.exitCode) || 0,
123
119
  ...(typeof message.signal === 'number' ? { signal: message.signal } : {}),
@@ -13,6 +13,7 @@ import { hostWebSocketUrl, resolveHostRelayUrl } from '../relay-urls.js';
13
13
  import { resolveSocketPath } from '../socket-discovery.js';
14
14
  import { resolveHostPalette } from '../terminal-palette.js';
15
15
  import { randomToken } from 'herdr-remote-relay/state';
16
+ import { openAdminTabFor } from './admin-tab.js';
16
17
  import { AgentWatch } from './agent-watch.js';
17
18
  import { FontServer } from './font-server.js';
18
19
  import { acquireHostLock, releaseHostLock } from './lock.js';
@@ -196,7 +197,7 @@ export class HostConnector {
196
197
  CAPABILITY.hostHandoff,
197
198
  CAPABILITY.idleHeartbeat,
198
199
  CAPABILITY.binaryFrameV2,
199
- ...(process.platform === 'win32' ? [CAPABILITY.adminSessions] : []),
200
+ ...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
200
201
  ],
201
202
  });
202
203
  // The relay sends host_ready with the current browser count. No business
@@ -283,6 +284,12 @@ export class HostConnector {
283
284
  return this.startSession(message);
284
285
  case 'herdr_start':
285
286
  return this.sessions.startHerdrFor(streamOf(message));
287
+ case 'admin_tab_open':
288
+ return openAdminTabFor(streamOf(message), {
289
+ socketPath: this.socketPath,
290
+ language: this.config.ui?.language,
291
+ send: (payload) => sendJson(this.ws, payload),
292
+ });
286
293
  case 'session_stop': {
287
294
  const streamId = streamOf(message);
288
295
  if (streamId)
@@ -105,10 +105,6 @@ export class Sessions {
105
105
  if (!streamId)
106
106
  return undefined;
107
107
  this.stop(streamId);
108
- if (message.adminTerminal) {
109
- this.spawn(message);
110
- return undefined;
111
- }
112
108
  const missingHerdr = this.ensureHerdrCommand();
113
109
  if (missingHerdr) {
114
110
  process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
@@ -204,15 +200,11 @@ export class Sessions {
204
200
  spawn(message) {
205
201
  const streamId = streamOf(message);
206
202
  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.
209
203
  const pty = new this.Pty({
210
- command: adminTerminal ? 'powershell.exe' : this.herdrCommand,
211
- args: adminTerminal ? ['-NoLogo', '-NoProfile'] : this.herdrArgs,
204
+ command: this.herdrCommand,
205
+ args: this.herdrArgs,
212
206
  cwd: this.cwd,
213
- socketPath: adminTerminal ? null : this.socketPath,
214
- adminTerminal,
215
- fastWindowsPty: adminTerminal,
207
+ socketPath: this.socketPath,
216
208
  });
217
209
  const session = {
218
210
  id: streamId,
@@ -243,29 +235,6 @@ export class Sessions {
243
235
  ? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
244
236
  : packStreamFrame('output', streamId, payload));
245
237
  };
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);
269
238
  try {
270
239
  pty.start({
271
240
  cols: session.cols,
@@ -291,16 +260,18 @@ export class Sessions {
291
260
  this.reportExit(session, exitCode);
292
261
  this.link.changed();
293
262
  },
294
- onReady: reportReady,
295
- onError: reportStartError,
296
263
  });
297
- if (!adminTerminal)
298
- reportReady();
299
264
  }
300
265
  catch (error) {
301
- reportStartError(error);
266
+ discardOutput(session);
267
+ this.sendError(streamId, 'pty_start_failed', error.message);
268
+ pty.kill();
302
269
  return;
303
270
  }
271
+ this.byStream.set(streamId, session);
272
+ if (streamIndex !== null)
273
+ this.streamIndexToId.set(streamIndex, streamId);
274
+ this.link.send({ type: 'session_ready', clientId: streamId });
304
275
  this.link.changed();
305
276
  }
306
277
  /**
package/dist/i18n/en.js CHANGED
@@ -97,6 +97,9 @@ const en = {
97
97
  'adminBroker.elevationCancelled': 'Administrator approval was declined; no changes were made.',
98
98
  'adminBroker.setupFailed': 'Could not configure the administrator terminal broker.',
99
99
  'adminBroker.tuiHint': 'Set this up once. Windows asks for administrator approval; later WebUI admin terminals open without UAC.',
100
+ 'adminShell.needsTerminal': 'admin-shell must run in a terminal.',
101
+ 'adminShell.brokerUnavailable': 'The administrator terminal broker is not running. Open the herdr-remote TUI, go to Keep-alive, and choose Set up admin terminal access.',
102
+ 'adminShell.tabLabel': 'Admin',
100
103
  'relay.title': 'Relay settings',
101
104
  'relay.settings': 'Settings',
102
105
  'relay.locked': 'fixed',
package/dist/i18n/zh.js CHANGED
@@ -96,6 +96,9 @@ const zh = {
96
96
  'adminBroker.elevationCancelled': '未批准管理员权限,未进行更改。',
97
97
  'adminBroker.setupFailed': '管理员终端 broker 设置失败。',
98
98
  'adminBroker.tuiHint': '只需设置一次。Windows 会请求管理员授权;之后 WebUI 新建管理员终端不再弹出 UAC。',
99
+ 'adminShell.needsTerminal': 'admin-shell 必须在终端中运行。',
100
+ 'adminShell.brokerUnavailable': '管理员终端 broker 尚未运行。请打开 herdr-remote TUI,进入「保活」并选择「设置管理员终端」。',
101
+ 'adminShell.tabLabel': '管理员',
99
102
  'relay.title': 'Relay 设置',
100
103
  'relay.settings': '设置',
101
104
  'relay.locked': '固定',
@@ -1,6 +1,5 @@
1
1
  import os from 'node:os';
2
2
  import pty from 'node-pty';
3
- import { ElevatedPty } from './connector/elevatedPty.js';
4
3
  class PtySession {
5
4
  static DEFAULT_COLS = 100;
6
5
  static DEFAULT_ROWS = 30;
@@ -11,12 +10,11 @@ class PtySession {
11
10
  cwd;
12
11
  socketPath;
13
12
  platform;
14
- adminTerminal;
15
13
  fastWindowsPty;
16
14
  spawnPty;
17
15
  terminal;
18
16
  startedAt;
19
- constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, adminTerminal = false, fastWindowsPty = false, } = {}) {
17
+ constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, fastWindowsPty = false, } = {}) {
20
18
  if (typeof command !== 'string' || command.length === 0)
21
19
  throw new TypeError('command must be a non-empty string');
22
20
  if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
@@ -26,7 +24,6 @@ class PtySession {
26
24
  this.cwd = cwd;
27
25
  this.socketPath = socketPath;
28
26
  this.platform = platform;
29
- this.adminTerminal = adminTerminal;
30
27
  this.fastWindowsPty = fastWindowsPty;
31
28
  this.spawnPty = spawnPty;
32
29
  this.terminal = null;
@@ -50,31 +47,9 @@ class PtySession {
50
47
  return fallback;
51
48
  return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
52
49
  }
53
- start({ cols, rows, onData, onExit, onReady, onError }) {
50
+ start({ cols, rows, onData, onExit }) {
54
51
  if (this.terminal)
55
52
  throw new Error('PTY session already started');
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
53
  // The Herdr UI needs the bundled ConPTY's Kitty and mouse reporting.
79
54
  this.terminal = this.spawnPty(this.command, this.args, {
80
55
  name: 'xterm-256color',
@@ -98,7 +73,6 @@ class PtySession {
98
73
  onExit(event);
99
74
  });
100
75
  }
101
- onReady?.();
102
76
  return this;
103
77
  }
104
78
  write(data) {
package/dist/tui/api.js CHANGED
@@ -19,11 +19,11 @@ 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() {
22
+ export async function adminBrokerStatus({ waitForStart = false } = {}) {
23
23
  if (process.platform !== 'win32')
24
24
  return false;
25
25
  const broker = await import('../connector/admin-broker.js');
26
- return broker.adminBrokerStatus();
26
+ return waitForStart ? broker.waitForAdminBroker() : broker.adminBrokerStatus();
27
27
  }
28
28
  export async function configureAdminBroker(action, t) {
29
29
  const setup = await import('../connector/admin-broker-setup.js');
@@ -53,7 +53,7 @@ export function Keepalive({ ctx }) {
53
53
  let ready = false;
54
54
  try {
55
55
  await configureAdminBroker(action, t);
56
- ready = action === 'install' && (await adminBrokerStatus());
56
+ ready = action === 'install' && (await adminBrokerStatus({ waitForStart: true }));
57
57
  }
58
58
  catch (error) {
59
59
  if (error.code === 'ELEVATION_CANCELLED') {
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.26"
3
+ version = "0.2.27"
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.26",
3
+ "version": "0.2.27",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,16 +0,0 @@
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
- }