zerogterm 0.7.0-alpha2 → 0.8.0-alpha

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.
@@ -1,21 +1,40 @@
1
- import { app, BrowserWindow, clipboard, ipcMain, Menu, session, shell } from 'electron';
1
+ import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { writeClipboardText } from './clipboard.js';
5
5
  import { ScreenService, parseWslDistributions } from './session-service.js';
6
6
  import { discoverShellBackends } from './shell-catalog.js';
7
7
  import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
8
+ import { CommandHistoryStore, defaultCommandHistoryPath } from './command-history-store.js';
9
+ import { WorkspaceStore, defaultWorkspacePath } from './workspace-store.js';
10
+ import { PortForwardService } from './port-forward-service.js';
11
+ import { PortForwardStore, defaultPortForwardPath } from './port-forward-store.js';
8
12
  import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
9
13
  import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
14
+ import { wslHomeDirectory } from './wsl-home.js';
10
15
  import { decideExternalLink, isApplicationUrl } from './external-links.js';
11
16
  import { SftpService } from './sftp-service.js';
17
+ import { AI_API_KEY, SPEECH_API_KEY, SecretStore, defaultSecretsPath } from './secret-store.js';
18
+ import { AiService } from './ai-service.js';
12
19
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
13
20
  const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
21
+ // The only store holding what the user typed. Nothing reaches it that has not
22
+ // been through command-redaction in the renderer, and it holds nothing at all
23
+ // until the setting is turned on.
24
+ const commands = new CommandHistoryStore({ filePath: defaultCommandHistoryPath(app.getPath('userData')) });
25
+ const workspaceStore = new WorkspaceStore({ filePath: defaultWorkspacePath(app.getPath('userData')) });
26
+ const forwardStore = new PortForwardStore({ filePath: defaultPortForwardPath(app.getPath('userData')) });
27
+ // Tunnels outlive the Ports view being closed: authenticating again is a real
28
+ // cost to pay for having looked away.
29
+ const forwards = new PortForwardService({ onEvent: (event) => win?.webContents.send('forwards:event', event) });
14
30
  const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
15
31
  let win;
16
32
  // Transfer connections outlive any single panel opening, so the panel can be
17
33
  // closed and reopened without re-authenticating to the host.
18
34
  const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:event', event) });
35
+ // API keys for speech servers. safeStorage is only usable after the app is
36
+ // ready, which every IPC call here already is.
37
+ const secrets = new SecretStore({ filePath: defaultSecretsPath(app.getPath('userData')), crypto: safeStorage });
19
38
  /** A pane's measured size, as it arrives from the renderer. */
20
39
  function parsePtySize(value) {
21
40
  if (!value || typeof value !== 'object')
@@ -25,10 +44,53 @@ function parsePtySize(value) {
25
44
  return undefined;
26
45
  return { cols: cols, rows: rows };
27
46
  }
28
- // GPU is unstable under Toolbox/Wayland on this host; allow override.
29
- if (process.env.ZEROG_ENABLE_GPU !== '1') {
47
+ /**
48
+ * Software rendering, on the one platform that needs it.
49
+ *
50
+ * The GPU is unstable under Toolbox/Wayland, which is where this was found and
51
+ * why the switch exists. It was applied everywhere, though, and on Windows that
52
+ * is a straight loss: every pane is then rasterised on the CPU, and a window
53
+ * holding a dozen terminals can stall long enough for the compositor to grey it
54
+ * out — which reads to the user as a crash. Scoped to Linux, with an override in
55
+ * each direction so a machine that disagrees can say so.
56
+ */
57
+ if (process.env.ZEROG_DISABLE_GPU === '1') {
58
+ app.disableHardwareAcceleration();
59
+ }
60
+ else if (process.platform === 'linux' && process.env.ZEROG_ENABLE_GPU !== '1') {
30
61
  app.disableHardwareAcceleration();
31
62
  }
63
+ /**
64
+ * How long the main process may block before it is worth saying so.
65
+ *
66
+ * Every pty write, every IPC reply and every window event goes through this one
67
+ * loop, so a stall here freezes the whole application however many panes are
68
+ * open. 250ms is well past a slow frame and well short of anything a person
69
+ * would call a hang, which makes it a warning rather than a report.
70
+ */
71
+ const LOOP_STALL_MS = 250;
72
+ const LOOP_SAMPLE_MS = 500;
73
+ /**
74
+ * Watch the main process for stalls.
75
+ *
76
+ * A timer that knows when it should have fired is the whole instrument: the
77
+ * difference between that and when it did fire is time the loop spent unable to
78
+ * run anything. It cannot say what blocked — only that something did, and for
79
+ * how long, which is the fact that was missing when a frozen window had to be
80
+ * explained from a screenshot.
81
+ */
82
+ function watchEventLoop() {
83
+ let due = Date.now() + LOOP_SAMPLE_MS;
84
+ const timer = setInterval(() => {
85
+ const now = Date.now();
86
+ const lag = now - due;
87
+ due = now + LOOP_SAMPLE_MS;
88
+ if (lag >= LOOP_STALL_MS)
89
+ console.warn(`[zerog] main process blocked for ${Math.round(lag)}ms`);
90
+ }, LOOP_SAMPLE_MS);
91
+ // Diagnostics must never be the reason the process stays alive.
92
+ timer.unref();
93
+ }
32
94
  function createWindow() {
33
95
  win = new BrowserWindow({
34
96
  width: 1440,
@@ -76,8 +138,32 @@ function createWindow() {
76
138
  win.webContents.on('preload-error', (_event, path, error) => {
77
139
  console.error('[zerog] preload-error', path, error);
78
140
  });
79
- win.webContents.on('console-message', (_event, _level, message) => {
80
- console.log('[renderer]', message);
141
+ // The renderer's console, copied into this one — which is the only way a
142
+ // warning from the workspace reaches a log at all.
143
+ //
144
+ // Read from the event object rather than from the positional arguments that
145
+ // used to carry it. Electron still passes those, and says so on every start:
146
+ // "'console-message' arguments are deprecated and will be removed". When they
147
+ // go, a handler reading them would keep being called and print nothing, so the
148
+ // renderer would fall silent without anything appearing to break — the exact
149
+ // failure this logging exists to rule out.
150
+ win.webContents.on('console-message', (details) => {
151
+ console.log('[renderer]', details.message);
152
+ });
153
+ // The two ends of the symptom this instrumentation exists for. `unresponsive`
154
+ // is the window the desktop has started greying out; `render-process-gone` is
155
+ // the renderer having actually died, which is a different failure that looks
156
+ // similar from the outside. Only recorded here: what to *do* about either —
157
+ // offer a reload, reattach the panes — is a decision about the interface, not
158
+ // about diagnostics.
159
+ win.on('unresponsive', () => {
160
+ console.warn('[zerog] window unresponsive');
161
+ });
162
+ win.on('responsive', () => {
163
+ console.warn('[zerog] window responsive again');
164
+ });
165
+ win.webContents.on('render-process-gone', (_event, details) => {
166
+ console.error('[zerog] render-process-gone', details);
81
167
  });
82
168
  const devUrl = process.env.VITE_DEV_SERVER_URL;
83
169
  if (devUrl) {
@@ -91,6 +177,9 @@ function createWindow() {
91
177
  win = undefined;
92
178
  service.detachAll();
93
179
  sftp.closeAll();
180
+ // A tunnel is a listening socket on someone's machine. It does not outlive
181
+ // the window that opened it.
182
+ forwards.closeAll();
94
183
  });
95
184
  }
96
185
  function isRecord(value) {
@@ -128,6 +217,18 @@ ipcMain.handle('links:openExternal', (_event, url) => {
128
217
  ipcMain.handle('sessions:list', () => service.list());
129
218
  ipcMain.handle('sessions:history', () => history.list());
130
219
  ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
220
+ ipcMain.handle('commands:list', () => commands.list());
221
+ ipcMain.handle('commands:record', (_event, record) => commands.record(requireCommandRecord(record)));
222
+ ipcMain.handle('commands:pick', (_event, id) => commands.pick(requireString(id, 'A command')));
223
+ ipcMain.handle('commands:clear', () => commands.clear());
224
+ ipcMain.handle('forwards:list', () => forwards.list());
225
+ ipcMain.handle('forwards:open', (_event, request) => forwards.open(requireForwardRequest(request)));
226
+ ipcMain.handle('forwards:close', (_event, id) => forwards.close(requireString(id, 'A shared port')));
227
+ ipcMain.handle('forwards:answerPrompt', (_event, id, answer) => forwards.answerPrompt(requireString(id, 'A shared port'), requireString(answer, 'An answer')));
228
+ ipcMain.handle('forwards:load', () => forwardStore.load());
229
+ ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
230
+ ipcMain.handle('workspaces:load', () => workspaceStore.load());
231
+ ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
131
232
  ipcMain.handle('sessions:backends', () => discoverShellBackends());
132
233
  ipcMain.handle('sessions:wslDistributions', async () => {
133
234
  try {
@@ -211,12 +312,100 @@ function requireString(value, field) {
211
312
  throw new Error(`${field} is required.`);
212
313
  return value;
213
314
  }
315
+ /**
316
+ * A captured command, shaped.
317
+ *
318
+ * Only the shape is checked here. Whether the text is safe to store was decided
319
+ * in the renderer by command-redaction, next to the capture that produced it —
320
+ * that is where the rules and their tests live.
321
+ */
322
+ function requireCommandRecord(value) {
323
+ if (!isRecord(value))
324
+ throw new Error('A command is required.');
325
+ const text = (field) => (typeof field === 'string' && field ? field : undefined);
326
+ return {
327
+ command: requireString(value.command, 'A command'),
328
+ ...(text(value.cwd) ? { cwd: String(value.cwd) } : {}),
329
+ ...(text(value.host) ? { host: String(value.host) } : {}),
330
+ ...(value.kind === 'ssh' || value.kind === 'local' ? { kind: value.kind } : {}),
331
+ ...(typeof value.exitCode === 'number' ? { exitCode: value.exitCode } : {})
332
+ };
333
+ }
334
+ function requireAiConfig(value) {
335
+ if (!isRecord(value))
336
+ throw new Error('An AI endpoint is required.');
337
+ return {
338
+ baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : '',
339
+ model: typeof value.model === 'string' ? value.model : ''
340
+ };
341
+ }
342
+ /**
343
+ * A suggestion request, shaped.
344
+ *
345
+ * The prompt and the context are checked for type here and for content by
346
+ * buildSuggestionRequest, which is where those rules are tested. The captured
347
+ * output is deliberately not inspected: it is untrusted by design, and
348
+ * ai-protocol is what makes it safe to include.
349
+ */
350
+ function requireSuggestionRequest(value) {
351
+ if (!isRecord(value))
352
+ throw new Error('A request is required.');
353
+ const context = isRecord(value.context) ? value.context : {};
354
+ const text = (field) => (typeof field === 'string' && field ? field : undefined);
355
+ return {
356
+ prompt: typeof value.prompt === 'string' ? value.prompt : '',
357
+ ...(text(value.sessionId) ? { sessionId: String(value.sessionId) } : {}),
358
+ context: {
359
+ ...(text(context.shell) ? { shell: String(context.shell) } : {}),
360
+ ...(text(context.cwd) ? { cwd: String(context.cwd) } : {}),
361
+ ...(text(context.host) ? { host: String(context.host) } : {}),
362
+ ...(context.kind === 'ssh' || context.kind === 'local' ? { kind: context.kind } : {}),
363
+ ...(text(context.output) ? { output: String(context.output) } : {})
364
+ }
365
+ };
366
+ }
367
+ /**
368
+ * A forwarding request, shaped.
369
+ *
370
+ * Only the shape is checked here: the values are vetted by buildForwardArgs,
371
+ * which is where the rules live and where they are tested, and which throws a
372
+ * sentence the renderer can show as-is.
373
+ */
374
+ function requireForwardRequest(value) {
375
+ if (!isRecord(value))
376
+ throw new Error('A shared port is required.');
377
+ return {
378
+ target: requireString(value.target, 'An SSH target'),
379
+ direction: value.direction,
380
+ bind: value.bind,
381
+ listenPort: value.listenPort,
382
+ destinationPort: value.destinationPort,
383
+ ...(typeof value.destinationHost === 'string' && value.destinationHost ? { destinationHost: value.destinationHost } : {}),
384
+ ...(typeof value.id === 'string' && value.id ? { id: value.id } : {})
385
+ };
386
+ }
214
387
  function requireEntryKind(value) {
215
388
  if (value === 'file' || value === 'directory' || value === 'symlink')
216
389
  return value;
217
390
  throw new Error('An entry kind of file, directory, or symlink is required.');
218
391
  }
392
+ // Electron's own reading of package.json, so the title bar shows what is
393
+ // actually running rather than a string compiled in from the repo.
394
+ ipcMain.handle('app:version', () => app.getVersion());
219
395
  ipcMain.handle('fs:localHome', () => localHome());
396
+ // Cached on success only, so a distribution that was not running when it was
397
+ // first asked can answer later.
398
+ const wslHomes = new Map();
399
+ ipcMain.handle('fs:wslHome', async (_event, distribution) => {
400
+ const name = typeof distribution === 'string' ? distribution : '';
401
+ const known = wslHomes.get(name);
402
+ if (known)
403
+ return known;
404
+ const home = await wslHomeDirectory(name);
405
+ if (home)
406
+ wslHomes.set(name, home);
407
+ return home;
408
+ });
220
409
  ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
221
410
  ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
222
411
  ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
@@ -236,10 +425,39 @@ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
236
425
  sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
237
426
  });
238
427
  ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
239
- ipcMain.handle('ai:suggest', () => ({
240
- command: 'git status --short',
241
- explanation: 'Read-only preview of changed files in the active workspace.'
242
- }));
428
+ // The key is read here, per request, and never handed to the renderer.
429
+ const ai = new AiService({ readApiKey: () => secrets.get(AI_API_KEY) });
430
+ ipcMain.handle('ai:suggest', (_event, config, request) => ai.suggest(requireAiConfig(config), requireSuggestionRequest(request)));
431
+ ipcMain.handle('ai:models', (_event, baseUrl) => ai.listModels(requireString(baseUrl, 'A base URL')));
432
+ ipcMain.handle('ai:test', (_event, config) => ai.test(requireAiConfig(config)));
433
+ ipcMain.handle('ai:cancel', () => ai.cancel());
434
+ async function aiKeyStatus() {
435
+ return { stored: await secrets.has(AI_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
436
+ }
437
+ ipcMain.handle('aiKey:status', () => aiKeyStatus());
438
+ ipcMain.handle('aiKey:save', async (_event, key) => {
439
+ await secrets.set(AI_API_KEY, typeof key === 'string' ? key.trim() : '');
440
+ return aiKeyStatus();
441
+ });
442
+ ipcMain.handle('aiKey:clear', async () => {
443
+ await secrets.clear(AI_API_KEY);
444
+ return aiKeyStatus();
445
+ });
446
+ async function speechKeyStatus() {
447
+ return { stored: await secrets.has(SPEECH_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
448
+ }
449
+ ipcMain.handle('speechKey:status', () => speechKeyStatus());
450
+ ipcMain.handle('speechKey:save', async (_event, key) => {
451
+ // An empty key means "clear", which SecretStore already does — but the
452
+ // string still has to be a string, and a pasted key often carries newlines.
453
+ await secrets.set(SPEECH_API_KEY, typeof key === 'string' ? key.trim() : '');
454
+ return speechKeyStatus();
455
+ });
456
+ ipcMain.handle('speechKey:clear', async () => {
457
+ await secrets.clear(SPEECH_API_KEY);
458
+ return speechKeyStatus();
459
+ });
460
+ ipcMain.handle('speechKey:read', () => secrets.get(SPEECH_API_KEY));
243
461
  app.whenReady().then(() => {
244
462
  // Voice input captures the local microphone only. Electron denies all
245
463
  // permission requests unless a handler answers, so grant media explicitly.
@@ -249,6 +467,7 @@ app.whenReady().then(() => {
249
467
  // Keep the normal window chrome, but let the ZeroG UI occupy the full
250
468
  // client area instead of showing Electron's default File/Edit/etc. menu.
251
469
  Menu.setApplicationMenu(null);
470
+ watchEventLoop();
252
471
  createWindow();
253
472
  app.on('activate', () => {
254
473
  if (!BrowserWindow.getAllWindows().length)
@@ -0,0 +1,161 @@
1
+ // Turning a forwarding request into an ssh command line, and reading back
2
+ // whether the tunnel came up.
3
+ //
4
+ // Kept apart from the service so the part that decides what `ssh` is asked to do
5
+ // can be tested exhaustively without spawning anything. That matters more here
6
+ // than usual: every field lands in an argv element, and the whole point of the
7
+ // feature is to open a listening socket.
8
+ import { parseSshTarget, sshExecutable } from './session-service.js';
9
+ import { isSshHostName } from './ssh-inventory.js';
10
+ /** Where traffic goes when the request does not say. */
11
+ const DEFAULT_DESTINATION_HOST = 'localhost';
12
+ /**
13
+ * How long to wait for a tunnel to declare itself before assuming it is up.
14
+ *
15
+ * Only a fallback: an ssh that has authenticated and gone quiet without saying
16
+ * it started listening is almost certainly listening. The explicit signals in
17
+ * readForwardOutcome are what normally settle this, and they arrive in
18
+ * milliseconds.
19
+ */
20
+ export const FORWARD_SETTLE_MS = 1500;
21
+ function requirePort(value, what) {
22
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
23
+ throw new Error(`${what} must be a whole number between 1 and 65535.`);
24
+ }
25
+ return value;
26
+ }
27
+ function requireDirection(value) {
28
+ if (value !== 'local' && value !== 'remote')
29
+ throw new Error('A forward must be local or remote.');
30
+ return value;
31
+ }
32
+ function requireBind(value) {
33
+ if (value !== 'loopback' && value !== 'all')
34
+ throw new Error('A forward must bind to loopback or to all interfaces.');
35
+ return value;
36
+ }
37
+ /**
38
+ * The host on the receiving side of the tunnel.
39
+ *
40
+ * Vetted with the same rule as a HostName in ~/.ssh/config, because it is
41
+ * concatenated into the forward spec — one argv element — and a value beginning
42
+ * with '-' would be read by ssh's getopt as an option instead.
43
+ *
44
+ * Colons are then refused on top of that rule, which a HostName is allowed to
45
+ * carry for an IPv6 literal. ssh splits a forward spec on colons, so an
46
+ * unbracketed IPv6 address there does not mean what it looks like: `[::1]` is
47
+ * the syntax, and accepting a bare `::1` would silently build a spec describing
48
+ * a different tunnel. Bracketed literals are a separate job, so this says no
49
+ * rather than guessing.
50
+ */
51
+ function requireDestinationHost(value) {
52
+ if (value === undefined || value === '')
53
+ return DEFAULT_DESTINATION_HOST;
54
+ if (typeof value !== 'string' || !isSshHostName(value)) {
55
+ throw new Error('The destination host must be a hostname or IP address.');
56
+ }
57
+ if (value.includes(':')) {
58
+ throw new Error('An IPv6 destination is not supported yet. Use a hostname or an IPv4 address.');
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * The bind address to put in the spec, or nothing.
64
+ *
65
+ * The two directions are not symmetric here, and it is the server's rule that
66
+ * makes them differ. A `-L` forward binds on this machine, so the address is
67
+ * ours to state and is always stated. A `-R` forward binds on the remote, where
68
+ * sshd's GatewayPorts decides: the default (`no`) binds loopback and rejects a
69
+ * client-specified address outright, so loopback has to be expressed by saying
70
+ * nothing at all. `*` is used for the wide case because it is accepted under
71
+ * both `yes` and `clientspecified`.
72
+ */
73
+ function bindPrefix(direction, bind) {
74
+ if (direction === 'local')
75
+ return bind === 'all' ? '0.0.0.0:' : '127.0.0.1:';
76
+ return bind === 'all' ? '*:' : '';
77
+ }
78
+ /**
79
+ * Build the command line for a forward.
80
+ *
81
+ * Two options carry most of the weight:
82
+ *
83
+ * `ExitOnForwardFailure=yes` — without it, ssh stays connected when the forward
84
+ * cannot bind, and there is no way to tell a working tunnel from a useless one
85
+ * short of trying to use it. With it, a forward that fails is a process that
86
+ * exits, and the UI can say so.
87
+ *
88
+ * `-v` — so that the tunnel being up is something ssh said rather than something
89
+ * we assumed. `-N` produces no output of its own on success, so without this the
90
+ * only signal available is the absence of an error.
91
+ */
92
+ export function buildForwardArgs(request, options = {}) {
93
+ const direction = requireDirection(request.direction);
94
+ const bind = requireBind(request.bind);
95
+ const listenPort = requirePort(request.listenPort, 'The port to open');
96
+ const destinationPort = requirePort(request.destinationPort, 'The port to forward to');
97
+ const destinationHost = requireDestinationHost(request.destinationHost);
98
+ const { destination, port } = parseSshTarget(request.target);
99
+ const spec = `${bindPrefix(direction, bind)}${listenPort}:${destinationHost}:${destinationPort}`;
100
+ const args = ['-v', '-N', '-o', 'ExitOnForwardFailure=yes', '-o', 'ConnectTimeout=20'];
101
+ if (port)
102
+ args.push('-p', port);
103
+ args.push(direction === 'local' ? '-L' : '-R', spec);
104
+ // '--' ends option parsing, so the destination can never be read as a flag.
105
+ args.push('--', destination);
106
+ return { file: sshExecutable(options), args, spec };
107
+ }
108
+ const FAILURES = [
109
+ {
110
+ pattern: /bind \[?([^\]\s]+)\]?:(\d+): (.+)/,
111
+ reason: (match) => `Could not bind ${match[1]}:${match[2]} — ${match[3].trim()}.`
112
+ },
113
+ {
114
+ pattern: /Warning: remote port forwarding failed for listen port (\d+)/,
115
+ reason: (match) => `The remote host refused to open port ${match[1]}. Its sshd may need GatewayPorts enabled to bind beyond loopback.`
116
+ },
117
+ {
118
+ pattern: /channel_setup_fwd_listener_tcpip: cannot listen to port: (\d+)/,
119
+ reason: (match) => `Could not listen on port ${match[1]}.`
120
+ },
121
+ { pattern: /Permission denied \(([^)]*)\)/, reason: (match) => `Authentication failed (${match[1]}).` },
122
+ { pattern: /(?:ssh: )?Could not resolve hostname ([^\s:]+)/, reason: (match) => `Could not resolve ${match[1]}.` },
123
+ { pattern: /Connection (?:refused|timed out|closed) by ([^\s]+)/, reason: (match) => `The host ${match[1]} refused the connection.` },
124
+ { pattern: /(ssh: connect to host [^\r\n]+)/, reason: (match) => `${match[1].trim()}.` },
125
+ { pattern: /Host key verification failed/, reason: () => 'Host key verification failed.' }
126
+ ];
127
+ /**
128
+ * What the client's output so far says about the tunnel.
129
+ *
130
+ * Read from the whole buffer rather than the tail: `-v` is talkative, and the one
131
+ * line that matters is followed by many that do not. Failure is checked before
132
+ * success because ssh reports a partial success — one forward of several — while
133
+ * still exiting.
134
+ */
135
+ export function readForwardOutcome(buffer, direction) {
136
+ for (const { pattern, reason } of FAILURES) {
137
+ const match = buffer.match(pattern);
138
+ if (match)
139
+ return { kind: 'failed', reason: reason(match) };
140
+ }
141
+ const success = direction === 'local'
142
+ ? /Local forwarding listening on \S+ port (\d+)/
143
+ : /remote forward success for: listen (\d+)/;
144
+ if (success.test(buffer))
145
+ return { kind: 'open' };
146
+ // Not proof of a tunnel, but it does mean the connection got that far — the
147
+ // service uses it to start the settle timer rather than waiting the full
148
+ // timeout on a host that never answered at all.
149
+ if (/Authentication succeeded|debug1: Entering interactive session/.test(buffer)) {
150
+ return { kind: 'authenticated' };
151
+ }
152
+ return null;
153
+ }
154
+ /** How a forward reads in the UI and in a log line. */
155
+ export function describeForward(request) {
156
+ const destinationHost = request.destinationHost || DEFAULT_DESTINATION_HOST;
157
+ const listener = request.bind === 'all' ? '0.0.0.0' : 'localhost';
158
+ return request.direction === 'local'
159
+ ? `${listener}:${request.listenPort} → ${destinationHost}:${request.destinationPort} on ${request.target}`
160
+ : `${request.target}:${request.listenPort} → ${destinationHost}:${request.destinationPort} here`;
161
+ }