From b0250ffea35423e6f5a7ce3fc78b81855e99fb89 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Wed, 8 Apr 2026 22:21:21 +0300 Subject: [PATCH 1/2] fix: improve pairing flow DX based on first-time user feedback Handle "device already in allow list" gracefully in invite (remove stale entry + re-add instead of crashing). Surface amesh provision in listen, init, and README for non-interactive pairing. Add trust model legend to amesh list, actionable error messages for handshake timeouts and expired codes, and pairing troubleshooting section in docs/guide.md. --- README.md | 2 ++ docs/guide.md | 20 ++++++++++++++++ packages/cli/src/commands/grant.ts | 6 ++++- packages/cli/src/commands/init.ts | 5 ++-- packages/cli/src/commands/invite.ts | 36 +++++++++++++++++++++++++---- packages/cli/src/commands/list.ts | 3 +++ packages/cli/src/commands/listen.ts | 26 ++++++++++++++++----- 7 files changed, 84 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b96abb8..c00fc19 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ amesh invite 482916 Trust is one-way: the controller can authenticate to the target, but not the reverse. +> **Remote device?** If you can't run interactive commands on the target, use `amesh provision` to generate a bootstrap token instead. See the [Integration Guide](./docs/integration-guide.md#pairing-remote-machines). + ### 3. Sign requests (2 lines) ```typescript diff --git a/docs/guide.md b/docs/guide.md index d989c95..05b2f80 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -278,6 +278,26 @@ cd packages/relay bun test ``` +### Pairing Troubleshooting + +**"Device already in allow list"** +The device was previously paired. The CLI will automatically update the existing entry with fresh handshake data. If you need to start clean, run `amesh revoke ` first. + +**Pairing code expired** +Codes expire after 60 seconds. Run `amesh listen` again to generate a new code. + +**SAS code mismatch** +The 6-digit verification code didn't match. This could indicate a MITM attack, or simply that the wrong code was entered. No changes were made — run `amesh listen` again to retry. + +**One-sided trust (paired on one side but not the other)** +This can happen if one side crashed mid-pairing. Run `amesh list` on both machines. Revoke the stale entry with `amesh revoke `, then re-pair. + +**Can't run interactive commands on the target?** +Use `amesh provision` on the controller to generate a bootstrap token. Set `AMESH_BOOTSTRAP_TOKEN` on the target — pairing happens automatically. See [Integration Guide — Pairing Remote Machines](./integration-guide.md#pairing-remote-machines). + +**Timed out waiting for the other device** +Both devices must be running and connected to the same relay. The default relay is `wss://relay.authmesh.dev/ws`. Use `--relay` to override on both sides. + --- ## 8. Start the Relay Server diff --git a/packages/cli/src/commands/grant.ts b/packages/cli/src/commands/grant.ts index cea5198..0e42b41 100644 --- a/packages/cli/src/commands/grant.ts +++ b/packages/cli/src/commands/grant.ts @@ -34,7 +34,11 @@ export default class Grant extends Command { const data = await allowList.read(); const device = data.devices.find((d) => d.deviceId === args.deviceId); if (!device) { - this.error(`Device ${args.deviceId} not found in allow list.`); + this.error( + `Device ${args.deviceId} not found in allow list.\n` + + 'Run `amesh list` to see paired devices.\n' + + 'Note: grant runs on the target — you\'re granting a controller permission to access this device.', + ); } await allowList.updatePermissions(args.deviceId, { shell: flags.shell }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6d27b99..8840548 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -150,7 +150,8 @@ export default class Init extends Command { this.log(''); this.log('Next steps:'); - this.log(' Target: run `amesh listen`, then `amesh invite` from your controller'); - this.log(' Controller: run `amesh listen` on a target first, then `amesh invite` here'); + this.log(' This is the server: run `amesh listen`, then `amesh invite ` from your laptop'); + this.log(' This is your laptop: run `amesh listen` on the server first, then `amesh invite ` here'); + this.log(' Remote / scripted: run `amesh provision` on the controller for non-interactive pairing'); } } diff --git a/packages/cli/src/commands/invite.ts b/packages/cli/src/commands/invite.ts index a0c4fdc..c673055 100644 --- a/packages/cli/src/commands/invite.ts +++ b/packages/cli/src/commands/invite.ts @@ -51,7 +51,20 @@ export default class Invite extends Command { signFn, ); } catch (err) { - this.error(`Handshake failed: ${(err as Error).message}`); + const msg = (err as Error).message; + if (msg.includes('Timeout')) { + this.error( + 'Timed out waiting for the target device.\n' + + 'Make sure `amesh listen` is running on the target and both devices use the same relay.', + ); + } else if (msg.includes('otc_not_found') || msg.includes('otc_expired')) { + this.error( + 'Pairing code not found or expired.\n' + + 'Run `amesh listen` again on the target for a fresh code.', + ); + } else { + this.error(`Handshake failed: ${msg}`); + } } this.log(' Peer found.'); @@ -65,14 +78,27 @@ export default class Invite extends Command { this.log(' └──────────────────────────────────┘'); this.log(''); - await allowList.addDevice({ + const newDevice = { deviceId: generateDeviceId(result.peerPublicKey), publicKey: Buffer.from(result.peerPublicKey).toString('base64'), friendlyName: result.peerFriendlyName, addedAt: new Date().toISOString(), - addedBy: 'handshake', - role: 'target', - }); + addedBy: 'handshake' as const, + role: 'target' as const, + }; + + try { + await allowList.addDevice(newDevice); + } catch (err) { + if ((err as Error).message.includes('already in allow list')) { + this.log(` "${result.peerFriendlyName}" is already in your allow list.`); + this.log(' Updating with fresh handshake data...'); + await allowList.removeDevice(newDevice.deviceId); + await allowList.addDevice(newDevice); + } else { + throw err; + } + } this.log(''); this.log(` "${result.peerFriendlyName}" added as target.`); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index cb3ce1c..1a352d1 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -52,6 +52,9 @@ export default class List extends Command { ); } this.log(' ' + '─'.repeat(55)); + this.log(''); + this.log(' [controller] = can authenticate TO this device'); + this.log(' [target] = this device can authenticate TO it'); } this.log(''); diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index 13f057d..b02f8aa 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -37,6 +37,9 @@ export default class Listen extends Command { this.log(''); this.log(' Share this code with your Controller device.'); this.log(''); + this.log(' Tip: Can\'t run interactive commands? Use `amesh provision` instead.'); + this.log(' Run `amesh provision --help` on the controller for details.'); + this.log(''); const signFn = async (message: Uint8Array) => { return keyStore.sign(keyAlias, message); @@ -52,23 +55,34 @@ export default class Listen extends Command { signFn, ); } catch (err) { - this.error(`Handshake failed: ${(err as Error).message}`); + const msg = (err as Error).message; + if (msg.includes('Timeout')) { + this.error( + 'Timed out waiting for a controller to connect.\n' + + 'Make sure the controller runs `amesh invite ` within 60 seconds.', + ); + } else { + this.error(`Handshake failed: ${msg}`); + } } this.log(' Controller connected.'); this.log(' Ephemeral P-256 ECDH tunnel established.'); this.log(' Keys exchanged and verified.'); this.log(''); - this.log(' ┌──────────────────────────────────┐'); - this.log(' │ Enter the 6-digit code shown │'); - this.log(" │ on the Controller's screen. │"); - this.log(' └──────────────────────────────────┘'); + this.log(' ┌────────────────────────────────────────────┐'); + this.log(' │ Enter the 6-digit code shown on the │'); + this.log(" │ Controller's screen to complete pairing. │"); + this.log(' │ │'); + this.log(' │ Press Ctrl+C to cancel without changes. │'); + this.log(' └────────────────────────────────────────────┘'); this.log(''); const entered = await this.prompt(' Verification code: '); if (!verifySAS(entered.trim(), result.sas)) { this.log(''); - this.log(' Code mismatch — possible MITM. Pairing aborted.'); + this.log(' Code mismatch — possible MITM attack. Pairing aborted.'); + this.log(' No changes were made. Run `amesh listen` again to retry.'); return; } From 0f64f79aa5dd16a80a1061274895b09dd217c613 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Wed, 8 Apr 2026 22:22:11 +0300 Subject: [PATCH 2/2] style: format grant, init, listen commands --- packages/cli/src/commands/grant.ts | 2 +- packages/cli/src/commands/init.ts | 12 +++++++++--- packages/cli/src/commands/listen.ts | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/grant.ts b/packages/cli/src/commands/grant.ts index 0e42b41..409deb7 100644 --- a/packages/cli/src/commands/grant.ts +++ b/packages/cli/src/commands/grant.ts @@ -37,7 +37,7 @@ export default class Grant extends Command { this.error( `Device ${args.deviceId} not found in allow list.\n` + 'Run `amesh list` to see paired devices.\n' + - 'Note: grant runs on the target — you\'re granting a controller permission to access this device.', + "Note: grant runs on the target — you're granting a controller permission to access this device.", ); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8840548..1aafb88 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -150,8 +150,14 @@ export default class Init extends Command { this.log(''); this.log('Next steps:'); - this.log(' This is the server: run `amesh listen`, then `amesh invite ` from your laptop'); - this.log(' This is your laptop: run `amesh listen` on the server first, then `amesh invite ` here'); - this.log(' Remote / scripted: run `amesh provision` on the controller for non-interactive pairing'); + this.log( + ' This is the server: run `amesh listen`, then `amesh invite ` from your laptop', + ); + this.log( + ' This is your laptop: run `amesh listen` on the server first, then `amesh invite ` here', + ); + this.log( + ' Remote / scripted: run `amesh provision` on the controller for non-interactive pairing', + ); } } diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index b02f8aa..5de66f9 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -37,7 +37,7 @@ export default class Listen extends Command { this.log(''); this.log(' Share this code with your Controller device.'); this.log(''); - this.log(' Tip: Can\'t run interactive commands? Use `amesh provision` instead.'); + this.log(" Tip: Can't run interactive commands? Use `amesh provision` instead."); this.log(' Run `amesh provision --help` on the controller for details.'); this.log('');