yaver-feedback-react-native 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Discovery.js CHANGED
@@ -257,15 +257,21 @@ class YaverDiscovery {
257
257
  */
258
258
  static async discoverViaRelay(convexUrl, authToken, deviceId) {
259
259
  try {
260
- const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
260
+ // /settings returns {ok, settings: {relayUrl, relayPassword, ...}}
261
+ // — `/auth/validate` only returns the user record. Using the
262
+ // wrong endpoint historically meant relayPassword stayed
263
+ // undefined and every relay-routed probe rejected with 401
264
+ // "invalid relay password" (relay/server.go:957).
265
+ const settingsRes = await fetch(`${convexUrl}/settings`, {
261
266
  headers: { Authorization: `Bearer ${authToken}` },
262
267
  });
263
268
  let relayUrl;
264
269
  let relayPassword;
265
270
  if (settingsRes.ok) {
266
271
  const settingsData = await settingsRes.json();
267
- relayUrl = settingsData.relayUrl;
268
- relayPassword = settingsData.relayPassword;
272
+ const inner = settingsData?.settings;
273
+ relayUrl = inner?.relayUrl ?? settingsData?.relayUrl;
274
+ relayPassword = inner?.relayPassword ?? settingsData?.relayPassword;
269
275
  }
270
276
  if (!relayUrl) {
271
277
  const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
@@ -1049,7 +1049,15 @@ const RunnerAuthNativeModal = ({ runner, onClose }) => {
1049
1049
  const [session, setSession] = (0, react_1.useState)(null);
1050
1050
  const [startError, setStartError] = (0, react_1.useState)(null);
1051
1051
  const [copied, setCopied] = (0, react_1.useState)(false);
1052
+ const [pasteCode, setPasteCode] = (0, react_1.useState)('');
1053
+ const [submitting, setSubmitting] = (0, react_1.useState)(false);
1054
+ const [submitError, setSubmitError] = (0, react_1.useState)(null);
1052
1055
  const startedRef = (0, react_1.useRef)(false);
1056
+ // Claude is the only runner that needs the user to paste a verifier
1057
+ // code back from platform.claude.com's callback page; Codex device-
1058
+ // auth and OpenCode (no OAuth at all) bypass this. Mirrors the
1059
+ // requiresPasteBack check in iOS YaverRunnerAuthFlowPane.swift.
1060
+ const needsPasteBack = runner === 'claude' || runner === 'claude-code';
1053
1061
  (0, react_1.useEffect)(() => {
1054
1062
  if (startedRef.current)
1055
1063
  return;
@@ -1164,9 +1172,68 @@ const RunnerAuthNativeModal = ({ runner, onClose }) => {
1164
1172
  </react_native_1.Text>
1165
1173
  </react_native_1.Pressable>
1166
1174
  </react_native_1.View>) : null}
1175
+ {needsPasteBack ? (<react_native_1.View style={{ marginTop: 14 }}>
1176
+ <react_native_1.Text style={runnerAuthModalStyles.codeLabel}>
1177
+ PASTE CODE FROM CLAUDE.COM
1178
+ </react_native_1.Text>
1179
+ <react_native_1.View style={{ flexDirection: 'row', gap: 8, marginTop: 6 }}>
1180
+ <react_native_1.View style={{
1181
+ flex: 1,
1182
+ backgroundColor: 'rgba(148,163,184,0.10)',
1183
+ borderRadius: 10,
1184
+ paddingHorizontal: 10,
1185
+ }}>
1186
+ {/* Lazy-import TextInput so the SDK doesn't pull
1187
+ extra surface from react-native at module load. */}
1188
+ {(() => {
1189
+ const { TextInput } = require('react-native');
1190
+ return (<TextInput value={pasteCode} onChangeText={(t) => {
1191
+ setPasteCode(t);
1192
+ setSubmitError(null);
1193
+ }} placeholder="paste code here" placeholderTextColor="#64748b" autoCapitalize="none" autoCorrect={false} spellCheck={false} style={{ color: '#f1f5f9', fontSize: 14, paddingVertical: 10 }}/>);
1194
+ })()}
1195
+ </react_native_1.View>
1196
+ <react_native_1.Pressable disabled={!pasteCode.trim() || submitting} onPress={async () => {
1197
+ if (!session || !pasteCode.trim())
1198
+ return;
1199
+ setSubmitting(true);
1200
+ setSubmitError(null);
1201
+ try {
1202
+ const next = await YaverFeedback_1.YaverFeedback.submitRunnerBrowserAuthCode(session.id, pasteCode.trim());
1203
+ setSession(next);
1204
+ setPasteCode('');
1205
+ }
1206
+ catch (err) {
1207
+ setSubmitError(err instanceof Error ? err.message : String(err));
1208
+ }
1209
+ finally {
1210
+ setSubmitting(false);
1211
+ }
1212
+ }} style={{
1213
+ paddingHorizontal: 14,
1214
+ justifyContent: 'center',
1215
+ backgroundColor: !pasteCode.trim() || submitting
1216
+ ? 'rgba(124,58,237,0.4)'
1217
+ : '#7c3aed',
1218
+ borderRadius: 10,
1219
+ }}>
1220
+ <react_native_1.Text style={{ color: 'white', fontWeight: '600' }}>
1221
+ {submitting ? '…' : 'Submit'}
1222
+ </react_native_1.Text>
1223
+ </react_native_1.Pressable>
1224
+ </react_native_1.View>
1225
+ {submitError ? (<react_native_1.Text style={{
1226
+ marginTop: 6,
1227
+ color: '#fca5a5',
1228
+ fontSize: 12,
1229
+ }}>
1230
+ {submitError}
1231
+ </react_native_1.Text>) : null}
1232
+ </react_native_1.View>) : null}
1167
1233
  <react_native_1.Text style={runnerAuthModalStyles.phishingHint}>
1168
- Device codes are a common phishing target. Never share this code. This dialog
1169
- turns green automatically once sign-in completes.
1234
+ {needsPasteBack
1235
+ ? 'After authorising on platform.claude.com, copy the code from the callback page and paste it above. Never share this code.'
1236
+ : 'Device codes are a common phishing target. Never share this code. This dialog turns green automatically once sign-in completes.'}
1170
1237
  </react_native_1.Text>
1171
1238
  </react_native_1.View>)}
1172
1239
  </react_native_1.View>
@@ -47,6 +47,12 @@ export declare class P2PClient {
47
47
  startRunnerBrowserAuth(runner: string): Promise<RunnerBrowserAuthSession>;
48
48
  getRunnerBrowserAuthStatus(sessionId: string): Promise<RunnerBrowserAuthSession>;
49
49
  cancelRunnerBrowserAuth(sessionId: string): Promise<void>;
50
+ /** Submit the verifier code Anthropic shows on the callback page so
51
+ * the agent can finalise claude CLI's OAuth handshake. Codex doesn't
52
+ * use this — its device-auth flow auto-resolves via polling — but
53
+ * the SDK still exposes it for symmetry with mobile/src/components/
54
+ * RunnerAuthModal.tsx and the Swift YaverRunnerAuthFlowPane. */
55
+ submitRunnerBrowserAuthCode(sessionId: string, code: string): Promise<RunnerBrowserAuthSession>;
50
56
  capabilitySnapshot(): Promise<CapabilitySnapshot | null>;
51
57
  incidents(opts?: {
52
58
  category?: string;
package/dist/P2PClient.js CHANGED
@@ -155,6 +155,25 @@ class P2PClient {
155
155
  }
156
156
  catch { /* best-effort */ }
157
157
  }
158
+ /** Submit the verifier code Anthropic shows on the callback page so
159
+ * the agent can finalise claude CLI's OAuth handshake. Codex doesn't
160
+ * use this — its device-auth flow auto-resolves via polling — but
161
+ * the SDK still exposes it for symmetry with mobile/src/components/
162
+ * RunnerAuthModal.tsx and the Swift YaverRunnerAuthFlowPane. */
163
+ async submitRunnerBrowserAuthCode(sessionId, code) {
164
+ const url = `${this.baseUrl}/runner-auth/browser/submit-code`;
165
+ const resp = await fetch(url, {
166
+ method: 'POST',
167
+ headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({ id: sessionId, code }),
169
+ });
170
+ if (!resp.ok) {
171
+ const text = await resp.text().catch(() => '');
172
+ throw new Error(`submitRunnerBrowserAuthCode HTTP ${resp.status}: ${text}`);
173
+ }
174
+ const data = await resp.json();
175
+ return data.session;
176
+ }
158
177
  async capabilitySnapshot() {
159
178
  try {
160
179
  const resp = await fetch(`${this.baseUrl}/capabilities/snapshot`, { headers: this.authHeaders() });
@@ -272,11 +291,12 @@ class P2PClient {
272
291
  name: 'voice_note.m4a',
273
292
  });
274
293
  }
294
+ // Use authHeaders() so a relay-routed baseUrl carries
295
+ // X-Relay-Password — without it the relay rejects with 401
296
+ // "invalid relay password" before the agent ever sees the form.
275
297
  const response = await fetch(`${this.baseUrl}/feedback`, {
276
298
  method: 'POST',
277
- headers: {
278
- Authorization: `Bearer ${this.authToken}`,
279
- },
299
+ headers: this.authHeaders(),
280
300
  body: formData,
281
301
  });
282
302
  if (!response.ok) {
@@ -294,10 +314,7 @@ class P2PClient {
294
314
  for await (const event of events) {
295
315
  const response = await fetch(`${this.baseUrl}/feedback/stream`, {
296
316
  method: 'POST',
297
- headers: {
298
- Authorization: `Bearer ${this.authToken}`,
299
- 'Content-Type': 'application/json',
300
- },
317
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
301
318
  body: JSON.stringify(event),
302
319
  });
303
320
  if (!response.ok) {
@@ -316,10 +333,7 @@ class P2PClient {
316
333
  async startBuild(platform) {
317
334
  const response = await fetch(`${this.baseUrl}/builds`, {
318
335
  method: 'POST',
319
- headers: {
320
- Authorization: `Bearer ${this.authToken}`,
321
- 'Content-Type': 'application/json',
322
- },
336
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
323
337
  body: JSON.stringify({ platform }),
324
338
  });
325
339
  if (!response.ok) {
@@ -359,9 +373,7 @@ class P2PClient {
359
373
  });
360
374
  const response = await fetch(`${this.baseUrl}/voice/transcribe`, {
361
375
  method: 'POST',
362
- headers: {
363
- Authorization: `Bearer ${this.authToken}`,
364
- },
376
+ headers: this.authHeaders(),
365
377
  body: formData,
366
378
  });
367
379
  if (!response.ok) {
@@ -401,7 +413,7 @@ class P2PClient {
401
413
  if (mode === 'dev') {
402
414
  const primary = await fetch(`${this.baseUrl}/dev/reload`, {
403
415
  method: 'POST',
404
- headers: { Authorization: `Bearer ${this.authToken}` },
416
+ headers: this.authHeaders(),
405
417
  });
406
418
  if (primary.ok) {
407
419
  const payload = await primary.json().catch(() => ({}));
@@ -432,10 +444,7 @@ class P2PClient {
432
444
  const identity = resolveAppIdentity(opts);
433
445
  const res = await fetch(`${this.baseUrl}/dev/reload-app`, {
434
446
  method: 'POST',
435
- headers: {
436
- Authorization: `Bearer ${this.authToken}`,
437
- 'Content-Type': 'application/json',
438
- },
447
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
439
448
  body: JSON.stringify({
440
449
  mode: 'bundle',
441
450
  ...identity,
@@ -479,10 +488,7 @@ class P2PClient {
479
488
  const identity = resolveAppIdentity(opts);
480
489
  const response = await fetch(`${this.baseUrl}/vibing/execute`, {
481
490
  method: 'POST',
482
- headers: {
483
- Authorization: `Bearer ${this.authToken}`,
484
- 'Content-Type': 'application/json',
485
- },
491
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
486
492
  body: JSON.stringify({
487
493
  prompt,
488
494
  projectPath: identity.projectPath ?? opts?.projectPath ?? '',
@@ -510,7 +516,7 @@ class P2PClient {
510
516
  }
511
517
  const response = await fetch(`${this.baseUrl}/vibing/eligibility?${params.toString()}`, {
512
518
  method: 'GET',
513
- headers: { Authorization: `Bearer ${this.authToken}` },
519
+ headers: this.authHeaders(),
514
520
  });
515
521
  if (!response.ok) {
516
522
  const text = await response.text().catch(() => '');
@@ -527,7 +533,7 @@ class P2PClient {
527
533
  async triggerFix(feedbackId) {
528
534
  const response = await fetch(`${this.baseUrl}/feedback/${encodeURIComponent(feedbackId)}/fix`, {
529
535
  method: 'POST',
530
- headers: { Authorization: `Bearer ${this.authToken}` },
536
+ headers: this.authHeaders(),
531
537
  });
532
538
  if (!response.ok) {
533
539
  const text = await response.text().catch(() => '');
@@ -548,10 +554,7 @@ class P2PClient {
548
554
  async startTestSession() {
549
555
  const response = await fetch(`${this.baseUrl}/test-app/start`, {
550
556
  method: 'POST',
551
- headers: {
552
- Authorization: `Bearer ${this.authToken}`,
553
- 'Content-Type': 'application/json',
554
- },
557
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
555
558
  body: JSON.stringify({ source: 'feedback-sdk' }),
556
559
  });
557
560
  if (!response.ok) {
@@ -564,7 +567,7 @@ class P2PClient {
564
567
  async stopTestSession() {
565
568
  await fetch(`${this.baseUrl}/test-app/stop`, {
566
569
  method: 'POST',
567
- headers: { Authorization: `Bearer ${this.authToken}` },
570
+ headers: this.authHeaders(),
568
571
  });
569
572
  }
570
573
  /** Get the current test session status and list of fixes. */
@@ -580,10 +583,7 @@ class P2PClient {
580
583
  async rotateToken() {
581
584
  const response = await fetch(`${this.baseUrl}/sdk/token/rotate`, {
582
585
  method: 'POST',
583
- headers: {
584
- Authorization: `Bearer ${this.authToken}`,
585
- 'Content-Type': 'application/json',
586
- },
586
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
587
587
  });
588
588
  if (!response.ok) {
589
589
  const text = await response.text().catch(() => '');
@@ -602,7 +602,7 @@ class P2PClient {
602
602
  * for 30s in getFlagsCached().
603
603
  */
604
604
  async flagsEvaluate(userId = 'anonymous') {
605
- const res = await fetch(`${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}`, { headers: { Authorization: `Bearer ${this.authToken}` } });
605
+ const res = await fetch(`${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}`, { headers: this.authHeaders() });
606
606
  if (!res.ok)
607
607
  return {};
608
608
  const data = await res.json();
@@ -610,7 +610,7 @@ class P2PClient {
610
610
  }
611
611
  /** Evaluate a single flag by key — shortcut when you only need one. */
612
612
  async flagsEvaluateOne(key, userId = 'anonymous') {
613
- const res = await fetch(`${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}&flag=${encodeURIComponent(key)}`, { headers: { Authorization: `Bearer ${this.authToken}` } });
613
+ const res = await fetch(`${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}&flag=${encodeURIComponent(key)}`, { headers: this.authHeaders() });
614
614
  if (!res.ok)
615
615
  return undefined;
616
616
  const data = await res.json();
@@ -628,7 +628,7 @@ class P2PClient {
628
628
  if (deviceId)
629
629
  params.set('device', deviceId);
630
630
  const res = await fetch(`${this.baseUrl}/releases/latest?${params.toString()}`, {
631
- headers: { Authorization: `Bearer ${this.authToken}` },
631
+ headers: this.authHeaders(),
632
632
  });
633
633
  if (!res.ok)
634
634
  return null;
@@ -638,7 +638,7 @@ class P2PClient {
638
638
  async releasesDownload(channel, semver) {
639
639
  const params = new URLSearchParams({ channel, semver });
640
640
  const res = await fetch(`${this.baseUrl}/releases/bundle?${params.toString()}`, {
641
- headers: { Authorization: `Bearer ${this.authToken}` },
641
+ headers: this.authHeaders(),
642
642
  });
643
643
  if (!res.ok)
644
644
  return null;
@@ -654,10 +654,7 @@ class P2PClient {
654
654
  try {
655
655
  const res = await fetch(`${this.baseUrl}/analytics/ingest`, {
656
656
  method: 'POST',
657
- headers: {
658
- Authorization: `Bearer ${this.authToken}`,
659
- 'Content-Type': 'application/json',
660
- },
657
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
661
658
  body: JSON.stringify({
662
659
  name,
663
660
  props,
@@ -78,6 +78,10 @@ export declare class YaverFeedback {
78
78
  static startRunnerBrowserAuth(runner: string): Promise<import('./types').RunnerBrowserAuthSession>;
79
79
  static getRunnerBrowserAuthStatus(sessionId: string): Promise<import('./types').RunnerBrowserAuthSession>;
80
80
  static cancelRunnerBrowserAuth(sessionId: string): Promise<void>;
81
+ /** Submit the Claude paste-back verifier so the agent can finalise the
82
+ * OAuth handshake. RunnerAuthModal calls this after the user copies
83
+ * the code from platform.claude.com's callback page. */
84
+ static submitRunnerBrowserAuthCode(sessionId: string, code: string): Promise<import('./types').RunnerBrowserAuthSession>;
81
85
  /**
82
86
  * Sign out: clear cached token + device, tear down the P2P client. The
83
87
  * SDK stays enabled; the next feedback trigger will re-prompt for login.
@@ -135,7 +135,50 @@ let enabled = false;
135
135
  let p2pClient = null;
136
136
  let shakeDetector = null;
137
137
  let p2pAuthToken = null;
138
+ let p2pRelayPassword = '';
138
139
  let reportLaunchInFlight = false;
140
+ /** Resolve the user's relay password by validating their auth token
141
+ * against Convex. Used whenever we (re)build the P2PClient so a
142
+ * relay-routed agentUrl carries a valid X-Relay-Password — without
143
+ * this, every relay-tunneled request rejects with HTTP 401
144
+ * "invalid relay password" (relay/server.go:957).
145
+ *
146
+ * Cached on `p2pRelayPassword` so we only round-trip Convex when the
147
+ * user's auth token actually changes. Returns "" on any failure so
148
+ * direct LAN agentUrls (which need no password) keep working.
149
+ */
150
+ async function resolveRelayPassword(authToken, convexUrl) {
151
+ const trimmed = (authToken || '').trim();
152
+ if (!trimmed) {
153
+ p2pRelayPassword = '';
154
+ return '';
155
+ }
156
+ const url = (convexUrl || config?.convexUrl || auth_1.DEFAULT_CONVEX_SITE_URL).replace(/\/+$/, '');
157
+ try {
158
+ // /settings returns {ok, settings: {relayPassword, relayUrl, ...}}
159
+ // Older accounts may flatten relayPassword to the top — match the
160
+ // tolerance the web shell already uses (route.ts:77).
161
+ const res = await fetch(`${url}/settings`, {
162
+ headers: { Authorization: `Bearer ${trimmed}` },
163
+ });
164
+ if (!res.ok)
165
+ return p2pRelayPassword;
166
+ const data = await res.json().catch(() => ({}));
167
+ const settings = data?.settings;
168
+ const pw = (typeof settings?.relayPassword === 'string' && settings.relayPassword) ||
169
+ (typeof data?.relayPassword === 'string'
170
+ ? data.relayPassword
171
+ : '') ||
172
+ '';
173
+ p2pRelayPassword = pw;
174
+ return pw;
175
+ }
176
+ catch {
177
+ // Network failure on a passive Convex round-trip shouldn't break
178
+ // direct-LAN flows. Fall through with whatever we already cached.
179
+ return p2pRelayPassword;
180
+ }
181
+ }
139
182
  /** Ring buffer of captured errors. */
140
183
  let errorBuffer = [];
141
184
  let maxErrors = 5;
@@ -192,7 +235,8 @@ class YaverFeedback {
192
235
  return;
193
236
  }
194
237
  p2pAuthToken = token;
195
- p2pClient = new P2PClient_1.P2PClient(effectiveUrl, token);
238
+ const rp = await resolveRelayPassword(token);
239
+ p2pClient = new P2PClient_1.P2PClient(effectiveUrl, token, rp);
196
240
  }
197
241
  /**
198
242
  * Initialize the feedback SDK with the given configuration.
@@ -271,7 +315,12 @@ class YaverFeedback {
271
315
  // Create P2P client if we have a URL
272
316
  if (config.agentUrl) {
273
317
  p2pAuthToken = config.authToken ?? null;
274
- p2pClient = new P2PClient_1.P2PClient(config.agentUrl, config.authToken ?? '');
318
+ // Initial construction uses the cached p2pRelayPassword (empty on
319
+ // first init). rebuildP2PClient below resolves the real password
320
+ // from Convex and replaces this client — but only when authToken
321
+ // is set, so set a placeholder header here that won't 401 a
322
+ // direct-LAN url and will be overwritten before any relay hop.
323
+ p2pClient = new P2PClient_1.P2PClient(config.agentUrl, config.authToken ?? '', p2pRelayPassword);
275
324
  if (config.authToken) {
276
325
  void YaverFeedback.rebuildP2PClient(config.agentUrl);
277
326
  }
@@ -571,6 +620,15 @@ class YaverFeedback {
571
620
  return;
572
621
  await p2pClient.cancelRunnerBrowserAuth(sessionId);
573
622
  }
623
+ /** Submit the Claude paste-back verifier so the agent can finalise the
624
+ * OAuth handshake. RunnerAuthModal calls this after the user copies
625
+ * the code from platform.claude.com's callback page. */
626
+ static async submitRunnerBrowserAuthCode(sessionId, code) {
627
+ if (!p2pClient) {
628
+ throw new Error('Not connected to any agent.');
629
+ }
630
+ return p2pClient.submitRunnerBrowserAuthCode(sessionId, code);
631
+ }
574
632
  /**
575
633
  * Sign out: clear cached token + device, tear down the P2P client. The
576
634
  * SDK stays enabled; the next feedback trigger will re-prompt for login.
@@ -891,7 +949,8 @@ class YaverFeedback {
891
949
  });
892
950
  if (result) {
893
951
  config.agentUrl = result.url;
894
- p2pClient = new P2PClient_1.P2PClient(result.url, config.authToken ?? '');
952
+ const rp = await resolveRelayPassword(config.authToken ?? '');
953
+ p2pClient = new P2PClient_1.P2PClient(result.url, config.authToken ?? '', rp);
895
954
  }
896
955
  }
897
956
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Visual feedback SDK for Yaver \u2014 bug reports, screen recording, voice annotations, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/Discovery.ts CHANGED
@@ -283,7 +283,12 @@ export class YaverDiscovery {
283
283
  deviceId: string,
284
284
  ): Promise<DiscoveryResult | null> {
285
285
  try {
286
- const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
286
+ // /settings returns {ok, settings: {relayUrl, relayPassword, ...}}
287
+ // — `/auth/validate` only returns the user record. Using the
288
+ // wrong endpoint historically meant relayPassword stayed
289
+ // undefined and every relay-routed probe rejected with 401
290
+ // "invalid relay password" (relay/server.go:957).
291
+ const settingsRes = await fetch(`${convexUrl}/settings`, {
287
292
  headers: { Authorization: `Bearer ${authToken}` },
288
293
  });
289
294
  let relayUrl: string | undefined;
@@ -291,8 +296,9 @@ export class YaverDiscovery {
291
296
 
292
297
  if (settingsRes.ok) {
293
298
  const settingsData = await settingsRes.json();
294
- relayUrl = settingsData.relayUrl;
295
- relayPassword = settingsData.relayPassword;
299
+ const inner = settingsData?.settings;
300
+ relayUrl = inner?.relayUrl ?? settingsData?.relayUrl;
301
+ relayPassword = inner?.relayPassword ?? settingsData?.relayPassword;
296
302
  }
297
303
 
298
304
  if (!relayUrl) {
@@ -1251,7 +1251,15 @@ const RunnerAuthNativeModal: React.FC<{
1251
1251
  const [session, setSession] = useState<import('./types').RunnerBrowserAuthSession | null>(null);
1252
1252
  const [startError, setStartError] = useState<string | null>(null);
1253
1253
  const [copied, setCopied] = useState(false);
1254
+ const [pasteCode, setPasteCode] = useState('');
1255
+ const [submitting, setSubmitting] = useState(false);
1256
+ const [submitError, setSubmitError] = useState<string | null>(null);
1254
1257
  const startedRef = useRef(false);
1258
+ // Claude is the only runner that needs the user to paste a verifier
1259
+ // code back from platform.claude.com's callback page; Codex device-
1260
+ // auth and OpenCode (no OAuth at all) bypass this. Mirrors the
1261
+ // requiresPasteBack check in iOS YaverRunnerAuthFlowPane.swift.
1262
+ const needsPasteBack = runner === 'claude' || runner === 'claude-code';
1255
1263
 
1256
1264
  useEffect(() => {
1257
1265
  if (startedRef.current) return;
@@ -1380,9 +1388,92 @@ const RunnerAuthNativeModal: React.FC<{
1380
1388
  </Pressable>
1381
1389
  </View>
1382
1390
  ) : null}
1391
+ {needsPasteBack ? (
1392
+ <View style={{ marginTop: 14 }}>
1393
+ <Text style={runnerAuthModalStyles.codeLabel}>
1394
+ PASTE CODE FROM CLAUDE.COM
1395
+ </Text>
1396
+ <View style={{ flexDirection: 'row', gap: 8, marginTop: 6 }}>
1397
+ <View
1398
+ style={{
1399
+ flex: 1,
1400
+ backgroundColor: 'rgba(148,163,184,0.10)',
1401
+ borderRadius: 10,
1402
+ paddingHorizontal: 10,
1403
+ }}
1404
+ >
1405
+ {/* Lazy-import TextInput so the SDK doesn't pull
1406
+ extra surface from react-native at module load. */}
1407
+ {(() => {
1408
+ const { TextInput } = require('react-native');
1409
+ return (
1410
+ <TextInput
1411
+ value={pasteCode}
1412
+ onChangeText={(t: string) => {
1413
+ setPasteCode(t);
1414
+ setSubmitError(null);
1415
+ }}
1416
+ placeholder="paste code here"
1417
+ placeholderTextColor="#64748b"
1418
+ autoCapitalize="none"
1419
+ autoCorrect={false}
1420
+ spellCheck={false}
1421
+ style={{ color: '#f1f5f9', fontSize: 14, paddingVertical: 10 }}
1422
+ />
1423
+ );
1424
+ })()}
1425
+ </View>
1426
+ <Pressable
1427
+ disabled={!pasteCode.trim() || submitting}
1428
+ onPress={async () => {
1429
+ if (!session || !pasteCode.trim()) return;
1430
+ setSubmitting(true);
1431
+ setSubmitError(null);
1432
+ try {
1433
+ const next = await YaverFeedback.submitRunnerBrowserAuthCode(
1434
+ session.id,
1435
+ pasteCode.trim(),
1436
+ );
1437
+ setSession(next);
1438
+ setPasteCode('');
1439
+ } catch (err) {
1440
+ setSubmitError(err instanceof Error ? err.message : String(err));
1441
+ } finally {
1442
+ setSubmitting(false);
1443
+ }
1444
+ }}
1445
+ style={{
1446
+ paddingHorizontal: 14,
1447
+ justifyContent: 'center',
1448
+ backgroundColor:
1449
+ !pasteCode.trim() || submitting
1450
+ ? 'rgba(124,58,237,0.4)'
1451
+ : '#7c3aed',
1452
+ borderRadius: 10,
1453
+ }}
1454
+ >
1455
+ <Text style={{ color: 'white', fontWeight: '600' }}>
1456
+ {submitting ? '…' : 'Submit'}
1457
+ </Text>
1458
+ </Pressable>
1459
+ </View>
1460
+ {submitError ? (
1461
+ <Text
1462
+ style={{
1463
+ marginTop: 6,
1464
+ color: '#fca5a5',
1465
+ fontSize: 12,
1466
+ }}
1467
+ >
1468
+ {submitError}
1469
+ </Text>
1470
+ ) : null}
1471
+ </View>
1472
+ ) : null}
1383
1473
  <Text style={runnerAuthModalStyles.phishingHint}>
1384
- Device codes are a common phishing target. Never share this code. This dialog
1385
- turns green automatically once sign-in completes.
1474
+ {needsPasteBack
1475
+ ? 'After authorising on platform.claude.com, copy the code from the callback page and paste it above. Never share this code.'
1476
+ : 'Device codes are a common phishing target. Never share this code. This dialog turns green automatically once sign-in completes.'}
1386
1477
  </Text>
1387
1478
  </View>
1388
1479
  )}
package/src/P2PClient.ts CHANGED
@@ -199,6 +199,29 @@ export class P2PClient {
199
199
  try { await fetch(url, { method: 'POST', headers: this.authHeaders() }); } catch { /* best-effort */ }
200
200
  }
201
201
 
202
+ /** Submit the verifier code Anthropic shows on the callback page so
203
+ * the agent can finalise claude CLI's OAuth handshake. Codex doesn't
204
+ * use this — its device-auth flow auto-resolves via polling — but
205
+ * the SDK still exposes it for symmetry with mobile/src/components/
206
+ * RunnerAuthModal.tsx and the Swift YaverRunnerAuthFlowPane. */
207
+ async submitRunnerBrowserAuthCode(
208
+ sessionId: string,
209
+ code: string,
210
+ ): Promise<RunnerBrowserAuthSession> {
211
+ const url = `${this.baseUrl}/runner-auth/browser/submit-code`;
212
+ const resp = await fetch(url, {
213
+ method: 'POST',
214
+ headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
215
+ body: JSON.stringify({ id: sessionId, code }),
216
+ });
217
+ if (!resp.ok) {
218
+ const text = await resp.text().catch(() => '');
219
+ throw new Error(`submitRunnerBrowserAuthCode HTTP ${resp.status}: ${text}`);
220
+ }
221
+ const data = await resp.json();
222
+ return data.session as RunnerBrowserAuthSession;
223
+ }
224
+
202
225
  async capabilitySnapshot(): Promise<CapabilitySnapshot | null> {
203
226
  try {
204
227
  const resp = await fetch(`${this.baseUrl}/capabilities/snapshot`, { headers: this.authHeaders() });
@@ -323,11 +346,12 @@ export class P2PClient {
323
346
  } as any);
324
347
  }
325
348
 
349
+ // Use authHeaders() so a relay-routed baseUrl carries
350
+ // X-Relay-Password — without it the relay rejects with 401
351
+ // "invalid relay password" before the agent ever sees the form.
326
352
  const response = await fetch(`${this.baseUrl}/feedback`, {
327
353
  method: 'POST',
328
- headers: {
329
- Authorization: `Bearer ${this.authToken}`,
330
- },
354
+ headers: this.authHeaders(),
331
355
  body: formData,
332
356
  });
333
357
 
@@ -348,10 +372,7 @@ export class P2PClient {
348
372
  for await (const event of events) {
349
373
  const response = await fetch(`${this.baseUrl}/feedback/stream`, {
350
374
  method: 'POST',
351
- headers: {
352
- Authorization: `Bearer ${this.authToken}`,
353
- 'Content-Type': 'application/json',
354
- },
375
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
355
376
  body: JSON.stringify(event),
356
377
  });
357
378
 
@@ -375,10 +396,7 @@ export class P2PClient {
375
396
  async startBuild(platform: string): Promise<any> {
376
397
  const response = await fetch(`${this.baseUrl}/builds`, {
377
398
  method: 'POST',
378
- headers: {
379
- Authorization: `Bearer ${this.authToken}`,
380
- 'Content-Type': 'application/json',
381
- },
399
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
382
400
  body: JSON.stringify({ platform }),
383
401
  });
384
402
 
@@ -423,9 +441,7 @@ export class P2PClient {
423
441
 
424
442
  const response = await fetch(`${this.baseUrl}/voice/transcribe`, {
425
443
  method: 'POST',
426
- headers: {
427
- Authorization: `Bearer ${this.authToken}`,
428
- },
444
+ headers: this.authHeaders(),
429
445
  body: formData,
430
446
  });
431
447
 
@@ -471,7 +487,7 @@ export class P2PClient {
471
487
  if (mode === 'dev') {
472
488
  const primary = await fetch(`${this.baseUrl}/dev/reload`, {
473
489
  method: 'POST',
474
- headers: { Authorization: `Bearer ${this.authToken}` },
490
+ headers: this.authHeaders(),
475
491
  });
476
492
  if (primary.ok) {
477
493
  const payload = await primary.json().catch(() => ({} as Record<string, unknown>));
@@ -505,10 +521,7 @@ export class P2PClient {
505
521
 
506
522
  const res = await fetch(`${this.baseUrl}/dev/reload-app`, {
507
523
  method: 'POST',
508
- headers: {
509
- Authorization: `Bearer ${this.authToken}`,
510
- 'Content-Type': 'application/json',
511
- },
524
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
512
525
  body: JSON.stringify({
513
526
  mode: 'bundle',
514
527
  ...identity,
@@ -558,10 +571,7 @@ export class P2PClient {
558
571
  const identity = resolveAppIdentity(opts);
559
572
  const response = await fetch(`${this.baseUrl}/vibing/execute`, {
560
573
  method: 'POST',
561
- headers: {
562
- Authorization: `Bearer ${this.authToken}`,
563
- 'Content-Type': 'application/json',
564
- },
574
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
565
575
  body: JSON.stringify({
566
576
  prompt,
567
577
  projectPath: identity.projectPath ?? opts?.projectPath ?? '',
@@ -600,7 +610,7 @@ export class P2PClient {
600
610
  }
601
611
  const response = await fetch(`${this.baseUrl}/vibing/eligibility?${params.toString()}`, {
602
612
  method: 'GET',
603
- headers: { Authorization: `Bearer ${this.authToken}` },
613
+ headers: this.authHeaders(),
604
614
  });
605
615
  if (!response.ok) {
606
616
  const text = await response.text().catch(() => '');
@@ -618,7 +628,7 @@ export class P2PClient {
618
628
  async triggerFix(feedbackId: string): Promise<{ taskId: string; prompt: string }> {
619
629
  const response = await fetch(`${this.baseUrl}/feedback/${encodeURIComponent(feedbackId)}/fix`, {
620
630
  method: 'POST',
621
- headers: { Authorization: `Bearer ${this.authToken}` },
631
+ headers: this.authHeaders(),
622
632
  });
623
633
  if (!response.ok) {
624
634
  const text = await response.text().catch(() => '');
@@ -641,10 +651,7 @@ export class P2PClient {
641
651
  async startTestSession(): Promise<{ sessionId: string }> {
642
652
  const response = await fetch(`${this.baseUrl}/test-app/start`, {
643
653
  method: 'POST',
644
- headers: {
645
- Authorization: `Bearer ${this.authToken}`,
646
- 'Content-Type': 'application/json',
647
- },
654
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
648
655
  body: JSON.stringify({ source: 'feedback-sdk' }),
649
656
  });
650
657
 
@@ -660,7 +667,7 @@ export class P2PClient {
660
667
  async stopTestSession(): Promise<void> {
661
668
  await fetch(`${this.baseUrl}/test-app/stop`, {
662
669
  method: 'POST',
663
- headers: { Authorization: `Bearer ${this.authToken}` },
670
+ headers: this.authHeaders(),
664
671
  });
665
672
  }
666
673
 
@@ -678,10 +685,7 @@ export class P2PClient {
678
685
  async rotateToken(): Promise<{ token: string; expiresAt: number }> {
679
686
  const response = await fetch(`${this.baseUrl}/sdk/token/rotate`, {
680
687
  method: 'POST',
681
- headers: {
682
- Authorization: `Bearer ${this.authToken}`,
683
- 'Content-Type': 'application/json',
684
- },
688
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
685
689
  });
686
690
 
687
691
  if (!response.ok) {
@@ -706,7 +710,7 @@ export class P2PClient {
706
710
  async flagsEvaluate(userId: string = 'anonymous'): Promise<Record<string, unknown>> {
707
711
  const res = await fetch(
708
712
  `${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}`,
709
- { headers: { Authorization: `Bearer ${this.authToken}` } },
713
+ { headers: this.authHeaders() },
710
714
  );
711
715
  if (!res.ok) return {};
712
716
  const data = await res.json();
@@ -720,7 +724,7 @@ export class P2PClient {
720
724
  ): Promise<T | undefined> {
721
725
  const res = await fetch(
722
726
  `${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}&flag=${encodeURIComponent(key)}`,
723
- { headers: { Authorization: `Bearer ${this.authToken}` } },
727
+ { headers: this.authHeaders() },
724
728
  );
725
729
  if (!res.ok) return undefined;
726
730
  const data = await res.json();
@@ -753,7 +757,7 @@ export class P2PClient {
753
757
  const params = new URLSearchParams({ channel });
754
758
  if (deviceId) params.set('device', deviceId);
755
759
  const res = await fetch(`${this.baseUrl}/releases/latest?${params.toString()}`, {
756
- headers: { Authorization: `Bearer ${this.authToken}` },
760
+ headers: this.authHeaders(),
757
761
  });
758
762
  if (!res.ok) return null;
759
763
  return res.json();
@@ -766,7 +770,7 @@ export class P2PClient {
766
770
  ): Promise<ArrayBuffer | null> {
767
771
  const params = new URLSearchParams({ channel, semver });
768
772
  const res = await fetch(`${this.baseUrl}/releases/bundle?${params.toString()}`, {
769
- headers: { Authorization: `Bearer ${this.authToken}` },
773
+ headers: this.authHeaders(),
770
774
  });
771
775
  if (!res.ok) return null;
772
776
  return res.arrayBuffer();
@@ -787,10 +791,7 @@ export class P2PClient {
787
791
  try {
788
792
  const res = await fetch(`${this.baseUrl}/analytics/ingest`, {
789
793
  method: 'POST',
790
- headers: {
791
- Authorization: `Bearer ${this.authToken}`,
792
- 'Content-Type': 'application/json',
793
- },
794
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
794
795
  body: JSON.stringify({
795
796
  name,
796
797
  props,
@@ -148,8 +148,51 @@ let enabled = false;
148
148
  let p2pClient: P2PClient | null = null;
149
149
  let shakeDetector: ShakeDetector | null = null;
150
150
  let p2pAuthToken: string | null = null;
151
+ let p2pRelayPassword: string = '';
151
152
  let reportLaunchInFlight = false;
152
153
 
154
+ /** Resolve the user's relay password by validating their auth token
155
+ * against Convex. Used whenever we (re)build the P2PClient so a
156
+ * relay-routed agentUrl carries a valid X-Relay-Password — without
157
+ * this, every relay-tunneled request rejects with HTTP 401
158
+ * "invalid relay password" (relay/server.go:957).
159
+ *
160
+ * Cached on `p2pRelayPassword` so we only round-trip Convex when the
161
+ * user's auth token actually changes. Returns "" on any failure so
162
+ * direct LAN agentUrls (which need no password) keep working.
163
+ */
164
+ async function resolveRelayPassword(authToken: string, convexUrl?: string): Promise<string> {
165
+ const trimmed = (authToken || '').trim();
166
+ if (!trimmed) {
167
+ p2pRelayPassword = '';
168
+ return '';
169
+ }
170
+ const url = (convexUrl || config?.convexUrl || DEFAULT_CONVEX_SITE_URL).replace(/\/+$/, '');
171
+ try {
172
+ // /settings returns {ok, settings: {relayPassword, relayUrl, ...}}
173
+ // Older accounts may flatten relayPassword to the top — match the
174
+ // tolerance the web shell already uses (route.ts:77).
175
+ const res = await fetch(`${url}/settings`, {
176
+ headers: { Authorization: `Bearer ${trimmed}` },
177
+ });
178
+ if (!res.ok) return p2pRelayPassword;
179
+ const data = await res.json().catch(() => ({} as Record<string, unknown>));
180
+ const settings = (data as { settings?: { relayPassword?: string } })?.settings;
181
+ const pw =
182
+ (typeof settings?.relayPassword === 'string' && settings.relayPassword) ||
183
+ (typeof (data as { relayPassword?: string })?.relayPassword === 'string'
184
+ ? (data as { relayPassword?: string }).relayPassword
185
+ : '') ||
186
+ '';
187
+ p2pRelayPassword = pw;
188
+ return pw;
189
+ } catch {
190
+ // Network failure on a passive Convex round-trip shouldn't break
191
+ // direct-LAN flows. Fall through with whatever we already cached.
192
+ return p2pRelayPassword;
193
+ }
194
+ }
195
+
153
196
  /** Ring buffer of captured errors. */
154
197
  let errorBuffer: CapturedError[] = [];
155
198
  let maxErrors = 5;
@@ -212,7 +255,8 @@ export class YaverFeedback {
212
255
  return;
213
256
  }
214
257
  p2pAuthToken = token;
215
- p2pClient = new P2PClient(effectiveUrl, token);
258
+ const rp = await resolveRelayPassword(token);
259
+ p2pClient = new P2PClient(effectiveUrl, token, rp);
216
260
  }
217
261
 
218
262
  /**
@@ -295,7 +339,12 @@ export class YaverFeedback {
295
339
  // Create P2P client if we have a URL
296
340
  if (config.agentUrl) {
297
341
  p2pAuthToken = config.authToken ?? null;
298
- p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '');
342
+ // Initial construction uses the cached p2pRelayPassword (empty on
343
+ // first init). rebuildP2PClient below resolves the real password
344
+ // from Convex and replaces this client — but only when authToken
345
+ // is set, so set a placeholder header here that won't 401 a
346
+ // direct-LAN url and will be overwritten before any relay hop.
347
+ p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '', p2pRelayPassword);
299
348
  if (config.authToken) {
300
349
  void YaverFeedback.rebuildP2PClient(config.agentUrl);
301
350
  }
@@ -596,6 +645,19 @@ export class YaverFeedback {
596
645
  await p2pClient.cancelRunnerBrowserAuth(sessionId);
597
646
  }
598
647
 
648
+ /** Submit the Claude paste-back verifier so the agent can finalise the
649
+ * OAuth handshake. RunnerAuthModal calls this after the user copies
650
+ * the code from platform.claude.com's callback page. */
651
+ static async submitRunnerBrowserAuthCode(
652
+ sessionId: string,
653
+ code: string,
654
+ ): Promise<import('./types').RunnerBrowserAuthSession> {
655
+ if (!p2pClient) {
656
+ throw new Error('Not connected to any agent.');
657
+ }
658
+ return p2pClient.submitRunnerBrowserAuthCode(sessionId, code);
659
+ }
660
+
599
661
  /**
600
662
  * Sign out: clear cached token + device, tear down the P2P client. The
601
663
  * SDK stays enabled; the next feedback trigger will re-prompt for login.
@@ -936,7 +998,8 @@ export class YaverFeedback {
936
998
  });
937
999
  if (result) {
938
1000
  config.agentUrl = result.url;
939
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
1001
+ const rp = await resolveRelayPassword(config.authToken ?? '');
1002
+ p2pClient = new P2PClient(result.url, config.authToken ?? '', rp);
940
1003
  }
941
1004
  } catch {}
942
1005
  }