Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ for what works, what's in progress, and what's still planned.
| ✅ | **One-click SSH key setup** | Generates an Ed25519 key, installs it, verifies it, and optionally turns off password login, with automatic rollback if anything fails. |
| ✅ | **ProxyJump** | Hosts behind one or more bastions work everywhere: dashboard, terminal, SFTP. |
| ✅ | **Encrypted passwords** | Stored passwords are encrypted with the OS keystore (DPAPI, Keychain, libsecret). |
| ✅ | **1Password** | Read the address, port, user, domain or password of a host or a remote desktop connection from 1Password when connecting instead of storing it, and use SSH keys from the 1Password SSH agent. [How](#using-1password) |
| ✅ | **Light & dark theme** | |
| 🚧 | **Snippets & automations** *(in progress)* | Save commands as snippets and chain them into automations on a canvas, run locally or on a host, with parameters and the output of earlier steps. Usable, but still changing. |
| 🚧 | **Remote desktop (RDP)** *(in progress)* | RDP sessions as tabs inside the app, next to your terminals (IronRDP, no extra window), or in the OS's own client (Remote Desktop on Windows, FreeRDP on Linux and macOS), signed in automatically either way. Drag files onto the session to copy them there, and save files copied on the remote desktop. Can tunnel through any SSH host, so machines behind a bastion work without exposing port 3389. Display, monitor, clipboard, drive and sound settings per profile. |
| 💡 | **AI integration** *(maybe in the future)* | Help with commands, explain output or errors, right in the terminal. |
| 💡 | **Plugin system** *(maybe in the future)* | Extend the app with your own features without touching the core. |
| 💡 | **1Password integration** *(maybe in the future)* | Use SSH keys and passwords straight from your 1Password vault. |

✅ done · 🚧 in progress · 📋 planned · 💡 maybe in the future

Expand Down Expand Up @@ -99,6 +99,22 @@ The app reads the hosts from your `~/.ssh/config` (it never writes to it) and st
own data in `%APPDATA%\better-ssh-client\` (Windows), `~/Library/Application Support/better-ssh-client/`
(macOS) or `~/.config/better-ssh-client/` (Linux).

### Using 1Password

**Addresses, ports, users, domains, passwords:** install the [1Password CLI](https://developer.1password.com/docs/cli/get-started/)
(on Windows: `winget install AgileBits.1Password.CLI`) and turn on *Settings → Developer →
Integrate with 1Password CLI* in the 1Password app. Then, in a host's or a remote desktop
connection's settings, click **1Password** next to the hostname, port, user, password, domain (remote desktop) or default path (SSH), and
put the item's secret reference into that field (in 1Password: right-click the field → *Copy
Secret Reference*, e.g. `op://Servers/web-1/password`). The app reads them when it connects —
1Password may ask for Windows Hello or Touch ID — keeps them in memory for 10 minutes, and never
writes the values to disk.

**SSH keys:** turn on *Settings → Developer → Use the SSH agent* in the 1Password app. The app
asks the SSH agent for keys before anything else, so your 1Password keys just work. On Windows,
stop and disable the *OpenSSH Authentication Agent* service first, since 1Password's agent
takes over its place.

---

## Feedback and contributing
Expand Down
1 change: 1 addition & 0 deletions packages/electron/src/core/config/remoteDesktop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe('remote-desktop.toml I/O', () => {
username: 'admin',
domain: 'CORP',
viaHost: 'bastion',
passwordRef: 'op://Servers/pc/password',
display: 'window',
width: 1600,
height: 900,
Expand Down
10 changes: 10 additions & 0 deletions packages/electron/src/core/config/remoteDesktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export interface RemoteDesktopConnection extends RdpSettings {
/** Name of an SSH host to tunnel the connection through; `hostname`/`port` are
* then as seen from that host. */
viaHost?: string;
/** A 1Password secret reference (`op://vault/item/field`) the password is read
* from at connect time instead of being stored. Takes precedence over `password`. */
passwordRef?: string;
/** A 1Password reference the port is read from at connect time; `port` then keeps
* the default. (Hostname, username and domain carry theirs in the field itself.) */
portRef?: string;
}

interface RemoteDesktopFile {
Expand Down Expand Up @@ -109,6 +115,8 @@ function connectionFromToml(raw: Record<string, unknown>): RemoteDesktopConnecti
domain: typeof raw.domain === 'string' ? raw.domain : undefined,
viewOnly: typeof raw.viewOnly === 'boolean' ? raw.viewOnly : undefined,
viaHost: typeof raw.viaHost === 'string' ? raw.viaHost : undefined,
passwordRef: typeof raw.passwordRef === 'string' ? raw.passwordRef : undefined,
portRef: typeof raw.portRef === 'string' ? raw.portRef : undefined,
...rdpSettingsFrom(raw)
});
}
Expand All @@ -127,6 +135,8 @@ function connectionToToml(connection: RemoteDesktopConnection): Record<string, u
if (encrypted.domain !== undefined) out.domain = encrypted.domain;
if (encrypted.viewOnly !== undefined) out.viewOnly = encrypted.viewOnly;
if (encrypted.viaHost !== undefined) out.viaHost = encrypted.viaHost;
if (encrypted.passwordRef !== undefined) out.passwordRef = encrypted.passwordRef;
if (encrypted.portRef !== undefined) out.portRef = encrypted.portRef;
for (const key of RDP_SETTING_KEYS) {
if (encrypted[key] !== undefined) out[key] = encrypted[key];
}
Expand Down
78 changes: 78 additions & 0 deletions packages/electron/src/core/rdp/password.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { clearSecretCache, setOpRunner } from '../secrets/onePassword.js';
import { connectionPassword, resolveConnection } from './password.js';

describe('connectionPassword', () => {
afterEach(() => {
setOpRunner(undefined);
clearSecretCache();
});

it('is the stored password when there is no 1Password reference', async () => {
expect(await connectionPassword({ password: 'stored' })).toBe('stored');
expect(await connectionPassword({})).toBeUndefined();
});

it('comes from 1Password when there is a reference — which wins over a stored one', async () => {
const op = vi.fn(async () => ({ stdout: 'from-1password', stderr: '' }));
setOpRunner(op);
expect(await connectionPassword({ password: 'stored', passwordRef: 'op://Servers/win11/password' })).toBe('from-1password');
expect(op).toHaveBeenCalledWith(['read', '--no-newline', 'op://Servers/win11/password']);
});

it('asks 1Password once for a while, however often it connects', async () => {
const op = vi.fn(async () => ({ stdout: 'secret', stderr: '' }));
setOpRunner(op);
await connectionPassword({ passwordRef: 'op://Servers/win11/password' });
await connectionPassword({ passwordRef: 'op://Servers/win11/password' });
expect(op).toHaveBeenCalledTimes(1);
});

it('resolves address, user and password — only the fields that are references', async () => {
const values: Record<string, string> = {
'op://Servers/win11/host': '10.0.0.7',
'op://Servers/win11/password': 'pw'
};
const op = vi.fn(async (args: string[]) => ({ stdout: values[args[2]], stderr: '' }));
setOpRunner(op);
const resolved = await resolveConnection({
hostname: 'op://Servers/win11/host',
username: 'admin',
passwordRef: 'op://Servers/win11/password',
port: 3389
});
expect(resolved).toEqual({ hostname: '10.0.0.7', username: 'admin', password: 'pw', passwordRef: undefined, port: 3389 });
expect(op).toHaveBeenCalledTimes(2);
});

it('resolves the port and the domain too', async () => {
const values: Record<string, string> = { 'op://S/pc/port': '13389\n', 'op://S/pc/domain': 'CORP' };
setOpRunner(async (args: string[]) => ({ stdout: values[args[2]], stderr: '' }));
const resolved = await resolveConnection({ hostname: 'pc', port: 3389, portRef: 'op://S/pc/port', domain: 'op://S/pc/domain' });
expect(resolved).toMatchObject({ port: 13389, portRef: undefined, domain: 'CORP' });
});

it('refuses a port that is not one — without echoing what 1Password returned', async () => {
setOpRunner(async () => ({ stdout: 'hunter2', stderr: '' }));
const err = await resolveConnection({ hostname: 'pc', port: 3389, portRef: 'op://S/pc/password' }).catch((e: Error) => e);
expect((err as Error).message).toBe('1Password: op://S/pc/password is not a port number (1–65535)');
});

it('leaves a connection without references as it is, without asking 1Password', async () => {
const op = vi.fn();
setOpRunner(op);
expect(await resolveConnection({ hostname: 'win11.lan', username: 'admin', password: 'pw' })).toEqual({
hostname: 'win11.lan',
username: 'admin',
password: 'pw',
passwordRef: undefined
});
expect(op).not.toHaveBeenCalled();
});

it("says what's wrong when 1Password can't be asked", async () => {
setOpRunner(async () => Promise.reject(Object.assign(new Error('spawn op ENOENT'), { code: 'ENOENT' })));
await expect(connectionPassword({ passwordRef: 'op://Servers/win11/password' })).rejects.toThrow('1Password CLI (op) not found');
});
});
32 changes: 32 additions & 0 deletions packages/electron/src/core/rdp/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { RemoteDesktopConnection } from '../config/remoteDesktop.js';
import { readSecretCached, resolvePort, resolveReference } from '../secrets/onePassword.js';

/**
* The password to sign in to `connection` with: read from 1Password when it has a
* reference (remembered for a few minutes, see `readSecretCached`), else the stored
* one. Read before anything connects — a Windows Hello or Touch ID prompt for the
* 1Password CLI can take a while.
*/
export async function connectionPassword(connection: Pick<RemoteDesktopConnection, 'password' | 'passwordRef'>): Promise<string | undefined> {
if (connection.passwordRef) return readSecretCached(connection.passwordRef);
return connection.password;
}

/**
* `connection` with everything that comes from 1Password filled in: the address, user
* name and domain when they are references (`op://…` in the field itself), the port
* from `portRef` and the password from `passwordRef`. The result carries no references
* any more, so whatever launches or tunnels it needs no knowledge of 1Password.
*/
export async function resolveConnection<
C extends Pick<RemoteDesktopConnection, 'hostname' | 'port' | 'portRef' | 'username' | 'domain' | 'password' | 'passwordRef'>
>(connection: C): Promise<C> {
// One after the other: the first read may wait for Windows Hello / Touch ID, and
// the rest then ride on that unlock.
const hostname = await resolveReference(connection.hostname);
const port = await resolvePort(connection.portRef, connection.port);
const username = connection.username ? await resolveReference(connection.username) : connection.username;
const domain = connection.domain ? await resolveReference(connection.domain) : connection.domain;
const password = await connectionPassword(connection);
return { ...connection, hostname, port, portRef: undefined, username, domain, password, passwordRef: undefined };
}
111 changes: 111 additions & 0 deletions packages/electron/src/core/secrets/onePassword.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from 'vitest';

import { cliVersion, installHint, isSecretReference, normalizeReference, opLocations, readSecret, setOpRunner } from './onePassword.js';

afterEach(() => setOpRunner(undefined));

describe('isSecretReference', () => {
it('accepts vault/item/field and vault/item/section/field', () => {
expect(isSecretReference('op://Servers/web-1/password')).toBe(true);
expect(isSecretReference(' op://Servers/web-1/login/password ')).toBe(true);
});

it('accepts names with spaces, and the quotes 1Password copies them with', () => {
expect(isSecretReference('op://IT/e-HPV one/Benutzername')).toBe(true);
expect(isSecretReference('"op://IT/e-HPV one/Benutzername"')).toBe(true);
expect(normalizeReference(' "op://IT/e-HPV one/Benutzername" ')).toBe('op://IT/e-HPV one/Benutzername');
});

it('refuses anything else', () => {
for (const v of ['hunter2', 'op://Servers/web-1', 'op://Servers', 'https://x/y/z', 'op://a /c/d', 'op://a/b/c"', 'op://a/b\nc/d', 'op://a/b/c/d/e']) {
expect(isSecretReference(v), v).toBe(false);
}
});
});

describe('readSecret', () => {
it('asks op for exactly the reference, without a trailing newline', async () => {
let seen: string[] = [];
setOpRunner(async (args) => {
seen = args;
return { stdout: 's3cret', stderr: '' };
});
expect(await readSecret(' op://Servers/web-1/password ')).toBe('s3cret');
expect(seen).toEqual(['read', '--no-newline', 'op://Servers/web-1/password']);
});

it('passes op a quoted reference without its quotes', async () => {
let seen: string[] = [];
setOpRunner(async (args) => {
seen = args;
return { stdout: 'admin', stderr: '' };
});
expect(await readSecret('"op://IT/e-HPV one/Benutzername"')).toBe('admin');
expect(seen).toEqual(['read', '--no-newline', 'op://IT/e-HPV one/Benutzername']);
});

it('never runs op for something that is not a reference', async () => {
let ran = false;
setOpRunner(async () => {
ran = true;
return { stdout: 'x', stderr: '' };
});
await expect(readSecret('op://nope')).rejects.toThrow('not a 1Password reference');
expect(ran).toBe(false);
});

it('explains a missing CLI', async () => {
setOpRunner(async () => {
throw Object.assign(new Error('spawn op ENOENT'), { code: 'ENOENT' });
});
await expect(readSecret('op://a/b/c')).rejects.toThrow('1Password CLI (op) not found');
});

it("passes op's own error on, without its timestamp prefix", async () => {
setOpRunner(async () => {
throw Object.assign(new Error('Command failed'), {
code: 1,
stderr: '[ERROR] 2026/09/25 20:01:02 could not read secret "op://a/b/c": item "b" not found\n'
});
});
await expect(readSecret('op://a/b/c')).rejects.toThrow('1Password: could not read secret "op://a/b/c": item "b" not found');
});

it('refuses an empty value rather than trying an empty password', async () => {
setOpRunner(async () => ({ stdout: '', stderr: '' }));
await expect(readSecret('op://a/b/c')).rejects.toThrow('empty value');
});
});

describe('cliVersion', () => {
it("is op's version when the CLI is installed", async () => {
setOpRunner(async (args) => {
expect(args).toEqual(['--version']);
return { stdout: '2.31.1\n', stderr: '' };
});
expect(await cliVersion()).toBe('2.31.1');
});

it("is undefined when it isn't", async () => {
setOpRunner(async () => Promise.reject(Object.assign(new Error('spawn op ENOENT'), { code: 'ENOENT' })));
expect(await cliVersion()).toBeUndefined();
});
});

describe('installHint', () => {
it('gives the usual install command per OS, and always the docs', () => {
expect(installHint('win32').command).toBe('winget install AgileBits.1Password.CLI');
expect(installHint('darwin').command).toBe('brew install 1password-cli');
expect(installHint('linux').command).toBeUndefined();
expect(installHint('linux').docsUrl).toMatch(/^https:\/\/developer\.1password\.com\//);
});
});

describe('opLocations', () => {
it("knows where winget, Homebrew and the installers put op, for when it isn't on PATH", () => {
const win = opLocations('win32', { LOCALAPPDATA: 'C:/Users/a/AppData/Local', ProgramFiles: 'C:/Program Files' });
expect(win[0]).toMatch(/WinGet.Links.op\.exe$/);
expect(win).toContainEqual(expect.stringMatching(/Program Files.1Password CLI.op\.exe$/));
expect(opLocations('darwin', {})).toEqual(['/opt/homebrew/bin/op', '/usr/local/bin/op']);
});
});
Loading
Loading