Relay pairing: prove direct + iOS deeplink smoke

This commit is contained in:
Mohamed Boudra
2026-02-05 01:26:39 +07:00
parent 9b8f1e348d
commit dc5336660b
11 changed files with 121 additions and 27 deletions
+39
View File
@@ -0,0 +1,39 @@
appId: com.moboudra.paseo
---
- runFlow:
when:
visible: "Open in.*Paseo.*\\?"
commands:
- tapOn: "Open"
- tapOn:
text: "Open"
optional: true
- runFlow:
when:
visible: "Development servers"
commands:
- tapOn:
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
optional: true
- tapOn:
text: "Enter URL manually"
optional: true
- runFlow:
when:
visible: "Continue"
commands:
- tapOn: "Continue"
- runFlow:
when:
visible: "Go home"
commands:
- tapOn:
point: "50%,20%"
- extendedWaitUntil:
visible: ".*Online.*"
timeout: 90000
@@ -0,0 +1,27 @@
# NOTE: Maestro requires a config section for every flow file, even when used
# via `runFlow`. Keep this aligned with the dev build application id.
appId: com.moboudra.paseo
---
# Reusable flow to handle Expo dev client screens after launch.
# Unlike `flows/launch.yaml`, this does not assert any particular app route.
# Handle dev launcher - tap localhost server (dev builds only)
- tapOn:
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
optional: true
# Dismiss dev menu intro modal
- runFlow:
when:
visible: "Continue"
commands:
- tapOn: "Continue"
# Dismiss dev menu if it opens
- runFlow:
when:
visible: "Go home"
commands:
- tapOn:
point: "50%,20%"
+1 -1
View File
@@ -1,6 +1,6 @@
# NOTE: Maestro requires a config section for every flow file, even when used
# via `runFlow`. Keep this aligned with the dev build application id.
appId: com.moboudra.paseo.dev
appId: com.moboudra.paseo
---
# Reusable flow to handle dev client screens after launchApp
# Works with both release builds (no-ops) and dev builds
+6
View File
@@ -0,0 +1,6 @@
appId: com.moboudra.paseo
---
- launchApp:
clearState: true
- runFlow: flows/dev-client.yaml
+4 -4
View File
@@ -1,4 +1,4 @@
appId: com.moboudra.paseo.dev
appId: com.moboudra.paseo
---
- launchApp:
clearState: true
@@ -6,13 +6,13 @@ appId: com.moboudra.paseo.dev
- tapOn: "Paste pairing link"
- tapOn:
text: "https://app.paseo.sh/#offer=..."
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn: "Pair"
- assertVisible: "New Agent"
- extendedWaitUntil:
visible: "New agent"
timeout: 30000
# Confirm we actually connected (relay is the only available connection).
- assertVisible: "Online"
+3 -4
View File
@@ -1,15 +1,14 @@
appId: com.moboudra.paseo.dev
appId: com.moboudra.paseo
---
- launchApp:
clearState: true
- runFlow: flows/launch.yaml
- tapOn: "Paste pairing link"
- tapOn:
text: "https://app.paseo.sh/#offer=..."
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn: "Pair"
- tapOn:
point: "75%,36%"
- extendedWaitUntil:
visible: "New agent"
+14 -5
View File
@@ -297,15 +297,24 @@ function OfferLinkListener({
}: {
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
}) {
const router = useRouter();
useEffect(() => {
let cancelled = false;
const handleUrl = (url: string | null) => {
if (!url) return;
if (!url.includes("#offer=")) return;
void upsertDaemonFromOfferUrl(url).catch((error) => {
if (cancelled) return;
console.warn("[Linking] Failed to import pairing offer", error);
});
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace({ pathname: "/", params: { serverId } });
})
.catch((error) => {
if (cancelled) return;
console.warn("[Linking] Failed to import pairing offer", error);
});
};
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
@@ -318,7 +327,7 @@ function OfferLinkListener({
cancelled = true;
subscription.remove();
};
}, [upsertDaemonFromOfferUrl]);
}, [router, upsertDaemonFromOfferUrl]);
return null;
}
@@ -124,11 +124,14 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
<Text style={styles.label}>Pairing link</Text>
<InputComponent
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
value={offerUrl}
onChangeText={setOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -137,7 +140,15 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
</View>
<View style={styles.actions}>
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving} testID="pair-link-cancel">
<Pressable
style={styles.button}
onPress={handleClose}
disabled={isSaving}
testID="pair-link-cancel"
accessible
accessibilityRole="button"
accessibilityLabel="Cancel"
>
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
<Pressable
@@ -145,6 +156,9 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
onPress={() => void handleSave()}
disabled={isSaving}
testID="pair-link-submit"
accessible
accessibilityRole="button"
accessibilityLabel="Pair"
>
<View style={styles.connectRow}>
<Link size={16} color={theme.colors.palette.white} />
+1 -1
View File
@@ -149,7 +149,7 @@ describe("crypto", () => {
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
// QR code would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
// QR code would contain: { serverId, daemonPubKeyB64, relay: { endpoint } }
// === CLIENT SIDE (scans QR) ===
const clientKeyPair = await generateKeyPair();
+8 -8
View File
@@ -25,18 +25,18 @@ describe("E2E Relay with E2EE", () => {
});
it("full flow: daemon and client exchange encrypted messages through relay", async () => {
const sessionId = "test-session-" + Date.now();
const serverId = "test-session-" + Date.now();
// === DAEMON SIDE ===
// Generate keypair (public key goes in QR)
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
// QR would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
// QR would contain: { serverId, daemonPubKeyB64, relay: { endpoint } }
// Daemon connects to relay as "server" role
const daemonWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server`
);
await new Promise<void>((resolve, reject) => {
@@ -59,7 +59,7 @@ describe("E2E Relay with E2EE", () => {
// Client connects to relay as "client" role
const clientWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client`
);
await new Promise<void>((resolve, reject) => {
@@ -149,7 +149,7 @@ describe("E2E Relay with E2EE", () => {
});
it("relay only sees opaque bytes after handshake", async () => {
const sessionId = "opaque-test-" + Date.now();
const serverId = "opaque-test-" + Date.now();
// Setup keys
const daemonKeyPair = await generateKeyPair();
@@ -172,10 +172,10 @@ describe("E2E Relay with E2EE", () => {
// Connect both
const daemonWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server`
);
const clientWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client`
);
await Promise.all([
@@ -212,7 +212,7 @@ describe("E2E Relay with E2EE", () => {
});
it("wrong key cannot decrypt", async () => {
const sessionId = "wrong-key-test-" + Date.now();
const serverId = "wrong-key-test-" + Date.now();
// Setup - daemon and client with correct keys
const daemonKeyPair = await generateKeyPair();
+3 -3
View File
@@ -35,9 +35,9 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
liveIt("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
await withRetry(
async () => {
const sessionId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=server`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=client`;
const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=client`;
// === Key setup ===
const daemonKeyPair = await generateKeyPair();