diff --git a/README.md b/README.md index da3f116..353b1a1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/packages/electron/src/core/config/remoteDesktop.test.ts b/packages/electron/src/core/config/remoteDesktop.test.ts index f6a6d08..207a843 100644 --- a/packages/electron/src/core/config/remoteDesktop.test.ts +++ b/packages/electron/src/core/config/remoteDesktop.test.ts @@ -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, diff --git a/packages/electron/src/core/config/remoteDesktop.ts b/packages/electron/src/core/config/remoteDesktop.ts index 8067c7c..a28d746 100644 --- a/packages/electron/src/core/config/remoteDesktop.ts +++ b/packages/electron/src/core/config/remoteDesktop.ts @@ -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 { @@ -109,6 +115,8 @@ function connectionFromToml(raw: Record): 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) }); } @@ -127,6 +135,8 @@ function connectionToToml(connection: RemoteDesktopConnection): Record { + 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 = { + '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 = { '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'); + }); +}); diff --git a/packages/electron/src/core/rdp/password.ts b/packages/electron/src/core/rdp/password.ts new file mode 100644 index 0000000..fde83be --- /dev/null +++ b/packages/electron/src/core/rdp/password.ts @@ -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): Promise { + 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 +>(connection: C): Promise { + // 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 }; +} diff --git a/packages/electron/src/core/secrets/onePassword.test.ts b/packages/electron/src/core/secrets/onePassword.test.ts new file mode 100644 index 0000000..a9213de --- /dev/null +++ b/packages/electron/src/core/secrets/onePassword.test.ts @@ -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']); + }); +}); diff --git a/packages/electron/src/core/secrets/onePassword.ts b/packages/electron/src/core/secrets/onePassword.ts new file mode 100644 index 0000000..21f1ff3 --- /dev/null +++ b/packages/electron/src/core/secrets/onePassword.ts @@ -0,0 +1,178 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * 1Password secret references (`op:////`) for host + * passwords. A host stores the reference, never the password: at connect time + * the app asks the 1Password CLI (`op read`) for the value, and it lives only + * in memory for that login. + * + * `op` authenticates through the 1Password desktop app ("Integrate with + * 1Password CLI" in its Developer settings), so the user may get a + * Windows Hello / Touch ID prompt the first time; op keeps that session alive + * for a while afterwards. + */ + +/** `op://vault/item/field`, optionally with a section: `op://vault/item/section/field`. + * Names may hold spaces — not at either end, and no line breaks or quotes. */ +const SEGMENT = '[^/\\s"\'](?:[^/\\r\\n"\']*[^/\\s"\'])?'; +const REFERENCE = new RegExp(`^op://${SEGMENT}/${SEGMENT}(?:/${SEGMENT}){1,2}$`); + +/** A reference as pasted, cleaned up: 1Password's "Copy Secret Reference" puts one + * whose names contain spaces in quotes, "op://IT/web one/password". */ +export function normalizeReference(value: string): string { + const v = value.trim(); + const quoted = v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]; + return quoted ? v.slice(1, -1).trim() : v; +} + +export function isSecretReference(value: string): boolean { + return REFERENCE.test(normalizeReference(value)); +} + +/** How the CLI is run — replaceable in tests (there is no 1Password in CI). */ +export type OpRunner = (args: string[]) => Promise<{ stdout: string; stderr: string }>; + +/** Waits long enough for a biometric prompt to be answered. */ +const OP_TIMEOUT_MS = 90_000; + +/** + * Where the CLI's usual installers put `op`, tried when it isn't on this process's + * PATH: an app started from the Finder or Dock gets a PATH without Homebrew's folder, + * and on Windows a CLI just installed with winget is on the user's PATH only for + * processes started afterwards — this finds it without restarting the app. + */ +export function opLocations(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] { + if (platform === 'win32') { + return [ + env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'op.exe'), + env.ProgramFiles && join(env.ProgramFiles, '1Password CLI', 'op.exe'), + env.LOCALAPPDATA && join(env.LOCALAPPDATA, '1Password CLI', 'op.exe') + ].filter((p): p is string => Boolean(p)); + } + if (platform === 'darwin') return ['/opt/homebrew/bin/op', '/usr/local/bin/op']; + return ['/usr/local/bin/op', '/usr/bin/op']; +} + +function run(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, { timeout: OP_TIMEOUT_MS, windowsHide: true }, (err, stdout, stderr) => { + if (err) reject(Object.assign(err, { stderr: String(stderr) })); + else resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); +} + +const defaultRunner: OpRunner = async (args) => { + try { + return await run('op', args); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + const found = opLocations(process.platform, process.env).find((p) => existsSync(p)); + if (!found) throw err; + return run(found, args); + } +}; + +let runner: OpRunner = defaultRunner; + +/** Swaps the CLI runner (tests only); `undefined` restores the real `op`. */ +export function setOpRunner(next: OpRunner | undefined): void { + runner = next ?? defaultRunner; +} + +export class OnePasswordError extends Error {} + +/** Resolves a secret reference to its value through the 1Password CLI. */ +export async function readSecret(reference: string): Promise { + const ref = normalizeReference(reference); + if (!isSecretReference(ref)) { + throw new OnePasswordError(`"${ref}" is not a 1Password reference (expected op://vault/item/field)`); + } + try { + const { stdout } = await runner(['read', '--no-newline', ref]); + if (stdout === '') throw new OnePasswordError(`1Password returned an empty value for ${ref}`); + return stdout; + } catch (e) { + if (e instanceof OnePasswordError) throw e; + const err = e as NodeJS.ErrnoException & { stderr?: string; killed?: boolean }; + if (err.code === 'ENOENT') { + throw new OnePasswordError( + '1Password CLI (op) not found. Install it and turn on "Integrate with 1Password CLI" in the 1Password app.' + ); + } + if (err.killed) throw new OnePasswordError(`1Password did not answer in time for ${ref}`); + // op's own message ("[ERROR] 2026/… could not read secret …") is the useful part. + const detail = (err.stderr ?? err.message ?? '').replace(/^\[ERROR\]\s*\S+\s+\S+\s*/, '').trim(); + throw new OnePasswordError(`1Password: ${detail || 'could not read the secret'}`); + } +} + +/** How long a secret read from 1Password is kept in memory, so new tabs and + * reconnects within that window don't prompt again. The same trade-off as a + * stored password, which is held decrypted in memory for the whole session. */ +export const SECRET_CACHE_MS = 10 * 60_000; +const secretCache = new Map(); + +/** `readSecret`, remembered for `SECRET_CACHE_MS` — shared by SSH hosts and remote + * desktop connections, so one reference prompts once however it's used. */ +export async function readSecretCached(reference: string): Promise { + const ref = normalizeReference(reference); + const cached = secretCache.get(ref); + if (cached && cached.expires > Date.now()) return cached.value; + const value = await readSecret(ref); + secretCache.set(ref, { value, expires: Date.now() + SECRET_CACHE_MS }); + return value; +} + +/** A field that may hold either its value or a 1Password reference to it (a host's + * address or user name): the value, read from 1Password when it is a reference. */ +export async function resolveReference(value: string): Promise { + return isSecretReference(value) ? readSecretCached(value) : value; +} + +/** A port read from 1Password: `reference` when there is one, else `port`. The value + * read is never put in the error — a reference to the wrong field could point at a + * password. */ +export async function resolvePort(reference: string | undefined, port: number): Promise { + if (!reference) return port; + const raw = (await readSecretCached(reference)).trim(); + const value = Number(raw); + if (!/^\d+$/.test(raw) || value < 1 || value > 65535) { + throw new OnePasswordError(`1Password: ${normalizeReference(reference)} is not a port number (1–65535)`); + } + return value; +} + +/** Forgets every remembered secret (tests). */ +export function clearSecretCache(): void { + secretCache.clear(); +} + +/** The installed 1Password CLI's version, or undefined when `op` isn't there (or + * doesn't answer) — for telling the user to install it before a connection fails. */ +export async function cliVersion(): Promise { + try { + const { stdout } = await runner(['--version']); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +export interface InstallHint { + /** A command that installs the CLI, when there is a usual one for this OS. */ + command?: string; + docsUrl: string; +} + +const CLI_DOCS = 'https://developer.1password.com/docs/cli/get-started/'; + +/** How to install the 1Password CLI here. */ +export function installHint(platform: NodeJS.Platform): InstallHint { + if (platform === 'win32') return { command: 'winget install AgileBits.1Password.CLI', docsUrl: CLI_DOCS }; + if (platform === 'darwin') return { command: 'brew install 1password-cli', docsUrl: CLI_DOCS }; + // Linux: per-distro package repositories — the docs walk through them. + return { docsUrl: CLI_DOCS }; +} diff --git a/packages/electron/src/core/ssh/client.test.ts b/packages/electron/src/core/ssh/client.test.ts index b4d155e..912eed7 100644 --- a/packages/electron/src/core/ssh/client.test.ts +++ b/packages/electron/src/core/ssh/client.test.ts @@ -70,4 +70,12 @@ describe('hostFromToml / hostToToml round trip', () => { expect(withoutCommand.startupCommand).toBeUndefined(); expect(hostToToml(withoutCommand)).not.toHaveProperty('startup_command'); }); + + it('round-trips a 1Password reference, and omits it when unset', () => { + const withRef = { ...defaultHost(), name: 'a', hostname: 'a', passwordRef: 'op://Servers/a/password' }; + const written = hostToToml(withRef); + expect(written.password_ref).toBe('op://Servers/a/password'); + expect(hostFromToml(written).passwordRef).toBe('op://Servers/a/password'); + expect(hostToToml(hostFromToml({ name: 'b', hostname: 'b' }))).not.toHaveProperty('password_ref'); + }); }); diff --git a/packages/electron/src/core/ssh/client.ts b/packages/electron/src/core/ssh/client.ts index 274cdec..0f1a779 100644 --- a/packages/electron/src/core/ssh/client.ts +++ b/packages/electron/src/core/ssh/client.ts @@ -18,6 +18,12 @@ export interface Host { port: number; identityFile?: string; password?: string; + /** A 1Password secret reference (`op://vault/item/field`) to read the password + * from at connect time, instead of storing it. Takes precedence over `password`. */ + passwordRef?: string; + /** A 1Password reference the port is read from at connect time; `port` then keeps + * the default. (Hostname and user carry theirs in the field itself.) */ + portRef?: string; proxyJump?: string; tags: string[]; notes?: string; @@ -78,6 +84,8 @@ export function hostFromToml(raw: Record): Host { port: typeof raw.port === 'number' ? raw.port : DEFAULT_PORT, identityFile: typeof raw.identity_file === 'string' ? raw.identity_file : undefined, password: typeof raw.password === 'string' ? raw.password : undefined, + passwordRef: typeof raw.password_ref === 'string' ? raw.password_ref : undefined, + portRef: typeof raw.port_ref === 'string' ? raw.port_ref : undefined, proxyJump: typeof raw.proxy_jump === 'string' ? raw.proxy_jump : undefined, tags: Array.isArray(raw.tags) ? raw.tags.filter((t): t is string => typeof t === 'string') : [], notes: typeof raw.notes === 'string' ? raw.notes : undefined, @@ -108,6 +116,8 @@ export function hostToToml(host: Host): Record { }; if (host.identityFile !== undefined) out.identity_file = host.identityFile; if (host.password !== undefined) out.password = host.password; + if (host.passwordRef !== undefined) out.password_ref = host.passwordRef; + if (host.portRef !== undefined) out.port_ref = host.portRef; if (host.proxyJump !== undefined) out.proxy_jump = host.proxyJump; if (host.tags.length > 0) out.tags = host.tags; if (host.notes !== undefined) out.notes = host.notes; diff --git a/packages/electron/src/core/ssh/onePassword.integration.test.ts b/packages/electron/src/core/ssh/onePassword.integration.test.ts new file mode 100644 index 0000000..06c6abe --- /dev/null +++ b/packages/electron/src/core/ssh/onePassword.integration.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { setOpRunner } from '../secrets/onePassword.js'; +import { SshSession } from './session.js'; +import { testTargetHost } from '../../testSupport/sshTestTarget.js'; + +// A host whose password lives in 1Password, against the real test sshd. There is no +// 1Password in CI, so a stand-in `op` answers `op read` the way the real CLI does. + +afterEach(() => setOpRunner(undefined)); + +describe('1Password password references against the test target', () => { + it('logs in with the password read from the reference', async () => { + const asked: string[] = []; + setOpRunner(async (args) => { + asked.push(args.join(' ')); + return { stdout: 'better-ssh-client', stderr: '' }; + }); + const host = testTargetHost({ password: undefined, passwordRef: 'op://Servers/it-op-ok/password' }); + const session = await SshSession.connect(host); + try { + expect((await session.runCommand('echo via-1password')).trim()).toBe('via-1password'); + } finally { + session.disconnect(); + } + expect(asked).toEqual(['read --no-newline op://Servers/it-op-ok/password']); + }); + + it("fails with 1Password's reason when the reference can't be read", async () => { + setOpRunner(async () => { + throw Object.assign(new Error('Command failed'), { + code: 1, + stderr: '[ERROR] 2026/09/25 20:00:00 could not read secret: item "gone" not found\n' + }); + }); + const host = testTargetHost({ password: undefined, passwordRef: 'op://Servers/it-op-gone/password' }); + await expect(SshSession.connect(host)).rejects.toThrow(/SSH authentication failed .*item "gone" not found/); + }); +}); diff --git a/packages/electron/src/core/ssh/pool.ts b/packages/electron/src/core/ssh/pool.ts index 4144300..42d68f4 100644 --- a/packages/electron/src/core/ssh/pool.ts +++ b/packages/electron/src/core/ssh/pool.ts @@ -13,7 +13,7 @@ import { parseUptime, type ProcessInfo } from './metrics.js'; -import { SshSession } from './session.js'; +import { resolveHostAddress, SshSession } from './session.js'; import { quickScan } from './discovery.js'; import type { CoreEvent, Metrics } from '../../event.js'; @@ -240,7 +240,8 @@ async function runTcpPoller( return; } - const port = host.monitorPort !== undefined && host.monitorPort !== 0 ? host.monitorPort : host.port; + // A probe port of its own wins; else the SSH port, which may come from 1Password. + const probePort = host.monitorPort !== undefined && host.monitorPort !== 0 ? host.monitorPort : undefined; const backoff = new BackoffState(); let last: ConnectionStatusResult | undefined; @@ -249,7 +250,10 @@ async function runTcpPoller( emit({ type: 'hostStatusChanged', hostName: host.name, status: { kind: 'connecting' } }); } - const status = await tcpProbe(host.hostname, port); + const status = await resolveHostAddress(host).then( + (resolved) => tcpProbe(resolved.hostname, probePort ?? resolved.port), + (err: Error): ConnectionStatusResult => ({ kind: 'failed', message: err.message }) + ); const reachable = status.kind === 'connected'; // Only on a change: re-announcing every cycle flickers the card between diff --git a/packages/electron/src/core/ssh/pty.ts b/packages/electron/src/core/ssh/pty.ts index 6aad864..c7c38f9 100644 --- a/packages/electron/src/core/ssh/pty.ts +++ b/packages/electron/src/core/ssh/pty.ts @@ -2,6 +2,7 @@ import type { ClientChannel } from 'ssh2'; import type { Host } from './client.js'; import { SshSession } from './session.js'; +import { resolveReference } from '../secrets/onePassword.js'; import type { CoreEvent } from '../../event.js'; /** @@ -141,7 +142,10 @@ export class PtyManager { let channel: ClientChannel; try { sshSession = await SshSession.shared(host); - channel = await sshSession.openShell(cols, rows, processLocaleEnv(), host.defaultPath); + // The default path may be a 1Password reference; read once connected, so the + // same unlock covers it. + const cwd = host.defaultPath === undefined ? undefined : await resolveReference(host.defaultPath); + channel = await sshSession.openShell(cols, rows, processLocaleEnv(), cwd); } catch (e) { emit({ type: 'error', message: `Terminal: ${(e as Error).message}` }); emit({ type: 'ptyExited', sessionId: id }); diff --git a/packages/electron/src/core/ssh/session.ts b/packages/electron/src/core/ssh/session.ts index fc53ac4..8ec49f0 100644 --- a/packages/electron/src/core/ssh/session.ts +++ b/packages/electron/src/core/ssh/session.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -8,6 +9,7 @@ import { ConnectionPool, type Lease } from './connectionPool.js'; import { checkKnownHosts, learnKnownHost } from './knownHosts.js'; import { resolveChain, jumpValue } from './jump.js'; import { loadAllHosts } from '../config/hosts.js'; +import { readSecretCached, resolvePort, resolveReference } from '../secrets/onePassword.js'; /** * SSH session management via `ssh2`. Ported from @@ -64,7 +66,7 @@ class SshConnection { * two hosts differing only in name, tags or notes share a connection, and an * edited password or key never reuses one made with the old value. */ function connectionKey(host: Host): string { - return JSON.stringify([host.hostname, host.port, host.user, host.identityFile, host.password, jumpValue(host)]); + return JSON.stringify([host.hostname, host.port, host.user, host.identityFile, host.password, host.passwordRef, host.portRef, jumpValue(host)]); } const sharedConnections = new ConnectionPool(connectionKey, connectAndAuth); @@ -357,15 +359,27 @@ async function connectAndAuth(host: Host): Promise { return new SshConnection(target, jumps); } +/** `host` with its address and user read from 1Password where they are references + * (`op://…` in the field itself), and its port where it has a `portRef`. Done per + * hop, before dialling it. */ +export async function resolveHostAddress(host: Host): Promise { + const hostname = await resolveReference(host.hostname); + const port = await resolvePort(host.portRef, host.port); + const user = await resolveReference(host.user); + if (hostname === host.hostname && port === host.port && user === host.user) return host; + return { ...host, hostname, port, user }; +} + /** Opens a TCP connection to `host` and authenticates. */ async function connectDirect(host: Host): Promise { - return authenticate(host, {}); + return authenticate(await resolveHostAddress(host), {}); } /** Reaches `host` through the already-connected bastion `via`: a * `direct-tcpip` channel on the bastion carries a second SSH session to * the target, which is verified and authenticated in its own right. */ -async function connectTunnelled(via: Client, host: Host): Promise { +async function connectTunnelled(via: Client, saved: Host): Promise { + const host = await resolveHostAddress(saved); const stream = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('SSH connection timed out (10 s)')), CONNECT_TIMEOUT_MS); // The originator address is informational; servers only log it. @@ -378,11 +392,20 @@ async function connectTunnelled(via: Client, host: Host): Promise { return authenticate(host, { sock: stream }); } +/** The password for `host`: resolved from its 1Password reference if it has one, + * else the stored one. Resolved before connecting — a Windows Hello or Touch ID + * prompt can take longer than the connect timeout allows mid-handshake. */ +async function hostPassword(host: Host): Promise { + if (host.passwordRef === undefined) return host.password; + return readSecretCached(host.passwordRef); +} + /** The auth methods to offer `host`, in priority order: agent → explicit * identity file → default key files → password. Unreadable key files are * left out; an unparseable (e.g. passphrase-protected) one is skipped by - * `ssh2` itself. */ -async function authMethods(host: Host): Promise { + * `ssh2` itself. A 1Password reference that can't be read drops the password + * and says why in `problems`, so the keys still get their turn. */ +async function authMethods(host: Host, problems: string[]): Promise { const username = host.user; const methods: AnyAuthMethod[] = []; @@ -394,7 +417,13 @@ async function authMethods(host: Host): Promise { const keys = await Promise.all(keyPaths.map(tryReadKey)); for (const key of keys) if (key !== undefined) methods.push({ type: 'publickey', username, key }); - if (host.password !== undefined) methods.push({ type: 'password', username, password: host.password }); + let password: string | undefined; + try { + password = await hostPassword(host); + } catch (e) { + problems.push((e as Error).message); + } + if (password !== undefined) methods.push({ type: 'password', username, password }); return methods; } @@ -419,7 +448,8 @@ async function authenticate(host: Host, extra: Partial): Promise< ...extra }; - const methods = await authMethods(host); + const problems: string[] = []; + const methods = await authMethods(host, problems); if (methods.length > 0) { const attempt = await tryConnect({ ...base, authHandler: methods }); if (attempt.client) return attempt.client; @@ -435,7 +465,8 @@ async function authenticate(host: Host, extra: Partial): Promise< } } - throw new SshAuthError(`SSH authentication failed for ${host.name}`); + const why = problems.length > 0 ? ` (${problems.join('; ')})` : ''; + throw new SshAuthError(`SSH authentication failed for ${host.name}${why}`); } interface ConnectAttempt { @@ -504,9 +535,18 @@ function makeHostVerifier(hostname: string, port: number): NonNullable existsSync(p)); } /** Returns the standard default SSH private key paths, in priority order. */ diff --git a/packages/electron/src/dto.ts b/packages/electron/src/dto.ts index 727680a..6b9dad6 100644 --- a/packages/electron/src/dto.ts +++ b/packages/electron/src/dto.ts @@ -29,6 +29,10 @@ export interface HostDto { monitorPort?: number; defaultPath?: string; startupCommand?: string; + /** A 1Password reference — not a secret itself, so it travels both ways. */ + passwordRef?: string; + /** The port's 1Password reference, likewise. */ + portRef?: string; } /** Inbound host form payload for `save_host`. Always builds a manual `Host`: @@ -48,6 +52,10 @@ export interface HostInputDto { monitorPort?: number; defaultPath?: string; startupCommand?: string; + /** A 1Password reference — not a secret itself, so it travels both ways. */ + passwordRef?: string; + /** The port's 1Password reference, likewise. */ + portRef?: string; } function sourceToDto(source: HostSource): HostSourceDto { @@ -81,7 +89,9 @@ export function hostToDto(host: Host): HostDto { monitoring: monitorModeToDto(host.monitoring), monitorPort: host.monitorPort, defaultPath: host.defaultPath, - startupCommand: host.startupCommand + startupCommand: host.startupCommand, + passwordRef: host.passwordRef, + portRef: host.portRef }; } @@ -103,7 +113,9 @@ export function hostFromInputDto(input: HostInputDto): Host { monitoring: input.monitoring !== undefined ? monitorModeFromDto(input.monitoring) : 'ssh', monitorPort: input.monitorPort, defaultPath: input.defaultPath, - startupCommand: input.startupCommand + startupCommand: input.startupCommand, + passwordRef: input.passwordRef, + portRef: input.portRef || undefined }; } @@ -193,12 +205,25 @@ export function nodeResultToDto(result: NodeResult): NodeResultDto { * silently. */ export type ImportResultDto = ImportResult; +/** Whether the 1Password CLI is installed (`onepassword_status`), and how to get it. */ +export interface OnePasswordStatusDto { + installed: boolean; + version?: string; + command?: string; + docsUrl: string; +} + export interface CommandError { message: string; } -export function toCommandError(err: unknown): CommandError { - return { message: err instanceof Error ? err.message : String(err) }; +/** What an IPC handler throws. An `Error`, not a plain `{ message }`: Electron hands a + * rejected `invoke` only the thrown value's `toString()`, so a plain object reached the + * renderer as "[object Object]" and every error message was lost in the real app. The + * renderer (`bindings.ts` → `call`) strips Electron's "Error invoking remote method" + * wrapping again. */ +export function toCommandError(err: unknown): CommandError & Error { + return new Error(err instanceof Error ? err.message : String(err)); } export type RemoteDesktopProtocolDto = RemoteDesktopProtocol; @@ -217,6 +242,10 @@ export interface RemoteDesktopConnectionDto extends RdpSettings { viewOnly?: boolean; /** SSH host the connection is tunnelled through. */ viaHost?: string; + /** A 1Password reference — not a secret itself, so it travels both ways. */ + passwordRef?: string; + /** The port's 1Password reference, likewise. */ + portRef?: string; } /** Inbound form payload for `save_remote_desktop_connection`. `password` arrives here @@ -234,6 +263,10 @@ export interface RemoteDesktopConnectionInputDto extends RdpSettings { viewOnly?: boolean; /** SSH host the connection is tunnelled through. */ viaHost?: string; + /** A 1Password reference — not a secret itself, so it travels both ways. */ + passwordRef?: string; + /** The port's 1Password reference, likewise. */ + portRef?: string; } /** Credentials typed in the embedded viewer for a profile that doesn't store them; @@ -287,6 +320,8 @@ export function remoteDesktopConnectionToDto(connection: RemoteDesktopConnection domain: connection.domain, viewOnly: connection.viewOnly, viaHost: connection.viaHost, + passwordRef: connection.passwordRef, + portRef: connection.portRef, ...rdpSettingsFrom(connection as unknown as Record) }; } @@ -306,6 +341,8 @@ export function remoteDesktopConnectionFromInputDto(input: RemoteDesktopConnecti domain: input.domain, viewOnly: input.viewOnly, viaHost: input.viaHost, + passwordRef: input.passwordRef || undefined, + portRef: input.portRef || undefined, ...rdpSettingsFrom(input as unknown as Record) }; } diff --git a/packages/electron/src/ipc/keysetup.ts b/packages/electron/src/ipc/keysetup.ts index bf3a714..3ceba56 100644 --- a/packages/electron/src/ipc/keysetup.ts +++ b/packages/electron/src/ipc/keysetup.ts @@ -76,6 +76,8 @@ async function persistKey(hostName: string, keyPath: string, passwordDisabled: b if (passwordDisabled) { host.passwordAuthDisabled = true; host.password = undefined; + // Nothing would accept it any more — and resolving it would prompt 1Password on every connect. + host.passwordRef = undefined; } await saveHosts(hosts); } diff --git a/packages/electron/src/ipc/onePassword.ts b/packages/electron/src/ipc/onePassword.ts new file mode 100644 index 0000000..906b4a2 --- /dev/null +++ b/packages/electron/src/ipc/onePassword.ts @@ -0,0 +1,13 @@ +import type { IpcMain } from 'electron'; + +import { cliVersion, installHint } from '../core/secrets/onePassword.js'; +import type { OnePasswordStatusDto } from '../dto.js'; + +/** `onepassword_status`: whether the 1Password CLI is there, and how to get it. Asked + * by the host and remote desktop forms once a 1Password reference is entered. */ +export function registerOnePasswordIpc(ipcMain: IpcMain): void { + ipcMain.handle('onepassword_status', async (): Promise => { + const version = await cliVersion(); + return { installed: version !== undefined, version, ...installHint(process.platform) }; + }); +} diff --git a/packages/electron/src/ipc/rdp.ts b/packages/electron/src/ipc/rdp.ts index 608e36e..43d7c9d 100644 --- a/packages/electron/src/ipc/rdp.ts +++ b/packages/electron/src/ipc/rdp.ts @@ -2,6 +2,7 @@ import { clipboard, screen, shell, type IpcMain } from 'electron'; import { loadRemoteDesktopConnections, type RdpSettings, type RemoteDesktopConnection } from '../core/config/remoteDesktop.js'; import { fitToWorkArea, launchRdp, pendingCredentialHosts, type LaunchTarget, type RdpLaunchResult } from '../core/rdp/launch.js'; +import { resolveConnection } from '../core/rdp/password.js'; import { openTunnel, tunnelAddress, type RdpTunnel } from '../core/rdp/tunnel.js'; import { removeCredentialsOnQuit, sweepStagedCredentials } from '../core/rdp/windowsCredentials.js'; import { SshSession } from '../core/ssh/session.js'; @@ -95,9 +96,11 @@ export function registerRdpIpc(ipcMain: IpcMain, state: GuiState): void { ipcMain.handle('rdp_launch', async (_event, connectionId: string): Promise => { try { const connections = await loadRemoteDesktopConnections(); - const connection = connections.find((c) => c.id === connectionId); - if (!connection) throw new Error(`unknown remote desktop connection '${connectionId}'`); - if (connection.protocol !== 'rdp') throw new Error(`connection '${connection.name}' is not an RDP connection`); + const saved = connections.find((c) => c.id === connectionId); + if (!saved) throw new Error(`unknown remote desktop connection '${connectionId}'`); + if (saved.protocol !== 'rdp') throw new Error(`connection '${saved.name}' is not an RDP connection`); + // Whatever comes from 1Password — before any tunnel or client starts. + const connection = await resolveConnection(saved); const tunnel = await tunnelFor(state, connection); try { diff --git a/packages/electron/src/ipc/rdpEmbedded.ts b/packages/electron/src/ipc/rdpEmbedded.ts index f89a6ff..8951907 100644 --- a/packages/electron/src/ipc/rdpEmbedded.ts +++ b/packages/electron/src/ipc/rdpEmbedded.ts @@ -2,9 +2,10 @@ import { connect } from 'node:net'; import type { Duplex } from 'node:stream'; import { app, BrowserWindow, dialog, shell, type IpcMain } from 'electron'; -import { loadRemoteDesktopConnections } from '../core/config/remoteDesktop.js'; +import { loadRemoteDesktopConnections, type RemoteDesktopConnection } from '../core/config/remoteDesktop.js'; import { RdpGateway } from '../core/rdp/gateway.js'; import { checkCertificate, forgetCertificate } from '../core/rdp/knownCerts.js'; +import { resolveConnection } from '../core/rdp/password.js'; import { saveReceivedFile } from '../core/rdp/savedFiles.js'; import { SshSession } from '../core/ssh/session.js'; import { toCommandError, type RdpCredentialsDto, type RdpEmbeddedOpenDto, type RdpEmbeddedStatusDto } from '../dto.js'; @@ -43,11 +44,13 @@ export function registerRdpEmbeddedIpc(ipcMain: IpcMain, state: GuiState): void 'rdp_embedded_open', async (_event, connectionId: string, typed?: RdpCredentialsDto): Promise => { try { - const connection = (await loadRemoteDesktopConnections()).find((c) => c.id === connectionId); - if (!connection) throw new Error(`unknown remote desktop connection '${connectionId}'`); - if (connection.protocol !== 'rdp') throw new Error(`connection '${connection.name}' is not an RDP connection`); + const saved = (await loadRemoteDesktopConnections()).find((c) => c.id === connectionId); + if (!saved) throw new Error(`unknown remote desktop connection '${connectionId}'`); + if (saved.protocol !== 'rdp') throw new Error(`connection '${saved.name}' is not an RDP connection`); + // Address, user and password from 1Password where the profile says so. + const connection = await resolveConnection(saved); // A profile without a stored password (or user) asks in the tab; what's typed - // there is used for this connection only. + // there is used for this connection only, and wins over the profile's. const username = typed?.username || connection.username; const password = typed?.password || connection.password; const domain = typed ? typed.domain || undefined : connection.domain; @@ -68,8 +71,8 @@ export function registerRdpEmbeddedIpc(ipcMain: IpcMain, state: GuiState): void session.ssh = ssh; } - // Certificates are remembered per target as the SSH host (if any) sees it. - const certKey = `${connection.viaHost ? `${connection.viaHost}>` : ''}${connection.hostname}:${connection.port}`; + // Keyed by the saved address, so a 1Password reference keeps its certificate. + const certKey = certificateKey(saved); const token = gateway.register({ host: connection.hostname, port: connection.port, @@ -151,13 +154,19 @@ export function registerRdpEmbeddedIpc(ipcMain: IpcMain, state: GuiState): void try { const connection = (await loadRemoteDesktopConnections()).find((c) => c.id === connectionId); if (!connection) throw new Error(`unknown remote desktop connection '${connectionId}'`); - await forgetCertificate(`${connection.viaHost ? `${connection.viaHost}>` : ''}${connection.hostname}:${connection.port}`); + await forgetCertificate(certificateKey(connection)); } catch (err) { throw toCommandError(err); } }); } +/** Where a profile's certificate is remembered: its target as the SSH host (if any) + * sees it, as saved. */ +function certificateKey(connection: RemoteDesktopConnection): string { + return `${connection.viaHost ? `${connection.viaHost}>` : ''}${connection.hostname}:${connection.portRef ?? connection.port}`; +} + function closeSession(token: string): void { gateway.revoke(token); open.get(token)?.ssh?.disconnect(); diff --git a/packages/electron/src/ipc/sftp.ts b/packages/electron/src/ipc/sftp.ts index 2fd041d..98faefe 100644 --- a/packages/electron/src/ipc/sftp.ts +++ b/packages/electron/src/ipc/sftp.ts @@ -1,6 +1,7 @@ import type { IpcMain } from 'electron'; import { listLocalDir, previewLocalFile, readLocalFile, writeLocalFile, SftpManager } from '../core/ssh/sftp.js'; +import { resolveReference } from '../core/secrets/onePassword.js'; import { toCommandError } from '../dto.js'; import type { GuiState } from '../state/guiState.js'; @@ -30,7 +31,19 @@ export function registerSftpIpc(ipcMain: IpcMain, state: GuiState): void { } }); - ipcMain.handle('sftp_list', (_event, sessionId: number, path: string) => { + // Where the browser opens: the host's default path, read from 1Password when it is + // a reference; null when it has none. + ipcMain.handle('sftp_default_path', async (_event, hostName: string): Promise => { + const host = state.hostByName(hostName); + if (host === undefined) throw toCommandError(new Error(`unknown host '${hostName}'`)); + try { + return host.defaultPath ? await resolveReference(host.defaultPath) : null; + } catch (err) { + throw toCommandError(err); + } + }); + + ipcMain.handle('sftp_list',(_event, sessionId: number, path: string) => { const manager = state.getSftp(sessionId); if (manager === undefined) return; manager diff --git a/packages/electron/src/main.ts b/packages/electron/src/main.ts index efe8288..ad91f52 100644 --- a/packages/electron/src/main.ts +++ b/packages/electron/src/main.ts @@ -5,6 +5,7 @@ import { APP_ORIGIN, registerAppProtocolHandler, registerAppScheme } from './app import { installApplicationMenu } from './applicationMenu.js'; import { registerAutomationsIpc } from './ipc/automations.js'; import { registerHostsIpc } from './ipc/hosts.js'; +import { registerOnePasswordIpc } from './ipc/onePassword.js'; import { registerKeySetupIpc } from './ipc/keysetup.js'; import { cleanUpRdpOnQuit, registerRdpIpc } from './ipc/rdp.js'; import { closeEmbeddedRdp, registerRdpEmbeddedIpc } from './ipc/rdpEmbedded.js'; @@ -130,6 +131,7 @@ app.whenReady().then(async () => { registerRemoteDesktopIpc(ipcMain); registerRdpIpc(ipcMain, state); registerRdpEmbeddedIpc(ipcMain, state); + registerOnePasswordIpc(ipcMain); // Pre-load the shared host config so the first `list_hosts` paints // immediately, before the renderer's own `reload_hosts` call. A load diff --git a/packages/ui/e2e/hosts.spec.ts b/packages/ui/e2e/hosts.spec.ts index 555f0f2..2a34cd2 100644 --- a/packages/ui/e2e/hosts.spec.ts +++ b/packages/ui/e2e/hosts.spec.ts @@ -85,7 +85,7 @@ test('adds a host and it appears as a card', async ({ page }) => { await editor.getByLabel('Name', { exact: true }).fill('db-1'); await editor.getByLabel('Hostname / IP').fill('db-1.example.com'); - await editor.getByLabel('User').fill('postgres'); + await editor.getByLabel('User', { exact: true }).fill('postgres'); await editor.getByRole('button', { name: 'Add host' }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); @@ -102,13 +102,13 @@ test('a default path set on add is there again when the host is reopened for edi let editor = page.getByRole('dialog', { name: 'Add host' }); await editor.getByLabel('Name', { exact: true }).fill('db-1'); await editor.getByLabel('Hostname / IP').fill('db-1.example.com'); - await editor.getByLabel('Default path').fill('/var/lib/postgresql'); + await editor.getByLabel('Default path', { exact: true }).fill('/var/lib/postgresql'); await editor.getByRole('button', { name: 'Add host' }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); await page.getByRole('button', { name: 'Edit db-1' }).click(); editor = page.getByRole('dialog', { name: 'Edit host' }); - await expect(editor.getByLabel('Default path')).toHaveValue('/var/lib/postgresql'); + await expect(editor.getByLabel('Default path', { exact: true })).toHaveValue('/var/lib/postgresql'); }); test('edits a manual host in place', async ({ page }) => { diff --git a/packages/ui/e2e/remoteDesktop.spec.ts b/packages/ui/e2e/remoteDesktop.spec.ts index 96e8adc..6b985c1 100644 --- a/packages/ui/e2e/remoteDesktop.spec.ts +++ b/packages/ui/e2e/remoteDesktop.spec.ts @@ -124,7 +124,7 @@ test('create, edit, connect to, and delete an RDP connection', async ({ page }) // `exact` — a substring match on "Name" would also hit "Hostname / IP" and "Username". await editor.getByLabel('Name', { exact: true }).fill('office-pc'); await editor.getByLabel('Hostname / IP').fill('10.0.0.5'); - await editor.getByLabel('Username').fill('admin'); + await editor.getByLabel('Username', { exact: true }).fill('admin'); await editor.getByRole('button', { name: 'Add connection' }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); @@ -152,6 +152,64 @@ test('create, edit, connect to, and delete an RDP connection', async ({ page }) await expect(page.getByText('No connections yet')).toBeVisible(); }); +test('address, user and password can each come from 1Password, switched per field', async ({ page }) => { + await boot(page); + await page.getByRole('button', { name: 'Switch to Remote Desktop' }).click(); + await page.getByRole('button', { name: 'New connection' }).first().click(); + const editor = page.getByRole('dialog', { name: 'New RDP connection' }); + await editor.getByLabel('Name', { exact: true }).fill('office-pc'); + + // Switched on, a field wants a reference. + await editor.getByRole('button', { name: 'Hostname from 1Password' }).click(); + await expect(editor.getByRole('button', { name: 'Hostname from 1Password' })).toHaveAttribute('aria-pressed', 'true'); + await editor.getByLabel('Hostname / IP').fill('10.0.0.5'); + await editor.getByRole('button', { name: 'Add connection' }).click(); + await expect(editor.getByText('Hostname / IP: 1Password reference must look like op://vault/item/field')).toBeVisible(); + await editor.getByLabel('Hostname / IP').fill('op://Servers/office-pc/hostname'); + + // The password's switch swaps the password box for a reference box. + await editor.getByRole('button', { name: 'Password from 1Password' }).click(); + await expect(editor.getByLabel('Password', { exact: true })).toHaveAttribute('placeholder', 'op://Servers/office-pc/password'); + await editor.getByLabel('Password', { exact: true }).fill('op://Servers/office-pc/password'); + await editor.getByLabel('Username', { exact: true }).fill('admin'); + await editor.getByRole('button', { name: 'Add connection' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + + const saved = await page.evaluate(() => (window as unknown as { __rdpConnections: { connections: Rec[] } }).__rdpConnections.connections[0]); + expect(saved).toMatchObject({ hostname: 'op://Servers/office-pc/hostname', username: 'admin', passwordRef: 'op://Servers/office-pc/password' }); + // The tile names the item instead of the whole reference, and says what comes from 1Password. + await expect(page.getByText('admin@‹office-pc›:3389')).toBeVisible(); + await expect(page.getByTitle('Address and password read from 1Password when connecting')).toBeVisible(); + + // Reopened, the switches are as saved; switching the password back drops its reference. + await page.getByRole('button', { name: 'Edit office-pc' }).click(); + const edit = page.getByRole('dialog', { name: 'Edit RDP connection' }); + await expect(edit.getByRole('button', { name: 'Hostname from 1Password' })).toHaveAttribute('aria-pressed', 'true'); + await expect(edit.getByRole('button', { name: 'Username from 1Password' })).toHaveAttribute('aria-pressed', 'false'); + await edit.getByRole('button', { name: 'Password from 1Password' }).click(); + await edit.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + const resaved = await page.evaluate(() => (window as unknown as { __rdpConnections: { connections: Rec[] } }).__rdpConnections.connections[0]); + expect(resaved.passwordRef).toBeUndefined(); + await expect(page.getByTitle('Address read from 1Password when connecting')).toBeVisible(); + + // Port and domain too; everything from the one item shows as just that item. + await page.getByRole('button', { name: 'Edit office-pc' }).click(); + const again = page.getByRole('dialog', { name: 'Edit RDP connection' }); + await again.getByRole('button', { name: 'Port from 1Password' }).click(); + await again.getByLabel('Port', { exact: true }).fill('op://Servers/office-pc/port'); + await again.getByRole('button', { name: 'Domain from 1Password' }).click(); + await again.getByLabel('Domain', { exact: true }).fill('op://Servers/office-pc/domain'); + await again.getByRole('button', { name: 'Username from 1Password' }).click(); + await again.getByLabel('Username', { exact: true }).fill('op://Servers/office-pc/username'); + await again.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + const all = await page.evaluate(() => (window as unknown as { __rdpConnections: { connections: Rec[] } }).__rdpConnections.connections[0]); + expect(all).toMatchObject({ port: 3389, portRef: 'op://Servers/office-pc/port', domain: 'op://Servers/office-pc/domain' }); + await expect(page.getByText('‹office-pc›', { exact: true })).toBeVisible(); + await expect(page.getByTitle('Address, port, user and domain read from 1Password when connecting')).toBeVisible(); +}); + test('a connection through an SSH host, with display and device settings', async ({ page }) => { await boot(page, { launch: { notice: 'Windows already has a saved password for this host, so Remote Desktop uses that one instead of the one stored here.' } diff --git a/packages/ui/src/lib/bindings.ts b/packages/ui/src/lib/bindings.ts index de25824..ce1aa20 100644 --- a/packages/ui/src/lib/bindings.ts +++ b/packages/ui/src/lib/bindings.ts @@ -48,6 +48,9 @@ export const commands = { async sftpOpen(hostName: string): Promise> { return call('sftp_open', hostName); }, + async sftpDefaultPath(hostName: string): Promise> { + return call('sftp_default_path', hostName); + }, async sftpList(sessionId: number, path: string): Promise> { return call('sftp_list', sessionId, path); }, @@ -165,6 +168,9 @@ export const commands = { async rdpEmbeddedClose(token: string): Promise> { return call('rdp_embedded_close', token); }, + async onePasswordStatus(): Promise> { + return call('onepassword_status'); + }, async rdpForgetCertificate(connectionId: string): Promise> { return call('rdp_forget_certificate', connectionId); }, @@ -296,6 +302,8 @@ export type AutomationNodeResult = NodeResultDto & { automationName: string }; /** A node started executing. */ export type AutomationNodeStarted = { automationName: string; nodeId: string; label: string }; export type CommandError = { message: string }; +/** Whether the 1Password CLI is installed, and how to get it. */ +export type OnePasswordStatusDto = { installed: boolean; version?: string | null; command?: string | null; docsUrl: string }; /** Live connection state for a host. Internally tagged so the frontend * consumes a discriminated union keyed on `kind`. */ export type ConnectionStatusDto = @@ -353,6 +361,10 @@ export type HostDto = { monitorPort?: number | null; defaultPath?: string | null; startupCommand?: string | null; + /** A 1Password secret reference the password is read from at connect time. */ + passwordRef?: string | null; + /** A 1Password reference the port is read from at connect time. */ + portRef?: string | null; }; /** Inbound host form payload for `save_host`. */ export type HostInputDto = { @@ -369,6 +381,10 @@ export type HostInputDto = { monitorPort?: number | null; defaultPath?: string | null; startupCommand?: string | null; + /** A 1Password secret reference the password is read from at connect time. */ + passwordRef?: string | null; + /** A 1Password reference the port is read from at connect time. */ + portRef?: string | null; }; /** Host origin. */ export type HostSourceDto = 'sshConfig' | 'manual'; @@ -475,6 +491,10 @@ export type RemoteDesktopConnectionDto = RdpSettingsDto & { viewOnly?: boolean | null; /** Name of the SSH host the connection is tunnelled through. */ viaHost?: string | null; + /** A 1Password reference the password is read from at connect time. */ + passwordRef?: string | null; + /** A 1Password reference the port is read from at connect time. */ + portRef?: string | null; }; /** Inbound form payload for `save_remote_desktop_connection`. Omitting `password` * means "keep the stored value" on an edit. */ @@ -490,6 +510,10 @@ export type RemoteDesktopConnectionInputDto = RdpSettingsDto & { viewOnly?: boolean | null; /** Name of the SSH host the connection is tunnelled through. */ viaHost?: string | null; + /** A 1Password reference the password is read from at connect time. */ + passwordRef?: string | null; + /** A 1Password reference the port is read from at connect time. */ + portRef?: string | null; }; /** Only `'rdp'` is reachable from the UI for now — `'vnc'` exists so a later pass is * additive, not a migration. */ @@ -569,11 +593,28 @@ async function invoke(channel: string, ...args: unknown[]): Promise { return bridge().invoke(channel, ...args); } +/** Electron rejects a failed `invoke` with an Error whose message wraps the handler's: + * "Error invoking remote method 'x': Error: ". */ +const REMOTE_ERROR = /^Error invoking remote method '[^']*': (?:[A-Za-z]*Error: )?([\s\S]*)$/; + +/** The command error inside a rejected `invoke`, or undefined if it isn't one (a + * missing bridge, say — a bug, not a command failing). */ +export function commandErrorFrom(e: unknown): CommandError | undefined { + if (e instanceof Error) { + const m = REMOTE_ERROR.exec(e.message); + return m ? { message: m[1] } : undefined; + } + // The e2e stubs reject with the plain `{ message }` object itself. + if (e && typeof e === 'object' && typeof (e as CommandError).message === 'string') return e as CommandError; + return undefined; +} + async function call(channel: string, ...args: unknown[]): Promise> { try { return { status: 'ok', data: (await invoke(channel, ...args)) as T }; } catch (e) { - if (e instanceof Error) throw e; - return { status: 'error', error: e as CommandError }; + const error = commandErrorFrom(e); + if (!error) throw e; + return { status: 'error', error }; } } diff --git a/packages/ui/src/lib/components/CommandPalette.svelte b/packages/ui/src/lib/components/CommandPalette.svelte index 5bc5ee7..32ccc61 100644 --- a/packages/ui/src/lib/components/CommandPalette.svelte +++ b/packages/ui/src/lib/components/CommandPalette.svelte @@ -13,6 +13,7 @@ import { activeEntity } from '$lib/stores/activeEntity'; import { spawnSession } from '$lib/stores/navigation'; import { streamerMode, displayHostname } from '$lib/stores/streamer'; + import { displayReference } from '$lib/screens/onePasswordRef'; import { isPaletteChord } from '$lib/stores/ui'; let inputEl = $state(); @@ -225,7 +226,7 @@ {item.host.name} - {item.host.user}@{displayHostname(item.host.hostname, $streamerMode)} + {displayReference(item.host.user)}@{displayHostname(item.host.hostname, $streamerMode)} {:else if item.kind === 'snippet'} diff --git a/packages/ui/src/lib/components/OnePasswordCliHint.svelte b/packages/ui/src/lib/components/OnePasswordCliHint.svelte new file mode 100644 index 0000000..6dc3ace --- /dev/null +++ b/packages/ui/src/lib/components/OnePasswordCliHint.svelte @@ -0,0 +1,87 @@ + + +{#if status && !status.installed} +
+

The 1Password CLI isn't installed

+

+ The app reads the password through it.{#if status.command}{' '}Install it with:{/if} +

+ {#if status.command} +
+ {status.command} + +
+ {/if} +

+ Then turn on Settings → Developer → Integrate with 1Password CLI in the 1Password + app and check again. +

+
+ + +
+
+{:else if status?.installed} +

+ + 1Password CLI {status.version ?? ''} found +

+{/if} diff --git a/packages/ui/src/lib/components/OnePasswordToggle.svelte b/packages/ui/src/lib/components/OnePasswordToggle.svelte new file mode 100644 index 0000000..10f28e3 --- /dev/null +++ b/packages/ui/src/lib/components/OnePasswordToggle.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/ui/src/lib/ipc/commandError.test.ts b/packages/ui/src/lib/ipc/commandError.test.ts new file mode 100644 index 0000000..fc75a07 --- /dev/null +++ b/packages/ui/src/lib/ipc/commandError.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { commandErrorFrom } from '$lib/bindings'; + +describe('commandErrorFrom', () => { + it("unwraps the handler's message from Electron's rejected invoke", () => { + const e = new Error("Error invoking remote method 'rdp_embedded_open': Error: 1Password: could not read secret"); + expect(commandErrorFrom(e)).toEqual({ message: '1Password: could not read secret' }); + }); + + it('keeps messages over several lines', () => { + const e = new Error("Error invoking remote method 'x': Error: first\nsecond"); + expect(commandErrorFrom(e)).toEqual({ message: 'first\nsecond' }); + }); + + it('takes the plain { message } the test stubs reject with', () => { + expect(commandErrorFrom({ message: 'stub said no' })).toEqual({ message: 'stub said no' }); + }); + + it('leaves other errors to be thrown (a missing bridge is a bug, not a failed command)', () => { + expect(commandErrorFrom(new Error('bsshClient is not defined'))).toBeUndefined(); + expect(commandErrorFrom('nope')).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/lib/ipc/commands.ts b/packages/ui/src/lib/ipc/commands.ts index 0c1d61c..d4d9dcf 100644 --- a/packages/ui/src/lib/ipc/commands.ts +++ b/packages/ui/src/lib/ipc/commands.ts @@ -9,6 +9,7 @@ import type { HostDto, HostInputDto, ImportResultDto, + OnePasswordStatusDto, RdpCredentialsDto, RdpEmbeddedOpenDto, RdpEmbeddedStatusDto, @@ -84,6 +85,13 @@ export async function sftpOpen(hostName: string): Promise { } /** List a remote directory; the result arrives as `sftp-dir-listed`. */ +/** The host's default path, read from 1Password when it is a reference; null if none. */ +export async function sftpDefaultPath(hostName: string): Promise { + const res = await commands.sftpDefaultPath(hostName); + if (res.status === 'error') throw new Error(res.error.message); + return res.data; +} + export async function sftpList(sessionId: number, path: string): Promise { const res = await commands.sftpList(sessionId, path); if (res.status === 'error') throw new Error(res.error.message); @@ -377,6 +385,13 @@ export async function rdpShowSaved(path: string): Promise { if (res.status === 'error') throw new Error(res.error.message); } +/** Whether the 1Password CLI is installed, and how to install it if not. */ +export async function onePasswordStatus(): Promise { + const res = await commands.onePasswordStatus(); + if (res.status === 'error') throw new Error(res.error.message); + return res.data; +} + /** Forgets the remembered certificate of a connection's server (trust on first use). */ export async function rdpForgetCertificate(connectionId: string): Promise { const res = await commands.rdpForgetCertificate(connectionId); diff --git a/packages/ui/src/lib/screens/Dashboard.svelte b/packages/ui/src/lib/screens/Dashboard.svelte index bbba801..0468292 100644 --- a/packages/ui/src/lib/screens/Dashboard.svelte +++ b/packages/ui/src/lib/screens/Dashboard.svelte @@ -12,6 +12,7 @@ import { serverCards, filterHosts, QUICK_ACTIONS } from './serverCard'; import { spawnSession } from '$lib/stores/navigation'; import { streamerMode, displayHostname } from '$lib/stores/streamer'; + import { addressLine } from './onePasswordRef'; import { hosts } from '$lib/stores/hosts'; import { lastError } from '$lib/stores/notifications'; import { saveHost, deleteHost, reloadHosts, startKeySetup, refreshMetrics } from '$lib/ipc/commands'; @@ -243,8 +244,7 @@ {/if}
- {card.host.user}@{displayHostname(card.host.hostname, $streamerMode)}:{card.host - .port} + {addressLine(card.host, displayHostname(card.host.hostname, $streamerMode))}
diff --git a/packages/ui/src/lib/screens/HostEditor.svelte b/packages/ui/src/lib/screens/HostEditor.svelte index cdffd1e..807d961 100644 --- a/packages/ui/src/lib/screens/HostEditor.svelte +++ b/packages/ui/src/lib/screens/HostEditor.svelte @@ -7,6 +7,8 @@ import type { HostInputDto } from '$lib/bindings'; import { Button } from '$lib/theme'; import Modal from '$lib/components/Modal.svelte'; + import OnePasswordCliHint from '$lib/components/OnePasswordCliHint.svelte'; + import OnePasswordToggle from '$lib/components/OnePasswordToggle.svelte'; import Select from '$lib/components/Select.svelte'; import { formToInput, type HostFormFields } from './hostForm'; @@ -63,6 +65,7 @@ const secretHint = $derived(mode === 'edit' ? 'Leave blank to keep the current value' : undefined); const label = 'block space-y-1 text-xs font-medium text-muted'; + const labelRow = 'flex items-center justify-between gap-2'; const field = 'w-full rounded-lg bg-surface-inset px-3 py-2 text-sm text-fg outline-none ' + 'focus-visible:ring-2 focus-visible:ring-focus placeholder:text-faint'; @@ -100,20 +103,53 @@ /> - +
+
+ + +
+ +
- - +
+
+ + +
+ +
+
+
+ + +
+ {#if fields.portFrom1P} + + {:else} + + {/if} +
- +
+
+ + +
+ {#if fields.passwordFrom1P} + + {:else} + + {/if} +
-
- - +
+
+ + +
+ +
+
+
+ + +
+ {#if fields.portFrom1P} + + {:else} + + {/if} +