viveworker 0.7.0 → 0.8.0

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.
Files changed (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1324 -103
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +634 -9
  21. package/web/app.js +1731 -187
  22. package/web/i18n.js +187 -9
  23. package/web/index.html +32 -2
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
@@ -21,6 +21,12 @@ import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
21
21
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
22
22
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
23
23
  import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems } from "./moltbook-api.mjs";
24
+ import { startRemotePairingRelay, DEFAULT_RELAY_URL } from "./lib/remote-pairing/orchestrator.mjs";
25
+ import { restartRemotePairingRelay, persistRemotePairingEnv, getRemotePairingStatus } from "./lib/remote-pairing/control.mjs";
26
+ import { appendRemotePairingAuditEvent, readRemotePairingAuditEvents } from "./lib/remote-pairing/audit.mjs";
27
+ import { loadPairings, removePairingPersisted, addPairingPersisted, rotateRelayTokenPersisted, buildPairing, REMOTE_PAIRINGS_FILE } from "./lib/remote-pairing/pairings.mjs";
28
+ import { ensureIdentityKeypair } from "./lib/remote-pairing/keys.mjs";
29
+ import { fingerprintIdentity, bytesToHex } from "./lib/remote-pairing/keys-core.mjs";
24
30
 
25
31
  const __filename = fileURLToPath(import.meta.url);
26
32
  const __dirname = path.dirname(__filename);
@@ -42,6 +48,8 @@ const listSupportedPayments = typeof hazbaseAuth.listSupportedPayments === "func
42
48
  ? hazbaseAuth.listSupportedPayments.bind(hazbaseAuth)
43
49
  : async () => ({ networks: [], defaultNetwork: "base-sepolia" });
44
50
  const appPackageVersion = readPackageVersion();
51
+ const WEB_APP_BUILD_ID = "20260428-remote-token-refresh";
52
+ const WEB_APP_SCRIPT_URL = `/app.js?v=${WEB_APP_BUILD_ID}`;
45
53
  const sessionCookieName = "viveworker_session";
46
54
  const deviceCookieName = "viveworker_device";
47
55
  const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
@@ -52,6 +60,7 @@ const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
52
60
  const MAX_PAIRED_DEVICES = 200;
53
61
  const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
54
62
  const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
63
+ const REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS = 30 * 24 * 60 * 60 * 1000;
55
64
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
56
65
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
57
66
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
@@ -209,6 +218,7 @@ const cli = parseCliArgs(process.argv.slice(2));
209
218
  const envFile = resolveEnvFile(cli.envFile);
210
219
  loadEnvFile(envFile);
211
220
  loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
221
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "remote-pairing.env"));
212
222
  await maybeRotateStartupPairingEnv(envFile);
213
223
 
214
224
  const config = buildConfig(cli);
@@ -261,6 +271,7 @@ const runtime = {
261
271
  recentCodeEvents: [],
262
272
  pairingAttemptsByRemoteAddress: new Map(),
263
273
  ipcClient: null,
274
+ remotePairingHandle: null,
264
275
  stopping: false,
265
276
  // In-memory cache for the `/api/share/status` endpoint. A 30s TTL avoids
266
277
  // hammering the share worker on every settings-page render. Purely runtime —
@@ -475,6 +486,11 @@ function buildSessionLocalePayload(config, state, deviceId) {
475
486
  a2aShareEnabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
476
487
  a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
477
488
  a2aExecutorPreference: state.a2aExecutorPreference || "ask",
489
+ // Remote pairing is always "available" — the row appears in Settings →
490
+ // Integrations regardless of whether the relay is currently enabled, so
491
+ // users can find the toggle. The actual enabled/connected state is read
492
+ // from /api/remote-pairing/status.
493
+ remotePairingAvailable: true,
478
494
  };
479
495
  }
480
496
 
@@ -808,6 +824,21 @@ function normalizeTimelineFileRefs(rawFileRefs) {
808
824
  return deduped;
809
825
  }
810
826
 
827
+ function filterTimelineFileRefsForImagePaths(fileRefs, imagePaths) {
828
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs);
829
+ const normalizedImages = normalizeTimelineImagePaths(imagePaths);
830
+ if (normalizedRefs.length === 0 || normalizedImages.length === 0) {
831
+ return normalizedRefs;
832
+ }
833
+
834
+ const imageRefKeys = new Set();
835
+ for (const imagePath of normalizedImages) {
836
+ imageRefKeys.add(imagePath);
837
+ imageRefKeys.add(path.basename(imagePath));
838
+ }
839
+ return normalizedRefs.filter((fileRef) => !imageRefKeys.has(fileRef) && !imageRefKeys.has(path.basename(fileRef)));
840
+ }
841
+
811
842
  function cleanTimelineFileRef(value) {
812
843
  let normalized = cleanText(value || "");
813
844
  if (!normalized) {
@@ -2874,7 +2905,8 @@ function normalizeHistoryItem(raw) {
2874
2905
  const title =
2875
2906
  (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2876
2907
  (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2877
- const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
2908
+ const rawMessageText = raw.messageText ?? "";
2909
+ const messageText = normalizeTimelineMessageText(rawMessageText);
2878
2910
  const summary = normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "";
2879
2911
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
2880
2912
  if (!stableId || !historyKinds.has(kind) || !title) {
@@ -2882,6 +2914,14 @@ function normalizeHistoryItem(raw) {
2882
2914
  }
2883
2915
 
2884
2916
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2917
+ const imagePaths = normalizeTimelineImagePaths([
2918
+ ...(Array.isArray(raw.imagePaths ?? raw.localImagePaths) ? (raw.imagePaths ?? raw.localImagePaths) : []),
2919
+ ...extractTimelineImagePaths(rawMessageText),
2920
+ ]);
2921
+ const fileRefs = filterTimelineFileRefsForImagePaths(
2922
+ normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
2923
+ imagePaths
2924
+ );
2885
2925
 
2886
2926
  const normalized = {
2887
2927
  stableId,
@@ -2892,8 +2932,8 @@ function normalizeHistoryItem(raw) {
2892
2932
  threadLabel,
2893
2933
  summary,
2894
2934
  messageText,
2895
- imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
2896
- fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
2935
+ imagePaths,
2936
+ fileRefs,
2897
2937
  diffText: normalizeTimelineDiffText(raw.diffText ?? ""),
2898
2938
  diffSource: normalizeTimelineDiffSource(raw.diffSource ?? ""),
2899
2939
  diffAvailable: raw.diffAvailable === true || Boolean(raw.diffText),
@@ -3127,7 +3167,8 @@ function normalizeTimelineEntry(raw) {
3127
3167
  }
3128
3168
 
3129
3169
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
3130
- const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
3170
+ const rawMessageText = raw.messageText ?? "";
3171
+ const messageText = normalizeTimelineMessageText(rawMessageText);
3131
3172
  const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3132
3173
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
3133
3174
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
@@ -3162,6 +3203,14 @@ function normalizeTimelineEntry(raw) {
3162
3203
  threadLabel ||
3163
3204
  kindTitle(DEFAULT_LOCALE, kind);
3164
3205
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
3206
+ const imagePaths = normalizeTimelineImagePaths([
3207
+ ...(Array.isArray(raw.imagePaths ?? raw.localImagePaths) ? (raw.imagePaths ?? raw.localImagePaths) : []),
3208
+ ...extractTimelineImagePaths(rawMessageText),
3209
+ ]);
3210
+ const fileRefs = filterTimelineFileRefsForImagePaths(
3211
+ normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
3212
+ imagePaths
3213
+ );
3165
3214
 
3166
3215
  const normalized = {
3167
3216
  stableId,
@@ -3174,8 +3223,8 @@ function normalizeTimelineEntry(raw) {
3174
3223
  messageText,
3175
3224
  fileEventType,
3176
3225
  previousFileRefs: normalizeTimelineFileRefs(raw.previousFileRefs ?? []),
3177
- imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
3178
- fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
3226
+ imagePaths,
3227
+ fileRefs,
3179
3228
  diffText,
3180
3229
  diffSource,
3181
3230
  diffAvailable: raw.diffAvailable === true || Boolean(diffText),
@@ -5181,8 +5230,8 @@ async function readRecentRolloutUserMessagesWithImages({ filePath, maxBytes }) {
5181
5230
  async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5182
5231
  const conditions = [
5183
5232
  `id > ${Math.max(0, Number(cursorId) || 0)}`,
5184
- `target = 'codex_api::endpoint::responses_websocket'`,
5185
- `feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%'`,
5233
+ `target IN ('codex_api::endpoint::responses_websocket', 'codex_api::sse::responses')`,
5234
+ `(feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%' OR feedback_log_body LIKE '%SSE event: {"type":"response.output_item.done"%')`,
5186
5235
  `feedback_log_body LIKE '%"type":"message"%'`,
5187
5236
  `feedback_log_body LIKE '%"role":"assistant"%'`,
5188
5237
  ];
@@ -5192,28 +5241,50 @@ async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5192
5241
  }
5193
5242
 
5194
5243
  const sql = `
5195
- SELECT id, ts, thread_id, feedback_log_body
5244
+ SELECT
5245
+ logs.id,
5246
+ logs.ts,
5247
+ logs.thread_id,
5248
+ logs.feedback_log_body,
5249
+ (
5250
+ SELECT ctx.feedback_log_body
5251
+ FROM logs AS ctx
5252
+ WHERE ctx.id < logs.id
5253
+ AND ctx.id >= logs.id - 5
5254
+ AND ctx.target = 'codex_otel.log_only'
5255
+ AND ctx.feedback_log_body LIKE '%conversation.id=%'
5256
+ ORDER BY ctx.id DESC
5257
+ LIMIT 1
5258
+ ) AS context_log_body
5196
5259
  FROM logs
5197
5260
  WHERE ${conditions.join("\n AND ")}
5198
- ORDER BY id ASC
5261
+ ORDER BY logs.id ASC
5199
5262
  LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
5200
5263
  `;
5201
5264
 
5202
5265
  return runSqliteJsonQuery(logsDbFile, sql);
5203
5266
  }
5204
5267
 
5205
- function buildSqliteTimelineEntry({ row, config, runtime }) {
5206
- const body = String(row?.feedback_log_body ?? "");
5207
- const marker = "websocket event: ";
5208
- const markerIndex = body.indexOf(marker);
5209
- if (markerIndex === -1) {
5210
- return null;
5268
+ function parseResponsesEventPayload(body) {
5269
+ const raw = String(body || "");
5270
+ for (const marker of ["websocket event: ", "SSE event: "]) {
5271
+ const markerIndex = raw.indexOf(marker);
5272
+ if (markerIndex === -1) {
5273
+ continue;
5274
+ }
5275
+ try {
5276
+ return JSON.parse(raw.slice(markerIndex + marker.length));
5277
+ } catch {
5278
+ return null;
5279
+ }
5211
5280
  }
5281
+ return null;
5282
+ }
5212
5283
 
5213
- let payload;
5214
- try {
5215
- payload = JSON.parse(body.slice(markerIndex + marker.length));
5216
- } catch {
5284
+ function buildSqliteTimelineEntry({ row, config, runtime }) {
5285
+ const body = String(row?.feedback_log_body ?? "");
5286
+ const payload = parseResponsesEventPayload(body);
5287
+ if (!payload) {
5217
5288
  return null;
5218
5289
  }
5219
5290
 
@@ -5237,7 +5308,12 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
5237
5308
  return null;
5238
5309
  }
5239
5310
 
5240
- const threadId = cleanText(row.thread_id || extractThreadIdFromLogBody(body));
5311
+ const contextLogBody = String(row?.context_log_body ?? "");
5312
+ const threadId = cleanText(
5313
+ row.thread_id ||
5314
+ extractThreadIdFromLogBody(body) ||
5315
+ extractThreadIdFromLogBody(contextLogBody)
5316
+ );
5241
5317
  if (!threadId) {
5242
5318
  return null;
5243
5319
  }
@@ -5365,6 +5441,25 @@ async function findReplyUploadFallback(config, sourcePath, usedPaths = new Set()
5365
5441
  return candidates[0]?.filePath || "";
5366
5442
  }
5367
5443
 
5444
+ function isFileAccessDeniedError(error) {
5445
+ const code = cleanText(error?.code || "");
5446
+ return code === "EACCES" || code === "EPERM";
5447
+ }
5448
+
5449
+ async function copyFileWithSystemCp(sourcePath, destinationPath) {
5450
+ return new Promise((resolve, reject) => {
5451
+ const child = spawn("/bin/cp", ["-p", sourcePath, destinationPath], { stdio: "ignore" });
5452
+ child.once("error", reject);
5453
+ child.once("exit", (code, signal) => {
5454
+ if (code === 0) {
5455
+ resolve(true);
5456
+ return;
5457
+ }
5458
+ reject(new Error(`cp exited with ${signal || code}`));
5459
+ });
5460
+ });
5461
+ }
5462
+
5368
5463
  async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
5369
5464
  const normalizedSourcePath = resolvePath(cleanText(sourcePath || ""));
5370
5465
  if (!normalizedSourcePath) {
@@ -5381,6 +5476,16 @@ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
5381
5476
  await fs.copyFile(normalizedSourcePath, destinationPath);
5382
5477
  return destinationPath;
5383
5478
  } catch (error) {
5479
+ if (isFileAccessDeniedError(error)) {
5480
+ try {
5481
+ await copyFileWithSystemCp(normalizedSourcePath, destinationPath);
5482
+ return destinationPath;
5483
+ } catch (fallbackError) {
5484
+ await fs.rm(destinationPath, { force: true }).catch(() => {});
5485
+ console.warn(`[timeline-image-copy-skipped] ${error?.message || error}; cp fallback failed: ${fallbackError?.message || fallbackError}`);
5486
+ return "";
5487
+ }
5488
+ }
5384
5489
  console.warn(`[timeline-image-copy-skipped] ${error?.message || error}`);
5385
5490
  return "";
5386
5491
  }
@@ -5393,8 +5498,12 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5393
5498
  }
5394
5499
 
5395
5500
  const aliases = isPlainObject(state.timelineImagePathAliases) ? state.timelineImagePathAliases : (state.timelineImagePathAliases = {});
5501
+ const copyFailures = isPlainObject(state.timelineImagePathCopyFailures)
5502
+ ? state.timelineImagePathCopyFailures
5503
+ : (state.timelineImagePathCopyFailures = {});
5396
5504
  const usedFallbacks = new Set();
5397
5505
  const nextPaths = [];
5506
+ const copyRetryMs = 10 * 60 * 1000;
5398
5507
 
5399
5508
  for (const rawPath of normalizedImagePaths) {
5400
5509
  const normalizedPath = cleanText(rawPath || "");
@@ -5404,24 +5513,38 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5404
5513
 
5405
5514
  const aliasedPath = cleanText(aliases[normalizedPath] || "");
5406
5515
  if (aliasedPath) {
5407
- try {
5408
- await fs.access(aliasedPath);
5409
- nextPaths.push(aliasedPath);
5410
- continue;
5411
- } catch {
5412
- // Fall through and repair below.
5516
+ if (aliasedPath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
5517
+ try {
5518
+ await fs.access(aliasedPath);
5519
+ nextPaths.push(aliasedPath);
5520
+ continue;
5521
+ } catch {
5522
+ // Fall through and repair below.
5523
+ }
5413
5524
  }
5414
5525
  }
5415
5526
 
5527
+ const lastCopyFailureMs = Math.max(0, Number(copyFailures[normalizedPath]) || 0);
5528
+ if (
5529
+ lastCopyFailureMs > 0 &&
5530
+ Date.now() - lastCopyFailureMs < copyRetryMs &&
5531
+ !normalizedPath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)
5532
+ ) {
5533
+ nextPaths.push(normalizedPath);
5534
+ continue;
5535
+ }
5536
+
5416
5537
  let existingSourcePath = normalizedPath;
5417
5538
  try {
5418
5539
  await fs.access(existingSourcePath);
5419
- } catch {
5420
- existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
5421
- if (!existingSourcePath) {
5422
- continue;
5540
+ } catch (error) {
5541
+ if (!isFileAccessDeniedError(error)) {
5542
+ existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
5543
+ if (!existingSourcePath) {
5544
+ continue;
5545
+ }
5546
+ usedFallbacks.add(existingSourcePath);
5423
5547
  }
5424
- usedFallbacks.add(existingSourcePath);
5425
5548
  }
5426
5549
 
5427
5550
  let persistentPath = existingSourcePath;
@@ -5429,6 +5552,11 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5429
5552
  persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath) || existingSourcePath;
5430
5553
  }
5431
5554
 
5555
+ if (persistentPath === existingSourcePath && !existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
5556
+ copyFailures[normalizedPath] = Date.now();
5557
+ } else {
5558
+ delete copyFailures[normalizedPath];
5559
+ }
5432
5560
  aliases[normalizedPath] = persistentPath;
5433
5561
  nextPaths.push(persistentPath);
5434
5562
  }
@@ -5775,8 +5903,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
5775
5903
  async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5776
5904
  const conditions = [
5777
5905
  `id > ${Math.max(0, Number(cursorId) || 0)}`,
5778
- `target = 'codex_api::endpoint::responses_websocket'`,
5779
- `feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%'`,
5906
+ `target IN ('codex_api::endpoint::responses_websocket', 'codex_api::sse::responses')`,
5907
+ `(feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%' OR feedback_log_body LIKE '%SSE event: {"type":"response.output_item.done"%')`,
5780
5908
  `feedback_log_body LIKE '%"type":"message"%'`,
5781
5909
  `feedback_log_body LIKE '%"role":"assistant"%'`,
5782
5910
  `feedback_log_body LIKE '%"phase":"final_answer"%'`,
@@ -5787,10 +5915,24 @@ async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 })
5787
5915
  }
5788
5916
 
5789
5917
  const sql = `
5790
- SELECT id, ts, thread_id, feedback_log_body
5918
+ SELECT
5919
+ logs.id,
5920
+ logs.ts,
5921
+ logs.thread_id,
5922
+ logs.feedback_log_body,
5923
+ (
5924
+ SELECT ctx.feedback_log_body
5925
+ FROM logs AS ctx
5926
+ WHERE ctx.id < logs.id
5927
+ AND ctx.id >= logs.id - 5
5928
+ AND ctx.target = 'codex_otel.log_only'
5929
+ AND ctx.feedback_log_body LIKE '%conversation.id=%'
5930
+ ORDER BY ctx.id DESC
5931
+ LIMIT 1
5932
+ ) AS context_log_body
5791
5933
  FROM logs
5792
5934
  WHERE ${conditions.join("\n AND ")}
5793
- ORDER BY id ASC
5935
+ ORDER BY logs.id ASC
5794
5936
  LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
5795
5937
  `;
5796
5938
 
@@ -5836,16 +5978,8 @@ async function runSqliteJsonQuery(dbFile, sql) {
5836
5978
 
5837
5979
  function buildSqliteCompletionEvent({ row, config, runtime }) {
5838
5980
  const body = String(row?.feedback_log_body ?? "");
5839
- const marker = "websocket event: ";
5840
- const markerIndex = body.indexOf(marker);
5841
- if (markerIndex === -1) {
5842
- return null;
5843
- }
5844
-
5845
- let payload;
5846
- try {
5847
- payload = JSON.parse(body.slice(markerIndex + marker.length));
5848
- } catch {
5981
+ const payload = parseResponsesEventPayload(body);
5982
+ if (!payload) {
5849
5983
  return null;
5850
5984
  }
5851
5985
 
@@ -5858,7 +5992,12 @@ function buildSqliteCompletionEvent({ row, config, runtime }) {
5858
5992
  return null;
5859
5993
  }
5860
5994
 
5861
- const threadId = cleanText(row.thread_id || extractThreadIdFromLogBody(body));
5995
+ const contextLogBody = String(row?.context_log_body ?? "");
5996
+ const threadId = cleanText(
5997
+ row.thread_id ||
5998
+ extractThreadIdFromLogBody(body) ||
5999
+ extractThreadIdFromLogBody(contextLogBody)
6000
+ );
5862
6001
  const turnId = cleanText(extractTurnIdFromLogBody(body) || item.id || row.id);
5863
6002
  if (!threadId || !turnId) {
5864
6003
  return null;
@@ -8251,6 +8390,12 @@ function normalizeIpcErrorMessage(errorValue) {
8251
8390
  return cleanText(String(errorValue ?? "")) || "ipc-request-failed";
8252
8391
  }
8253
8392
 
8393
+ function isStartTurnAckTimeout(errorValue) {
8394
+ const message = normalizeIpcErrorMessage(errorValue);
8395
+ return message === "thread-follower-start-turn-timeout" ||
8396
+ message === "turn/start-timeout";
8397
+ }
8398
+
8254
8399
  function buildDefaultCollaborationMode(threadState) {
8255
8400
  // Fallback turns must leave Plan mode unless the caller explicitly opts in.
8256
8401
  return buildRequestedCollaborationMode(threadState, "default");
@@ -8942,6 +9087,14 @@ function deriveClaudeSdkCliThreadLabel(messageText, conversationId = "") {
8942
9087
  if (/^You are scoring Moltbook posts? for an AI agent\b/iu.test(single)) {
8943
9088
  return "Moltbook scoring";
8944
9089
  }
9090
+ if (/^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9091
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single)) {
9092
+ return "Moltbook reply drafting";
9093
+ }
9094
+ if (/^You are composing an original post on Moltbook\b/iu.test(single) ||
9095
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)) {
9096
+ return "Moltbook post drafting";
9097
+ }
8945
9098
  if (/^Codex from another agent:/iu.test(single)) {
8946
9099
  return truncate(cleanText(single.replace(/^Codex from another agent:\s*/iu, "")), 90) || "Cross-agent task";
8947
9100
  }
@@ -8962,6 +9115,46 @@ function isHiddenClaudeInternalScoringText(text) {
8962
9115
  return /^You are scoring Moltbook posts? for an AI agent\b/iu.test(single);
8963
9116
  }
8964
9117
 
9118
+ function isMoltbookHarnessThreadLabel(text) {
9119
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9120
+ if (!single) {
9121
+ return false;
9122
+ }
9123
+ return (
9124
+ single === "Moltbook reply drafting" ||
9125
+ single === "Moltbook post drafting" ||
9126
+ /^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9127
+ /^You are composing an original post on Moltbook\b/iu.test(single) ||
9128
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single) ||
9129
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)
9130
+ );
9131
+ }
9132
+
9133
+ function isHiddenMoltbookHarnessPromptText(text) {
9134
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9135
+ if (!single) {
9136
+ return false;
9137
+ }
9138
+ return (
9139
+ /^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9140
+ /^You are composing an original post on Moltbook\b/iu.test(single) ||
9141
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single) ||
9142
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)
9143
+ );
9144
+ }
9145
+
9146
+ function isHiddenMoltbookHarnessOutputText(text) {
9147
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9148
+ if (!single) {
9149
+ return false;
9150
+ }
9151
+ return (
9152
+ /^INTENT:\s+.+\s+---\s+.+/isu.test(single) ||
9153
+ /^SUBMOLT:\s+.+\s+TITLE:\s+.+\s+INTENT:\s+.+\s+---\s+.+/isu.test(single) ||
9154
+ single === "NO_MATCH"
9155
+ );
9156
+ }
9157
+
8965
9158
  function isHiddenCodexApprovalAssessmentText(text) {
8966
9159
  const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
8967
9160
  if (!single) {
@@ -8971,7 +9164,7 @@ function isHiddenCodexApprovalAssessmentText(text) {
8971
9164
  }
8972
9165
 
8973
9166
  function shouldHideInternalTimelineItem(item) {
8974
- return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item);
9167
+ return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item) || shouldHideMoltbookHarnessItem(item);
8975
9168
  }
8976
9169
 
8977
9170
  function shouldHideClaudeInternalItem(item) {
@@ -8993,6 +9186,29 @@ function shouldHideClaudeInternalItem(item) {
8993
9186
  );
8994
9187
  }
8995
9188
 
9189
+ function shouldHideMoltbookHarnessItem(item) {
9190
+ if (!isPlainObject(item)) {
9191
+ return false;
9192
+ }
9193
+ const provider = normalizeProvider(item.provider);
9194
+ if (provider !== "claude" && provider !== "codex") {
9195
+ return false;
9196
+ }
9197
+
9198
+ return (
9199
+ isMoltbookHarnessThreadLabel(item.threadLabel) ||
9200
+ isMoltbookHarnessThreadLabel(item.title) ||
9201
+ isHiddenMoltbookHarnessPromptText(item.messageText) ||
9202
+ isHiddenMoltbookHarnessPromptText(item.summary) ||
9203
+ isHiddenMoltbookHarnessPromptText(item.detailText) ||
9204
+ isHiddenMoltbookHarnessPromptText(item.message) ||
9205
+ isHiddenMoltbookHarnessOutputText(item.messageText) ||
9206
+ isHiddenMoltbookHarnessOutputText(item.summary) ||
9207
+ isHiddenMoltbookHarnessOutputText(item.detailText) ||
9208
+ isHiddenMoltbookHarnessOutputText(item.message)
9209
+ );
9210
+ }
9211
+
8996
9212
  function shouldSuppressInternalScannedEvent(event) {
8997
9213
  if (!isPlainObject(event)) {
8998
9214
  return false;
@@ -9811,6 +10027,16 @@ function base64UrlDecode(value) {
9811
10027
  return Buffer.from(String(value), "base64url").toString("utf8");
9812
10028
  }
9813
10029
 
10030
+ function constantTimeStringEqual(a, b) {
10031
+ // Pad-then-compare so the timing leak is bounded by the longer string's
10032
+ // length rather than the matching prefix. Caller still gets a clean
10033
+ // boolean — no behavioural difference from `===`.
10034
+ const left = Buffer.from(String(a ?? ""), "utf8");
10035
+ const right = Buffer.from(String(b ?? ""), "utf8");
10036
+ if (left.length !== right.length) return false;
10037
+ return crypto.timingSafeEqual(left, right);
10038
+ }
10039
+
9814
10040
  function signSessionPayload(payload, secret) {
9815
10041
  const encoded = base64UrlEncode(JSON.stringify(payload));
9816
10042
  const signature = crypto.createHmac("sha256", secret).update(encoded).digest("base64url");
@@ -10137,6 +10363,35 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
10137
10363
  };
10138
10364
  }
10139
10365
 
10366
+ function inferLegacyRelayDeviceId(state, config, pairing) {
10367
+ const active = activeTrustedDevices(state, config);
10368
+ if (active.length === 1) {
10369
+ return active[0].deviceId;
10370
+ }
10371
+
10372
+ const addedAtMs = Number(pairing?.addedAtMs) || 0;
10373
+ if (addedAtMs <= 0) {
10374
+ return "";
10375
+ }
10376
+ const nearby = active.filter(({ record }) => {
10377
+ const pairedAtMs = Number(record?.pairedAtMs) || 0;
10378
+ return pairedAtMs > 0 && Math.abs(pairedAtMs - addedAtMs) <= 10 * 60 * 1000;
10379
+ });
10380
+ return nearby.length === 1 ? nearby[0].deviceId : "";
10381
+ }
10382
+
10383
+ function resolveRelaySessionDeviceId(state, config, pairing, fallbackDeviceId) {
10384
+ const explicit = cleanText(pairing?.deviceId || "");
10385
+ if (explicit) {
10386
+ return explicit;
10387
+ }
10388
+ const inferred = inferLegacyRelayDeviceId(state, config, pairing);
10389
+ if (inferred) {
10390
+ return inferred;
10391
+ }
10392
+ return cleanText(pairing?.phoneFingerprint || "") || cleanText(fallbackDeviceId || "") || null;
10393
+ }
10394
+
10140
10395
  // Shared between `/api/session` and `/api/bootstrap` so both return an
10141
10396
  // identical session shape.
10142
10397
  function buildSessionPayload({ config, state, session }) {
@@ -10147,6 +10402,7 @@ function buildSessionPayload({ config, state, session }) {
10147
10402
  webPushEnabled: config.webPushEnabled,
10148
10403
  httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
10149
10404
  appVersion: appPackageVersion,
10405
+ webAppBuildId: WEB_APP_BUILD_ID,
10150
10406
  deviceId: session.deviceId || null,
10151
10407
  temporaryPairing: session.temporaryPairing === true,
10152
10408
  ...buildSessionLocalePayload(config, state, session.deviceId),
@@ -10180,6 +10436,38 @@ function readSession(req, config, state) {
10180
10436
  };
10181
10437
  }
10182
10438
 
10439
+ // Relay-dispatched requests authenticate via the Noise channel
10440
+ // binding + pairing identity, set on the synthetic IncomingMessage by
10441
+ // scripts/lib/remote-pairing/http-dispatch.mjs. They have no cookies
10442
+ // (HttpOnly session cookie can't be forwarded by the PWA's JS layer,
10443
+ // and the RPC frame doesn't carry browser cookies), so the cookie
10444
+ // path below would always 401 them.
10445
+ //
10446
+ // Trust model: only this process's relay orchestrator can attach this
10447
+ // marker — it's set on a synthetic req object that never leaves the
10448
+ // bridge. An attacker would need code-exec inside the bridge to forge
10449
+ // it, which is past the threat model anyway. The orchestrator only
10450
+ // dispatches requests for pairings whose Noise IK handshake succeeded
10451
+ // against the bridge's static key, so reaching this branch implies
10452
+ // the phone proved possession of `pairing.phonePub`.
10453
+ const relay = req.viveworker;
10454
+ if (relay?.fromRelay && relay.pairing?.pairingId) {
10455
+ const relayDeviceId = resolveRelaySessionDeviceId(state, config, relay.pairing, deviceId);
10456
+ return {
10457
+ authenticated: true,
10458
+ sessionId: `relay:${relay.pairing.pairingId}`,
10459
+ pairedAtMs: Number(relay.pairing.addedAtMs) || Date.now(),
10460
+ // Relay sessions live only as long as the underlying Noise channel
10461
+ // — there's no cookie expiry to track. 0 = "no fixed expiry, the
10462
+ // transport layer manages liveness".
10463
+ expiresAtMs: 0,
10464
+ deviceId: relayDeviceId,
10465
+ fromRelay: true,
10466
+ pairingId: relay.pairing.pairingId,
10467
+ relayPhoneFingerprint: relay.pairing.phoneFingerprint || "",
10468
+ };
10469
+ }
10470
+
10183
10471
  const token = parseCookies(req)[sessionCookieName];
10184
10472
  const payload = token ? verifySessionToken(token, config.sessionSecret) : null;
10185
10473
  if (!payload) {
@@ -10477,8 +10765,15 @@ function validatePairingPayload(payload, config, state) {
10477
10765
 
10478
10766
  const code = cleanText(payload?.code ?? "").toUpperCase();
10479
10767
  const token = cleanText(payload?.token ?? "");
10480
- const matchesCode = code && cleanText(config.pairingCode).toUpperCase() === code;
10481
- const matchesToken = token && cleanText(config.pairingToken) === token;
10768
+ // Constant-time compare both sides even with the 8/15min lockout the
10769
+ // pairing token is long enough that a measurable timing prefix-attack
10770
+ // could meaningfully reduce the brute-force search space.
10771
+ const matchesCode =
10772
+ !!code &&
10773
+ constantTimeStringEqual(cleanText(config.pairingCode).toUpperCase(), code);
10774
+ const matchesToken =
10775
+ !!token &&
10776
+ constantTimeStringEqual(cleanText(config.pairingToken), token);
10482
10777
  if (matchesToken) {
10483
10778
  return { ok: true, credential: `token:${token}` };
10484
10779
  }
@@ -10638,6 +10933,16 @@ function requestOrigin(req) {
10638
10933
  }
10639
10934
 
10640
10935
  function requireTrustedMutationOrigin(req, res, config) {
10936
+ // Relay-dispatched requests have no Origin / Referer (the PWA's RPC
10937
+ // frame doesn't synthesize one) and a non-loopback synthetic
10938
+ // remoteAddress, so they would always 403 here. They're authenticated
10939
+ // by the Noise channel binding instead — no browser-side CSRF surface
10940
+ // exists because an attacker can't establish a Noise channel without
10941
+ // the pairing's static key. See readSession() for the trust-model
10942
+ // notes.
10943
+ if (req.viveworker?.fromRelay) {
10944
+ return true;
10945
+ }
10641
10946
  const origin = requestOrigin(req);
10642
10947
  if (!origin) {
10643
10948
  if (isLoopbackRequest(req)) {
@@ -11285,7 +11590,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
11285
11590
  threadLabel: entry.threadLabel,
11286
11591
  summary: entry.summary,
11287
11592
  fileEventType: normalizeTimelineFileEventType(entry.fileEventType ?? ""),
11288
- imageUrls: buildTimelineEntryImageUrls(entry),
11593
+ imageUrls: buildTimelineEntryImageUrls(entry, config),
11289
11594
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
11290
11595
  diffAvailable: Boolean(entry.diffAvailable),
11291
11596
  diffAddedLines: Math.max(0, Number(entry.diffAddedLines) || 0),
@@ -11794,7 +12099,35 @@ function buildHistoryDetail(item, locale, runtime = null) {
11794
12099
  };
11795
12100
  }
11796
12101
 
11797
- function buildTimelineEntryImageUrls(entry) {
12102
+ function signTimelineEntryImageUrl(config, token, index, filePath) {
12103
+ const secret = cleanText(config?.sessionSecret || "");
12104
+ const normalizedToken = cleanText(token || "");
12105
+ const normalizedPath = filePath ? path.resolve(String(filePath)) : "";
12106
+ const normalizedIndex = Math.max(0, Number(index) || 0);
12107
+ if (!secret || !normalizedToken || !normalizedPath) {
12108
+ return "";
12109
+ }
12110
+ return crypto
12111
+ .createHmac("sha256", secret)
12112
+ .update(`${normalizedToken}\n${normalizedIndex}\n${normalizedPath}`)
12113
+ .digest("base64url");
12114
+ }
12115
+
12116
+ function isValidTimelineEntryImageSignature(config, url, token, index, filePath) {
12117
+ const provided = cleanText(url?.searchParams?.get("imageToken") || "");
12118
+ if (!provided) {
12119
+ return false;
12120
+ }
12121
+ const expected = signTimelineEntryImageUrl(config, token, index, filePath);
12122
+ if (!expected) {
12123
+ return false;
12124
+ }
12125
+ const left = Buffer.from(provided, "utf8");
12126
+ const right = Buffer.from(expected, "utf8");
12127
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
12128
+ }
12129
+
12130
+ function buildTimelineEntryImageUrls(entry, config = null) {
11798
12131
  const imagePaths = normalizeTimelineImagePaths(entry?.imagePaths ?? []);
11799
12132
  if (imagePaths.length === 0) {
11800
12133
  return [];
@@ -11803,10 +12136,14 @@ function buildTimelineEntryImageUrls(entry) {
11803
12136
  if (!token) {
11804
12137
  return [];
11805
12138
  }
11806
- return imagePaths.map((_, index) => `/api/timeline/${encodeURIComponent(token)}/images/${index}`);
12139
+ return imagePaths.map((filePath, index) => {
12140
+ const imageToken = signTimelineEntryImageUrl(config, token, index, filePath);
12141
+ const suffix = imageToken ? `?imageToken=${encodeURIComponent(imageToken)}` : "";
12142
+ return `/api/timeline/${encodeURIComponent(token)}/images/${index}${suffix}`;
12143
+ });
11807
12144
  }
11808
12145
 
11809
- function buildTimelineMessageDetail(entry, locale, runtime = null) {
12146
+ function buildTimelineMessageDetail(entry, locale, runtime = null, config = null) {
11810
12147
  return {
11811
12148
  kind: entry.kind,
11812
12149
  token: entry.token,
@@ -11815,7 +12152,7 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
11815
12152
  threadLabel: entry.threadLabel || "",
11816
12153
  createdAtMs: Number(entry.createdAtMs) || 0,
11817
12154
  messageHtml: renderMessageHtml(entry.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
11818
- imageUrls: buildTimelineEntryImageUrls(entry),
12155
+ imageUrls: buildTimelineEntryImageUrls(entry, config),
11819
12156
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
11820
12157
  previousContext: buildInterruptedTimelineContext(runtime, entry, locale),
11821
12158
  interruptNotice: interruptedDetailNotice(entry.messageText, locale),
@@ -12307,6 +12644,18 @@ async function handleCompletionReply({
12307
12644
  );
12308
12645
  let lastError = null;
12309
12646
  const ownerClientId = runtime.threadOwnerClientIds.get(conversationId) ?? null;
12647
+ const finalizeReplyAccepted = async () => {
12648
+ if (timelineImageAliases.length > 0) {
12649
+ const aliases = isPlainObject(state.timelineImagePathAliases)
12650
+ ? state.timelineImagePathAliases
12651
+ : (state.timelineImagePathAliases = {});
12652
+ for (const [sourcePath, persistentPath] of timelineImageAliases) {
12653
+ aliases[sourcePath] = persistentPath;
12654
+ }
12655
+ await saveState(config.stateFile, state);
12656
+ }
12657
+ scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
12658
+ };
12310
12659
 
12311
12660
  for (const candidate of turnCandidates) {
12312
12661
  try {
@@ -12329,18 +12678,19 @@ async function handleCompletionReply({
12329
12678
  console.log(
12330
12679
  `[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
12331
12680
  );
12332
- if (timelineImageAliases.length > 0) {
12333
- const aliases = isPlainObject(state.timelineImagePathAliases)
12334
- ? state.timelineImagePathAliases
12335
- : (state.timelineImagePathAliases = {});
12336
- for (const [sourcePath, persistentPath] of timelineImageAliases) {
12337
- aliases[sourcePath] = persistentPath;
12338
- }
12339
- await saveState(config.stateFile, state);
12340
- }
12341
- scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
12681
+ await finalizeReplyAccepted();
12342
12682
  return;
12343
12683
  } catch (error) {
12684
+ if (isStartTurnAckTimeout(error)) {
12685
+ // Codex can create the turn but fail to send the bridge an ACK before
12686
+ // its internal follower timeout fires. Retrying risks duplicate user
12687
+ // turns, so treat this as accepted and let the timeline catch up.
12688
+ console.log(
12689
+ `[completion-reply] accepted candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
12690
+ );
12691
+ await finalizeReplyAccepted();
12692
+ return;
12693
+ }
12344
12694
  lastError = error;
12345
12695
  console.log(
12346
12696
  `[completion-reply] failed candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} error=${normalizeIpcErrorMessage(error)} raw=${inspect(error?.ipcError ?? error, { depth: 6, breakLength: 160 })}`
@@ -13120,7 +13470,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13120
13470
  if (timelineMessageKinds.has(kind)) {
13121
13471
  const entry = timelineEntryByToken(runtime, token, kind);
13122
13472
  if (!entry) return null;
13123
- const detail = buildTimelineMessageDetail(entry, locale, runtime);
13473
+ const detail = buildTimelineMessageDetail(entry, locale, runtime, config);
13124
13474
  // Add reply support for Codex assistant_final entries only (replaces completion reply).
13125
13475
  // Check directly against timeline entries (not history items) to avoid
13126
13476
  // maxHistoryItems eviction issues.
@@ -13321,8 +13671,11 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13321
13671
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
13322
13672
  }
13323
13673
 
13324
- function resolveTimelineEntryImagePath(runtime, token, index) {
13325
- const entry = timelineEntryByToken(runtime, token);
13674
+ function resolveTimelineEntryImagePath(runtime, state, token, index) {
13675
+ const normalizedToken = cleanText(token || "");
13676
+ const entry = timelineEntryByToken(runtime, normalizedToken)
13677
+ || (state?.recentTimelineEntries ?? []).find((candidate) => cleanText(candidate?.token || "") === normalizedToken)
13678
+ || null;
13326
13679
  if (!entry) {
13327
13680
  return "";
13328
13681
  }
@@ -13562,6 +13915,23 @@ function buildWebAppHtml({ pairToken }) {
13562
13915
  font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13563
13916
  font-size: 0.9rem;
13564
13917
  letter-spacing: 0.02em;
13918
+ min-height: 1.25em;
13919
+ transition: opacity 160ms ease, transform 160ms ease;
13920
+ }
13921
+ .boot-splash__hint {
13922
+ max-width: 15rem;
13923
+ margin: -0.35rem 0 0;
13924
+ color: rgba(178, 196, 210, 0.58);
13925
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13926
+ font-size: 0.78rem;
13927
+ line-height: 1.45;
13928
+ opacity: 0;
13929
+ transform: translateY(-0.2rem);
13930
+ transition: opacity 220ms ease, transform 220ms ease;
13931
+ }
13932
+ .boot-splash__hint.is-visible {
13933
+ opacity: 1;
13934
+ transform: translateY(0);
13565
13935
  }
13566
13936
  .boot-splash__dots {
13567
13937
  display: inline-grid;
@@ -13588,6 +13958,8 @@ function buildWebAppHtml({ pairToken }) {
13588
13958
  }
13589
13959
  @media (prefers-reduced-motion: reduce) {
13590
13960
  .boot-splash,
13961
+ .boot-splash__status,
13962
+ .boot-splash__hint,
13591
13963
  .boot-splash__dots span {
13592
13964
  transition: none;
13593
13965
  animation: none;
@@ -13601,12 +13973,23 @@ function buildWebAppHtml({ pairToken }) {
13601
13973
  <div class="boot-splash__card">
13602
13974
  <img class="boot-splash__logo" src="/icons/viveworker-v-pulse.svg" alt="" width="112" height="112" decoding="async">
13603
13975
  <h1 class="boot-splash__title">viveworker</h1>
13604
- <p class="boot-splash__status">Starting</p>
13976
+ <p id="boot-splash-status" class="boot-splash__status">Checking your trusted Wi-Fi...</p>
13977
+ <p id="boot-splash-hint" class="boot-splash__hint" hidden>The first remote connection can take tens of seconds.</p>
13605
13978
  <span class="boot-splash__dots" aria-hidden="true"><span></span><span></span><span></span></span>
13606
13979
  </div>
13607
13980
  </div>
13608
13981
  <div id="app"></div>
13609
- <script type="module" src="/app.js"></script>
13982
+ <script>
13983
+ (() => {
13984
+ const isJa = (navigator.language || "").toLowerCase().startsWith("ja");
13985
+ const message = isJa ? "同じWi-Fi内のPCを確認中..." : "Checking your trusted Wi-Fi...";
13986
+ const status = document.getElementById("boot-splash-status");
13987
+ const splash = document.getElementById("boot-splash");
13988
+ if (status) status.textContent = message;
13989
+ if (splash) splash.setAttribute("aria-label", \`viveworker \${message}\`);
13990
+ })();
13991
+ </script>
13992
+ <script type="module" src="${WEB_APP_SCRIPT_URL}"></script>
13610
13993
  </body>
13611
13994
  </html>`;
13612
13995
  }
@@ -13615,10 +13998,132 @@ function resolvePagePairingToken({ req, config, state, requestedToken }) {
13615
13998
  return resolveManifestPairingToken({ config, state, requestedToken });
13616
13999
  }
13617
14000
 
14001
+ function normalizeBootTraceEvent(event) {
14002
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
14003
+ return null;
14004
+ }
14005
+ const normalized = {
14006
+ tMs: Math.max(0, Math.min(600_000, Math.round(Number(event.tMs) || 0))),
14007
+ type: cleanText(event.type || "").slice(0, 80),
14008
+ };
14009
+ if (!normalized.type) {
14010
+ return null;
14011
+ }
14012
+ const phase = cleanText(event.phase || "").slice(0, 80);
14013
+ const stateValue = cleanText(event.state || "").slice(0, 80);
14014
+ const previousState = cleanText(event.previousState || "").slice(0, 80);
14015
+ const reason = cleanText(event.reason || "").slice(0, 120);
14016
+ const urlPath = cleanText(event.url || "").split("?")[0].slice(0, 120);
14017
+ if (phase) normalized.phase = phase;
14018
+ if (stateValue) normalized.state = stateValue;
14019
+ if (previousState) normalized.previousState = previousState;
14020
+ if (reason) normalized.reason = reason;
14021
+ if (urlPath) normalized.url = urlPath;
14022
+ if (event.sticky === true || event.sticky === false) normalized.sticky = event.sticky;
14023
+ if (Number.isFinite(Number(event.code))) normalized.code = Math.round(Number(event.code));
14024
+ if (event.resumed === true || event.resumed === false) normalized.resumed = event.resumed;
14025
+ return normalized;
14026
+ }
14027
+
14028
+ function formatBootTraceEvent(event) {
14029
+ const parts = [`${event.tMs}ms`, event.type];
14030
+ if (event.phase) parts.push(event.phase);
14031
+ if (event.state) {
14032
+ parts.push(event.previousState ? `${event.previousState}->${event.state}` : event.state);
14033
+ }
14034
+ if (event.url) parts.push(event.url);
14035
+ if (event.reason) parts.push(`reason=${event.reason}`);
14036
+ if (event.sticky === true) parts.push("sticky=1");
14037
+ if (event.code) parts.push(`code=${event.code}`);
14038
+ if (event.resumed === true || event.resumed === false) parts.push(`resumed=${event.resumed ? 1 : 0}`);
14039
+ return parts.join(":");
14040
+ }
14041
+
14042
+ function logRemotePairingBootTrace(body, session, req) {
14043
+ const traceId = cleanText(body?.traceId || "").slice(0, 80) || "unknown";
14044
+ const reason = cleanText(body?.reason || "").slice(0, 80) || "unknown";
14045
+ const appBuildId = cleanText(body?.appBuildId || "").slice(0, 80) || "unknown";
14046
+ const totalMs = Math.max(0, Math.min(600_000, Math.round(Number(body?.totalMs) || 0)));
14047
+ const remoteRouteSeen = body?.remoteRouteSeen === true;
14048
+ const eventList = Array.isArray(body?.events) ? body.events : [];
14049
+ const events = eventList
14050
+ .slice(-90)
14051
+ .map(normalizeBootTraceEvent)
14052
+ .filter(Boolean);
14053
+ const phases = events
14054
+ .filter((event) => event.type === "route" && event.phase)
14055
+ .map((event) => `${event.tMs}:${event.phase}`)
14056
+ .join(" > ");
14057
+ const transport = events
14058
+ .filter((event) => event.phase === "remote-transport-state" || event.phase === "remote-transport-error")
14059
+ .map((event) => {
14060
+ if (event.phase === "remote-transport-error") return `${event.tMs}:error(${event.reason || "unknown"})`;
14061
+ return `${event.tMs}:${event.previousState || "?"}->${event.state || "?"}${event.reason ? `(${event.reason})` : ""}`;
14062
+ })
14063
+ .join(" > ");
14064
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
14065
+ const ua = requestUserAgent(req).slice(0, 90);
14066
+
14067
+ console.log(
14068
+ `[remote-pairing-boot] trace=${traceId} device=${deviceId} reason=${reason} ` +
14069
+ `total=${totalMs}ms remote=${remoteRouteSeen ? 1 : 0} build=${appBuildId} ` +
14070
+ `events=${events.length} ua=${JSON.stringify(ua)}`
14071
+ );
14072
+ if (phases) {
14073
+ console.log(`[remote-pairing-boot-phases] trace=${traceId} ${phases}`);
14074
+ }
14075
+ if (transport) {
14076
+ console.log(`[remote-pairing-boot-transport] trace=${traceId} ${transport}`);
14077
+ }
14078
+ if (totalMs >= 5_000 || remoteRouteSeen) {
14079
+ console.log(`[remote-pairing-boot-detail] trace=${traceId} ${events.map(formatBootTraceEvent).join(" | ")}`);
14080
+ }
14081
+ }
14082
+
14083
+ function recordRemotePairingAudit(event) {
14084
+ appendRemotePairingAuditEvent({
14085
+ atMs: Date.now(),
14086
+ ...event,
14087
+ }).catch((err) => {
14088
+ console.warn(`[remote-pairing-audit] write failed: ${err?.message}`);
14089
+ });
14090
+ }
14091
+
14092
+ function remotePairingAuditFieldsForPairing(pairing) {
14093
+ if (!pairing) return {};
14094
+ return {
14095
+ pairingId: redactShortId(pairing.pairingId),
14096
+ phoneFingerprint: pairing.phoneFingerprint || "",
14097
+ label: pairing.label || "",
14098
+ deviceId: pairing.deviceId || "",
14099
+ };
14100
+ }
14101
+
14102
+ function redactShortId(value) {
14103
+ const text = cleanText(value || "").slice(0, 80);
14104
+ return text ? `${text.slice(0, 6)}…` : "";
14105
+ }
14106
+
14107
+ function relayHostForAudit(value) {
14108
+ try {
14109
+ return new URL(value || DEFAULT_RELAY_URL).host;
14110
+ } catch {
14111
+ return "";
14112
+ }
14113
+ }
14114
+
14115
+ function shouldAutoRotateRemotePairingToken(pairing, now = Date.now()) {
14116
+ if (!pairing) return false;
14117
+ const updatedAt = Number(pairing.relayTokenUpdatedAtMs) || 0;
14118
+ if (!updatedAt) return true;
14119
+ return now - updatedAt >= REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS;
14120
+ }
14121
+
13618
14122
  function createNativeApprovalServer({ config, runtime, state }) {
13619
14123
  const requestHandler = async (req, res) => {
13620
14124
  try {
13621
14125
  const url = new URL(req.url, config.nativeApprovalPublicBaseUrl);
14126
+ refreshPairingConfigFromEnvFile(config);
13622
14127
 
13623
14128
  if (url.pathname === "/health") {
13624
14129
  return writeJson(res, 200, { ok: true });
@@ -13671,7 +14176,16 @@ function createNativeApprovalServer({ config, runtime, state }) {
13671
14176
  url.pathname === "/i18n.js" ||
13672
14177
  url.pathname === "/hazbase-passkey.js" ||
13673
14178
  url.pathname === "/sw.js" ||
13674
- url.pathname.startsWith("/icons/")
14179
+ url.pathname.startsWith("/icons/") ||
14180
+ // Remote-pairing PWA modules + their generated crypto bundle.
14181
+ // The bundle is auto-built by scripts/build-remote-pairing-bundle.mjs
14182
+ // and the per-module wrappers under web/remote-pairing/ import from it.
14183
+ // resolveWebAsset() still enforces the webRoot containment check, so
14184
+ // a wide-prefix allowlist here is safe against path traversal.
14185
+ url.pathname.startsWith("/remote-pairing/") ||
14186
+ url.pathname === "/remote-pairing.bundle.js" ||
14187
+ url.pathname === "/remote-pairing.bundle.js.map" ||
14188
+ url.pathname === "/remote-pairing.bundle.js.LEGAL.txt"
13675
14189
  ) {
13676
14190
  const served = await serveWebAsset(res, url.pathname, req);
13677
14191
  if (served) {
@@ -13847,9 +14361,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
13847
14361
  }
13848
14362
  const sessionPayload = buildSessionPayload({ config, state, session });
13849
14363
  if (!session.authenticated) {
13850
- return writeJson(res, 200, { session: sessionPayload });
14364
+ return writeJson(res, 200, { appBuildId: WEB_APP_BUILD_ID, session: sessionPayload });
13851
14365
  }
13852
14366
  return writeJson(res, 200, {
14367
+ appBuildId: WEB_APP_BUILD_ID,
13853
14368
  session: sessionPayload,
13854
14369
  inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
13855
14370
  timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
@@ -14576,6 +15091,512 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
14576
15091
  return writeJson(res, 200, { ok: true, acceptPublicTasks: accept });
14577
15092
  }
14578
15093
 
15094
+ // ─── Remote pairing (off-LAN PWA relay) ──────────────────────────
15095
+
15096
+ // GET /api/remote-pairing/status — settings UI bootstrap.
15097
+ //
15098
+ // Returns the current orchestrator state plus the (read-only) list of
15099
+ // pairings on disk. Sessions only exist when `enabled: true`; when the
15100
+ // relay is off we still surface the pairings so the user can revoke
15101
+ // before re-enabling.
15102
+ if (url.pathname === "/api/remote-pairing/status" && req.method === "GET") {
15103
+ const session = requireApiSession(req, res, config, state);
15104
+ if (!session) return;
15105
+ const status = getRemotePairingStatus(runtime);
15106
+ let pairings = [];
15107
+ let auditEvents = [];
15108
+ try {
15109
+ const list = await loadPairings();
15110
+ pairings = list.map((p) => ({
15111
+ pairingId: p.pairingId,
15112
+ label: p.label || "",
15113
+ phonePub: p.phonePub,
15114
+ phoneFingerprint: p.phoneFingerprint,
15115
+ deviceId: p.deviceId || null,
15116
+ addedAtMs: p.addedAtMs ?? null,
15117
+ relayTokenUpdatedAtMs: p.relayTokenUpdatedAtMs ?? null,
15118
+ lastSeenAtMs: p.lastSeenAtMs ?? null,
15119
+ }));
15120
+ } catch (err) {
15121
+ console.warn(`[remote-pairing] status: failed to load pairings: ${err?.message}`);
15122
+ }
15123
+ try {
15124
+ auditEvents = await readRemotePairingAuditEvents({ limit: 12 });
15125
+ } catch (err) {
15126
+ console.warn(`[remote-pairing] status: failed to load audit log: ${err?.message}`);
15127
+ }
15128
+ return writeJson(res, 200, {
15129
+ enabled: status.enabled,
15130
+ relayUrl: status.relayUrl,
15131
+ configuredRelayUrl: config.remotePairingRelayUrl || "",
15132
+ identityFingerprint: status.identityFingerprint,
15133
+ identityPubHex: status.identityPubHex,
15134
+ sessions: status.sessions,
15135
+ pairings,
15136
+ auditEvents,
15137
+ pairingsFile: REMOTE_PAIRINGS_FILE,
15138
+ });
15139
+ }
15140
+
15141
+ // POST /api/remote-pairing/boot-trace — PWA cold-start timing.
15142
+ //
15143
+ // This is intentionally diagnostics-only: it does not mutate state and
15144
+ // only logs a sanitized, capped phase timeline so off-LAN boot latency
15145
+ // can be debugged without attaching Safari Web Inspector to the phone.
15146
+ if (url.pathname === "/api/remote-pairing/boot-trace" && req.method === "POST") {
15147
+ const session = requireApiSession(req, res, config, state);
15148
+ if (!session) return;
15149
+ let body;
15150
+ try {
15151
+ body = await parseJsonBody(req);
15152
+ } catch (err) {
15153
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15154
+ }
15155
+ try {
15156
+ logRemotePairingBootTrace(body, session, req);
15157
+ } catch (err) {
15158
+ console.warn(`[remote-pairing-boot] failed to log trace: ${err?.message}`);
15159
+ }
15160
+ return writeJson(res, 200, { ok: true });
15161
+ }
15162
+
15163
+ // POST /api/remote-pairing/toggle — flip on/off without restart.
15164
+ //
15165
+ // Body: { enabled: boolean }
15166
+ // Persists to ~/.viveworker/remote-pairing.env (so the next bridge
15167
+ // launch starts in the new state) and hot-restarts the orchestrator.
15168
+ // The same approval server's request handler is reused, so cookies /
15169
+ // session auth behave identically over LAN HTTP and the relay.
15170
+ if (url.pathname === "/api/remote-pairing/toggle" && req.method === "POST") {
15171
+ const session = requireMutatingApiSession(req, res, config, state);
15172
+ if (!session) return;
15173
+ let body;
15174
+ try {
15175
+ body = await parseJsonBody(req);
15176
+ } catch (err) {
15177
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15178
+ }
15179
+ if (typeof body?.enabled !== "boolean") {
15180
+ return writeJson(res, 400, { error: "enabled-required", message: "body.enabled must be a boolean" });
15181
+ }
15182
+ if (typeof requestHandler !== "function") {
15183
+ return writeJson(res, 503, { error: "request-handler-unavailable" });
15184
+ }
15185
+ const next = body.enabled;
15186
+ config.remotePairingEnabled = next;
15187
+ recordRemotePairingAudit({
15188
+ type: next ? "relay_enabled" : "relay_disabled",
15189
+ outcome: "info",
15190
+ deviceId: session.deviceId || "",
15191
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15192
+ });
15193
+ try {
15194
+ await persistRemotePairingEnv({ enabled: next });
15195
+ } catch (err) {
15196
+ // Persistence failure shouldn't roll back the in-memory config —
15197
+ // the user can retry from the UI and the runtime is still in the
15198
+ // intended state. Surface the error clearly.
15199
+ console.error(`[remote-pairing] env persist failed: ${err?.message}`);
15200
+ }
15201
+ try {
15202
+ await restartRemotePairingRelay({
15203
+ runtime,
15204
+ config,
15205
+ requestListener: requestHandler,
15206
+ logger: console,
15207
+ auditEventSink: recordRemotePairingAudit,
15208
+ });
15209
+ } catch (err) {
15210
+ console.error(`[remote-pairing] hot-restart failed: ${err?.message}`);
15211
+ return writeJson(res, 500, { error: "restart-failed", message: err.message });
15212
+ }
15213
+ const status = getRemotePairingStatus(runtime);
15214
+ return writeJson(res, 200, {
15215
+ ok: true,
15216
+ enabled: status.enabled,
15217
+ relayUrl: status.relayUrl,
15218
+ identityFingerprint: status.identityFingerprint,
15219
+ sessions: status.sessions,
15220
+ });
15221
+ }
15222
+
15223
+ // POST /api/remote-pairing/relay-url — change the relay endpoint.
15224
+ //
15225
+ // Body: { relayUrl: string | null }
15226
+ // - empty string / null → falls back to DEFAULT_RELAY_URL
15227
+ // - non-empty string → must be wss://, except ws:// loopback for local dev
15228
+ // Persists + hot-restarts (only restarts if currently enabled, since
15229
+ // a dormant handle has nothing to reconnect).
15230
+ if (url.pathname === "/api/remote-pairing/relay-url" && req.method === "POST") {
15231
+ const session = requireMutatingApiSession(req, res, config, state);
15232
+ if (!session) return;
15233
+ let body;
15234
+ try {
15235
+ body = await parseJsonBody(req);
15236
+ } catch (err) {
15237
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15238
+ }
15239
+ const raw = body?.relayUrl;
15240
+ const relayUrlResult = normalizeRemotePairingRelayUrl(raw ?? "");
15241
+ if (!relayUrlResult.ok) {
15242
+ return writeJson(res, 400, {
15243
+ error: "invalid-relay-url",
15244
+ message: relayUrlResult.message,
15245
+ });
15246
+ }
15247
+ const normalized = relayUrlResult.value;
15248
+ config.remotePairingRelayUrl = normalized;
15249
+ recordRemotePairingAudit({
15250
+ type: "relay_url_changed",
15251
+ outcome: "info",
15252
+ deviceId: session.deviceId || "",
15253
+ relayHost: relayHostForAudit(normalized || DEFAULT_RELAY_URL),
15254
+ });
15255
+ try {
15256
+ await persistRemotePairingEnv({ relayUrl: normalized || null });
15257
+ } catch (err) {
15258
+ console.error(`[remote-pairing] env persist failed: ${err?.message}`);
15259
+ }
15260
+ if (config.remotePairingEnabled && typeof requestHandler === "function") {
15261
+ try {
15262
+ await restartRemotePairingRelay({
15263
+ runtime,
15264
+ config,
15265
+ requestListener: requestHandler,
15266
+ logger: console,
15267
+ auditEventSink: recordRemotePairingAudit,
15268
+ });
15269
+ } catch (err) {
15270
+ console.error(`[remote-pairing] hot-restart failed: ${err?.message}`);
15271
+ return writeJson(res, 500, { error: "restart-failed", message: err.message });
15272
+ }
15273
+ }
15274
+ const status = getRemotePairingStatus(runtime);
15275
+ return writeJson(res, 200, {
15276
+ ok: true,
15277
+ enabled: status.enabled,
15278
+ relayUrl: status.relayUrl,
15279
+ configuredRelayUrl: config.remotePairingRelayUrl || "",
15280
+ });
15281
+ }
15282
+
15283
+ // POST /api/remote-pairing/revoke — drop a pairing by phonePub.
15284
+ //
15285
+ // Body: { phonePub: string } (64-char hex, the phone's static X25519 pub)
15286
+ // Removes the entry from `~/.viveworker/remote-pairings.json` and
15287
+ // calls reloadNow() so the orchestrator tears the live session down.
15288
+ // No-op if the pub isn't on file (idempotent).
15289
+ if (url.pathname === "/api/remote-pairing/revoke" && req.method === "POST") {
15290
+ const session = requireMutatingApiSession(req, res, config, state);
15291
+ if (!session) return;
15292
+ let body;
15293
+ try {
15294
+ body = await parseJsonBody(req);
15295
+ } catch (err) {
15296
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15297
+ }
15298
+ const phonePub = typeof body?.phonePub === "string" ? body.phonePub.trim().toLowerCase() : "";
15299
+ if (!/^[0-9a-f]{64}$/u.test(phonePub)) {
15300
+ return writeJson(res, 400, {
15301
+ error: "invalid-phone-pub",
15302
+ message: "phonePub must be a 64-char hex string",
15303
+ });
15304
+ }
15305
+ let beforeLen = 0;
15306
+ let afterLen = 0;
15307
+ let removedPairing = null;
15308
+ try {
15309
+ const before = await loadPairings();
15310
+ beforeLen = before.length;
15311
+ removedPairing = before.find((p) => p.phonePub === phonePub) || null;
15312
+ const after = await removePairingPersisted(phonePub);
15313
+ afterLen = after.length;
15314
+ } catch (err) {
15315
+ return writeJson(res, 500, { error: "revoke-failed", message: err.message });
15316
+ }
15317
+ const removed = beforeLen !== afterLen;
15318
+ if (removed && runtime.remotePairingHandle) {
15319
+ try {
15320
+ await runtime.remotePairingHandle.reloadNow();
15321
+ } catch (err) {
15322
+ console.warn(`[remote-pairing] reloadNow after revoke failed: ${err?.message}`);
15323
+ }
15324
+ }
15325
+ if (removed) {
15326
+ recordRemotePairingAudit({
15327
+ type: "pairing_revoked",
15328
+ outcome: "info",
15329
+ deviceId: session.deviceId || "",
15330
+ ...remotePairingAuditFieldsForPairing(removedPairing),
15331
+ });
15332
+ }
15333
+ return writeJson(res, 200, { ok: true, removed, remaining: afterLen });
15334
+ }
15335
+
15336
+ // POST /api/remote-pairing/rotate-token — rotate the relay capability
15337
+ // for the current LAN-reachable paired phone. This endpoint is also
15338
+ // denied inside the relay dispatcher, so an off-LAN phone cannot rotate
15339
+ // itself into a split-brain state where the bridge updated but the PWA
15340
+ // failed to persist the new token.
15341
+ if (url.pathname === "/api/remote-pairing/rotate-token" && req.method === "POST") {
15342
+ const session = requireMutatingApiSession(req, res, config, state);
15343
+ if (!session) return;
15344
+ let body;
15345
+ try {
15346
+ body = await parseJsonBody(req);
15347
+ } catch (err) {
15348
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15349
+ }
15350
+ const phonePub = typeof body?.phonePub === "string" ? body.phonePub.trim().toLowerCase() : "";
15351
+ if (!/^[0-9a-f]{64}$/u.test(phonePub)) {
15352
+ return writeJson(res, 400, {
15353
+ error: "invalid-phone-pub",
15354
+ message: "phonePub must be a 64-char hex string",
15355
+ });
15356
+ }
15357
+
15358
+ let current = [];
15359
+ try {
15360
+ current = await loadPairings();
15361
+ } catch (err) {
15362
+ return writeJson(res, 500, { error: "load-pairings-failed", message: err.message });
15363
+ }
15364
+ const existing = current.find((p) => p.phonePub === phonePub) || null;
15365
+ if (!existing) {
15366
+ return writeJson(res, 404, { error: "pairing-not-found" });
15367
+ }
15368
+ if (existing.deviceId && session.deviceId && existing.deviceId !== session.deviceId) {
15369
+ recordRemotePairingAudit({
15370
+ type: "token_rotate_denied",
15371
+ outcome: "failure",
15372
+ deviceId: session.deviceId || "",
15373
+ reason: "device mismatch",
15374
+ ...remotePairingAuditFieldsForPairing(existing),
15375
+ });
15376
+ return writeJson(res, 403, { error: "device-mismatch" });
15377
+ }
15378
+
15379
+ let rotated;
15380
+ try {
15381
+ ({ rotated } = await rotateRelayTokenPersisted(phonePub));
15382
+ } catch (err) {
15383
+ recordRemotePairingAudit({
15384
+ type: "token_rotate_failed",
15385
+ outcome: "failure",
15386
+ deviceId: session.deviceId || "",
15387
+ reason: err.message,
15388
+ ...remotePairingAuditFieldsForPairing(existing),
15389
+ });
15390
+ return writeJson(res, 500, { error: "rotate-token-failed", message: err.message });
15391
+ }
15392
+ if (!rotated) {
15393
+ return writeJson(res, 404, { error: "pairing-not-found" });
15394
+ }
15395
+
15396
+ if (runtime.remotePairingHandle) {
15397
+ try {
15398
+ await runtime.remotePairingHandle.reloadNow();
15399
+ } catch (err) {
15400
+ console.warn(`[remote-pairing] reloadNow after token rotation failed: ${err?.message}`);
15401
+ }
15402
+ }
15403
+
15404
+ let bridgeKeypair;
15405
+ try {
15406
+ bridgeKeypair = await ensureIdentityKeypair();
15407
+ } catch (err) {
15408
+ return writeJson(res, 500, {
15409
+ error: "bridge-keypair-failed",
15410
+ message: err.message,
15411
+ });
15412
+ }
15413
+ const relayUrl = (config.remotePairingRelayUrl || "").trim() || DEFAULT_RELAY_URL;
15414
+ recordRemotePairingAudit({
15415
+ type: "token_rotated",
15416
+ outcome: "success",
15417
+ deviceId: session.deviceId || "",
15418
+ relayHost: relayHostForAudit(relayUrl),
15419
+ ...remotePairingAuditFieldsForPairing(rotated),
15420
+ });
15421
+
15422
+ return writeJson(res, 200, {
15423
+ ok: true,
15424
+ pairingId: rotated.pairingId,
15425
+ relayToken: rotated.relayToken,
15426
+ phonePub: rotated.phonePub,
15427
+ phoneFingerprint: rotated.phoneFingerprint,
15428
+ deviceId: rotated.deviceId || null,
15429
+ bridgePubHex: bytesToHex(bridgeKeypair.pub),
15430
+ bridgeFingerprint: fingerprintIdentity(bridgeKeypair.pub),
15431
+ relayUrl,
15432
+ label: rotated.label,
15433
+ addedAtMs: rotated.addedAtMs,
15434
+ relayTokenUpdatedAtMs: rotated.relayTokenUpdatedAtMs ?? null,
15435
+ });
15436
+ }
15437
+
15438
+ // POST /api/remote-pairing/lan-enroll — finalize a LAN pairing by
15439
+ // exchanging X25519 static pubkeys so the same phone can later reach
15440
+ // the bridge via the relay (when off-LAN).
15441
+ //
15442
+ // Body: { phonePubHex: string (64-hex), label?: string (≤200 chars) }
15443
+ // Returns: { ok, pairingId, relayToken, phonePub, phoneFingerprint, bridgePubHex,
15444
+ // bridgeFingerprint, relayUrl, label, addedAtMs }
15445
+ //
15446
+ // Side effects:
15447
+ // - Adds (or refreshes) the phone in ~/.viveworker/remote-pairings.json.
15448
+ // Idempotent on phonePub: re-pairing the same device keeps the
15449
+ // existing pairingId so the relay slot doesn't churn (any in-flight
15450
+ // remote session for that phone stays valid).
15451
+ // - Triggers runtime.remotePairingHandle.reloadNow() so the
15452
+ // orchestrator spawns a session for the new phone immediately
15453
+ // when the relay is currently on. Safe no-op when the relay is off
15454
+ // — the next toggle-on will pick up the new entry from disk.
15455
+ // - Calls ensureIdentityKeypair(), which generates the bridge's
15456
+ // static keypair on first run. After this endpoint succeeds the
15457
+ // bridge always has a stable identity.
15458
+ //
15459
+ // Auth: requires an authenticated session. The natural caller is the
15460
+ // LAN-paired phone right after /api/session/pair succeeds — the
15461
+ // session cookie set by that endpoint is what gates this one.
15462
+ if (url.pathname === "/api/remote-pairing/lan-enroll" && req.method === "POST") {
15463
+ const session = requireMutatingApiSession(req, res, config, state);
15464
+ if (!session) return;
15465
+ let body;
15466
+ try {
15467
+ body = await parseJsonBody(req);
15468
+ } catch (err) {
15469
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15470
+ }
15471
+ const phonePubHex = typeof body?.phonePubHex === "string"
15472
+ ? body.phonePubHex.trim().toLowerCase()
15473
+ : "";
15474
+ if (!/^[0-9a-f]{64}$/u.test(phonePubHex)) {
15475
+ return writeJson(res, 400, {
15476
+ error: "invalid-phone-pub",
15477
+ message: "phonePubHex must be a 64-char hex string",
15478
+ });
15479
+ }
15480
+ // Cap label at 200 chars — the persisted JSON has no hard limit but
15481
+ // long strings make the settings UI ugly and there's no legitimate
15482
+ // reason for a device label to exceed this.
15483
+ const label = typeof body?.label === "string"
15484
+ ? body.label.trim().slice(0, 200)
15485
+ : "";
15486
+
15487
+ // Idempotent re-enroll: if the same phone re-pairs (e.g. user
15488
+ // cleared site data on the phone, regenerated the keypair, then
15489
+ // happened to land on the same hex — vanishingly unlikely; far more
15490
+ // commonly: same keypair, fresh label), keep the original pairingId.
15491
+ let existingPairings = [];
15492
+ try {
15493
+ existingPairings = await loadPairings();
15494
+ } catch (err) {
15495
+ return writeJson(res, 500, {
15496
+ error: "load-pairings-failed",
15497
+ message: err.message,
15498
+ });
15499
+ }
15500
+ const existing = existingPairings.find((p) => p.phonePub === phonePubHex);
15501
+ const pairingId = existing?.pairingId || crypto.randomUUID();
15502
+ const autoRotateToken = shouldAutoRotateRemotePairingToken(existing);
15503
+ const relayToken = autoRotateToken ? undefined : existing?.relayToken || undefined;
15504
+ const relayTokenUpdatedAtMs = autoRotateToken ? Date.now() : existing?.relayTokenUpdatedAtMs;
15505
+
15506
+ // Bridge identity — generates on first call. We can't lean on
15507
+ // runtime.remotePairingHandle here because the relay may be OFF
15508
+ // (in which case the handle is dormant and identityKeypair is
15509
+ // null), but enrollment still has to expose a stable bridge pub
15510
+ // so the phone can store it.
15511
+ let bridgeKeypair;
15512
+ try {
15513
+ bridgeKeypair = await ensureIdentityKeypair();
15514
+ } catch (err) {
15515
+ return writeJson(res, 500, {
15516
+ error: "bridge-keypair-failed",
15517
+ message: err.message,
15518
+ });
15519
+ }
15520
+
15521
+ let entry;
15522
+ try {
15523
+ entry = buildPairing({
15524
+ pairingId,
15525
+ relayToken,
15526
+ relayTokenUpdatedAtMs,
15527
+ phonePub: phonePubHex,
15528
+ label,
15529
+ deviceId: session.deviceId,
15530
+ });
15531
+ } catch (err) {
15532
+ return writeJson(res, 400, {
15533
+ error: "build-pairing-failed",
15534
+ message: err.message,
15535
+ });
15536
+ }
15537
+
15538
+ try {
15539
+ await addPairingPersisted(entry);
15540
+ } catch (err) {
15541
+ return writeJson(res, 500, {
15542
+ error: "save-pairings-failed",
15543
+ message: err.message,
15544
+ });
15545
+ }
15546
+
15547
+ // Kick the orchestrator if it's running so the new pairing's
15548
+ // session spawns without waiting for the fs.watch debounce. When
15549
+ // the relay is off this is a no-op (dormant handle's reloadNow is
15550
+ // safe to call).
15551
+ if (runtime.remotePairingHandle) {
15552
+ try {
15553
+ await runtime.remotePairingHandle.reloadNow();
15554
+ } catch (err) {
15555
+ console.warn(`[remote-pairing] reloadNow after lan-enroll failed: ${err?.message}`);
15556
+ }
15557
+ }
15558
+ recordRemotePairingAudit({
15559
+ type: existing ? "pairing_refreshed" : "pairing_enrolled",
15560
+ outcome: "success",
15561
+ deviceId: session.deviceId || "",
15562
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15563
+ ...remotePairingAuditFieldsForPairing(entry),
15564
+ });
15565
+ if (existing && autoRotateToken) {
15566
+ recordRemotePairingAudit({
15567
+ type: "token_auto_rotated",
15568
+ outcome: "success",
15569
+ deviceId: session.deviceId || "",
15570
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15571
+ reason: existing.relayTokenUpdatedAtMs ? "scheduled LAN refresh" : "legacy token metadata",
15572
+ ...remotePairingAuditFieldsForPairing(entry),
15573
+ });
15574
+ }
15575
+
15576
+ const bridgePubHex = bytesToHex(bridgeKeypair.pub);
15577
+ const bridgeFingerprint = fingerprintIdentity(bridgeKeypair.pub);
15578
+ // Relay URL: explicit config value > orchestrator default. We
15579
+ // surface the resolved URL (not the configured one) so the phone
15580
+ // doesn't have to know about the fallback chain.
15581
+ const relayUrl = (config.remotePairingRelayUrl || "").trim() || DEFAULT_RELAY_URL;
15582
+
15583
+ return writeJson(res, 200, {
15584
+ ok: true,
15585
+ pairingId: entry.pairingId,
15586
+ relayToken: entry.relayToken,
15587
+ phonePub: entry.phonePub,
15588
+ phoneFingerprint: entry.phoneFingerprint,
15589
+ deviceId: entry.deviceId || null,
15590
+ bridgePubHex,
15591
+ bridgeFingerprint,
15592
+ relayUrl,
15593
+ label: entry.label,
15594
+ addedAtMs: entry.addedAtMs,
15595
+ relayTokenUpdatedAtMs: entry.relayTokenUpdatedAtMs ?? null,
15596
+ relayTokenRotated: existing ? autoRotateToken : false,
15597
+ });
15598
+ }
15599
+
14579
15600
  // ─── Thread sharing ──────────────────────────────────────────────
14580
15601
 
14581
15602
  function buildThreadShareContent(shareType, body) {
@@ -15925,18 +16946,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15925
16946
 
15926
16947
  const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
15927
16948
  if (apiTimelineImageMatch && req.method === "GET") {
15928
- const session = requireApiSession(req, res, config, state);
15929
- if (!session) {
15930
- return;
15931
- }
15932
16949
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
15933
16950
  const index = Number(apiTimelineImageMatch[2]) || 0;
15934
- const filePath = resolveTimelineEntryImagePath(runtime, token, index);
16951
+ const filePath = resolveTimelineEntryImagePath(runtime, state, token, index);
15935
16952
  if (!filePath) {
15936
16953
  res.statusCode = 404;
15937
16954
  res.end("not-found");
15938
16955
  return;
15939
16956
  }
16957
+ if (!isValidTimelineEntryImageSignature(config, url, token, index, filePath)) {
16958
+ const session = requireApiSession(req, res, config, state);
16959
+ if (!session) {
16960
+ return;
16961
+ }
16962
+ }
15940
16963
  try {
15941
16964
  const body = await fs.readFile(filePath);
15942
16965
  res.statusCode = 200;
@@ -16725,14 +17748,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16725
17748
  }
16726
17749
  };
16727
17750
 
17751
+ let server;
16728
17752
  if (config.webPushEnabled) {
16729
- return createHttpsServer({
17753
+ server = createHttpsServer({
16730
17754
  cert: readFileSync(config.tlsCertFile, "utf8"),
16731
17755
  key: readFileSync(config.tlsKeyFile, "utf8"),
16732
17756
  }, requestHandler);
17757
+ } else {
17758
+ server = createHttpServer(requestHandler);
16733
17759
  }
16734
-
16735
- return createHttpServer(requestHandler);
17760
+ // Expose the handler on the server so the remote-pairing relay client can
17761
+ // reuse it: RPC frames arriving over the encrypted relay channel get
17762
+ // wrapped in synthetic req/res objects and dispatched through the SAME
17763
+ // listener LAN HTTP requests use, so authn/authz/cookies/CSRF behave
17764
+ // identically off-LAN.
17765
+ server.requestHandler = requestHandler;
17766
+ return server;
16736
17767
  }
16737
17768
 
16738
17769
  function requestWantsHtml(req) {
@@ -18036,6 +19067,7 @@ function buildConfig(cli) {
18036
19067
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
18037
19068
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
18038
19069
  return {
19070
+ envFile: resolveEnvFile(cli.envFile),
18039
19071
  dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
18040
19072
  once: cli.once,
18041
19073
  codexHome,
@@ -18050,6 +19082,12 @@ function buildConfig(cli) {
18050
19082
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
18051
19083
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
18052
19084
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
19085
+ // Remote-pairing relay (PWA off-LAN). Off by default — bridges that
19086
+ // never leave the trusted LAN don't need to open an outbound connection
19087
+ // to the relay. Toggle with REMOTE_PAIRING_ENABLED=true in
19088
+ // ~/.viveworker/remote-pairing.env (or any source loadEnvFile reads).
19089
+ remotePairingEnabled: boolEnv("REMOTE_PAIRING_ENABLED", false),
19090
+ remotePairingRelayUrl: remotePairingRelayUrlFromEnv(),
18053
19091
  // share.viveworker.com — HTML hosting backed by the same A2A credentials.
18054
19092
  // Override for staging / self-hosted deployments via VIVEWORKER_SHARE_URL.
18055
19093
  a2aShareUrl: stripTrailingSlash(
@@ -18688,32 +19726,70 @@ function resolveEnvFile(explicitPath) {
18688
19726
  return path.join(workspaceRoot, "viveworker.env");
18689
19727
  }
18690
19728
 
18691
- function loadEnvFile(filePath) {
18692
- try {
18693
- const text = readFileSync(filePath, "utf8");
18694
- for (const rawLine of text.split(/\r?\n/u)) {
18695
- const line = rawLine.trim();
18696
- if (!line || line.startsWith("#")) {
18697
- continue;
18698
- }
19729
+ function parseEnvText(text) {
19730
+ const output = {};
19731
+ for (const rawLine of String(text || "").split(/\r?\n/u)) {
19732
+ const line = rawLine.trim();
19733
+ if (!line || line.startsWith("#")) {
19734
+ continue;
19735
+ }
18699
19736
 
18700
- const separator = line.indexOf("=");
18701
- if (separator === -1) {
18702
- continue;
18703
- }
19737
+ const separator = line.indexOf("=");
19738
+ if (separator === -1) {
19739
+ continue;
19740
+ }
18704
19741
 
18705
- const key = line.slice(0, separator).trim();
18706
- let value = line.slice(separator + 1).trim();
18707
- if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
18708
- value = value.slice(1, -1);
18709
- }
18710
- process.env[key] = value;
19742
+ const key = line.slice(0, separator).trim();
19743
+ let value = line.slice(separator + 1).trim();
19744
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
19745
+ value = value.slice(1, -1);
18711
19746
  }
19747
+ output[key] = value;
19748
+ }
19749
+ return output;
19750
+ }
19751
+
19752
+ function loadEnvFile(filePath) {
19753
+ try {
19754
+ Object.assign(process.env, parseEnvText(readFileSync(filePath, "utf8")));
18712
19755
  } catch {
18713
19756
  // Optional env file.
18714
19757
  }
18715
19758
  }
18716
19759
 
19760
+ function refreshPairingConfigFromEnvFile(config) {
19761
+ if (!config?.envFile) {
19762
+ return false;
19763
+ }
19764
+
19765
+ let entries;
19766
+ try {
19767
+ entries = parseEnvText(readFileSync(config.envFile, "utf8"));
19768
+ } catch {
19769
+ return false;
19770
+ }
19771
+
19772
+ const nextCode = entries.PAIRING_CODE ?? "";
19773
+ const nextToken = entries.PAIRING_TOKEN ?? "";
19774
+ const nextExpiresAtMs = Number(entries.PAIRING_EXPIRES_AT_MS) || 0;
19775
+ if (
19776
+ config.pairingCode === nextCode &&
19777
+ config.pairingToken === nextToken &&
19778
+ Number(config.pairingExpiresAtMs) === nextExpiresAtMs
19779
+ ) {
19780
+ return false;
19781
+ }
19782
+
19783
+ config.pairingCode = nextCode;
19784
+ config.pairingToken = nextToken;
19785
+ config.pairingExpiresAtMs = nextExpiresAtMs;
19786
+ process.env.PAIRING_CODE = nextCode;
19787
+ process.env.PAIRING_TOKEN = nextToken;
19788
+ process.env.PAIRING_EXPIRES_AT_MS = String(nextExpiresAtMs);
19789
+ console.log(`[pairing] refreshed credentials from ${config.envFile}`);
19790
+ return true;
19791
+ }
19792
+
18717
19793
  async function maybeRotateStartupPairingEnv(envFile) {
18718
19794
  if (!truthy(process.env.AUTH_REQUIRED)) {
18719
19795
  return;
@@ -19569,21 +20645,68 @@ function isInlineImagePlaceholderText(value) {
19569
20645
  return cleanText(stripInlineImagePlaceholderMarkup(value)) === "" && /<\/?image\b/iu.test(String(value || ""));
19570
20646
  }
19571
20647
 
20648
+ function stripCodexUserAttachmentEnvelope(value) {
20649
+ const source = String(value || "");
20650
+ if (!/Files mentioned by the user:/iu.test(source) || !/My request for Codex:/iu.test(source)) {
20651
+ return source;
20652
+ }
20653
+
20654
+ const marker = source.match(/\bMy request for Codex:\s*/iu);
20655
+ if (!marker || marker.index == null) {
20656
+ return source;
20657
+ }
20658
+ return source.slice(marker.index + marker[0].length).trim();
20659
+ }
20660
+
19572
20661
  function normalizeTimelineMessageText(value, locale = DEFAULT_LOCALE) {
19573
- return normalizeLongText(replaceTurnAbortedMarkup(stripInlineImagePlaceholderMarkup(value), locale));
20662
+ return normalizeLongText(
20663
+ replaceTurnAbortedMarkup(
20664
+ stripCodexUserAttachmentEnvelope(stripInlineImagePlaceholderMarkup(value)),
20665
+ locale
20666
+ )
20667
+ );
20668
+ }
20669
+
20670
+ function extractTimelineImagePaths(value) {
20671
+ const source = String(value || "");
20672
+ if (!/Files mentioned by the user:/iu.test(source)) {
20673
+ return [];
20674
+ }
20675
+
20676
+ const paths = [];
20677
+ for (const match of source.matchAll(/(\/Users\/[^\n\r]+?\.(?:png|jpe?g|webp|gif|heic|heif))(?:\s|$)/giu)) {
20678
+ const filePath = cleanText(match[1] || "");
20679
+ if (filePath) {
20680
+ paths.push(filePath);
20681
+ }
20682
+ }
20683
+ return normalizeTimelineImagePaths(paths);
19574
20684
  }
19575
20685
 
19576
20686
  function normalizeTimelineImagePaths(value) {
19577
20687
  if (!Array.isArray(value)) {
19578
20688
  return [];
19579
20689
  }
19580
- return value
19581
- .map((entry) => cleanText(entry || ""))
19582
- .filter(Boolean);
20690
+ const output = [];
20691
+ const seen = new Set();
20692
+ for (const entry of value) {
20693
+ const normalized = cleanText(entry || "");
20694
+ if (!normalized || seen.has(normalized)) {
20695
+ continue;
20696
+ }
20697
+ seen.add(normalized);
20698
+ output.push(normalized);
20699
+ }
20700
+ return output;
19583
20701
  }
19584
20702
 
19585
20703
  function normalizeNotificationText(value, locale = DEFAULT_LOCALE) {
19586
- return normalizeLongText(replaceTurnAbortedMarkup(stripNotificationMarkup(value), locale))
20704
+ return normalizeLongText(
20705
+ replaceTurnAbortedMarkup(
20706
+ stripCodexUserAttachmentEnvelope(stripNotificationMarkup(value)),
20707
+ locale
20708
+ )
20709
+ )
19587
20710
  .replace(/\n{2,}/gu, "\n")
19588
20711
  .trim();
19589
20712
  }
@@ -19644,6 +20767,65 @@ function cleanText(value) {
19644
20767
  return String(value || "").replace(/\s+/gu, " ").trim();
19645
20768
  }
19646
20769
 
20770
+ function normalizeRemotePairingRelayUrl(value) {
20771
+ const trimmed = cleanText(value);
20772
+ if (!trimmed) {
20773
+ return { ok: true, value: "" };
20774
+ }
20775
+
20776
+ let parsed;
20777
+ try {
20778
+ parsed = new URL(trimmed);
20779
+ } catch {
20780
+ return {
20781
+ ok: false,
20782
+ message: "relayUrl must be a valid URL",
20783
+ };
20784
+ }
20785
+
20786
+ if (parsed.username || parsed.password) {
20787
+ return {
20788
+ ok: false,
20789
+ message: "relayUrl must not include credentials",
20790
+ };
20791
+ }
20792
+
20793
+ if (parsed.protocol === "wss:") {
20794
+ return { ok: true, value: trimmed };
20795
+ }
20796
+
20797
+ if (parsed.protocol === "ws:" && isLoopbackRelayHost(parsed.hostname)) {
20798
+ return { ok: true, value: trimmed };
20799
+ }
20800
+
20801
+ return {
20802
+ ok: false,
20803
+ message: "relayUrl must use wss://; ws:// is allowed only for localhost or loopback development",
20804
+ };
20805
+ }
20806
+
20807
+ function isLoopbackRelayHost(hostname) {
20808
+ const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/gu, "");
20809
+ return (
20810
+ host === "localhost" ||
20811
+ host.endsWith(".localhost") ||
20812
+ host === "::1" ||
20813
+ /^127(?:\.\d{1,3}){3}$/u.test(host)
20814
+ );
20815
+ }
20816
+
20817
+ function remotePairingRelayUrlFromEnv() {
20818
+ const raw = process.env.REMOTE_PAIRING_RELAY_URL || "";
20819
+ const result = normalizeRemotePairingRelayUrl(raw);
20820
+ if (result.ok) {
20821
+ return result.value;
20822
+ }
20823
+ if (cleanText(raw)) {
20824
+ console.warn(`[remote-pairing] ignoring REMOTE_PAIRING_RELAY_URL: ${result.message}`);
20825
+ }
20826
+ return "";
20827
+ }
20828
+
19647
20829
  function extractTitleOnlyJsonTitle(value) {
19648
20830
  const rawText = String(value ?? "").trim();
19649
20831
  if (!rawText) {
@@ -20068,6 +21250,38 @@ async function main() {
20068
21250
  }
20069
21251
  }
20070
21252
 
21253
+ // --- Remote-pairing relay (PWA off-LAN) ---
21254
+ // Optional. When enabled, the bridge keeps an outbound WebSocket open per
21255
+ // paired phone (`~/.viveworker/remote-pairings.json`) to the remote-pairing
21256
+ // relay (Cloudflare Worker + Durable Object). RPC frames arriving over
21257
+ // those channels are dispatched through the same `requestHandler` LAN HTTP
21258
+ // uses, so cookies / auth / CSRF behave identically off-LAN.
21259
+ //
21260
+ // If disabled (default), the orchestrator returns a dormant handle and
21261
+ // does no I/O. Pairings file changes are auto-reloaded via fs.watch — no
21262
+ // outer loop needed here.
21263
+ if (approvalServer && approvalServer.requestHandler) {
21264
+ try {
21265
+ runtime.remotePairingHandle = await startRemotePairingRelay({
21266
+ enabled: config.remotePairingEnabled,
21267
+ relayUrl: config.remotePairingRelayUrl || undefined,
21268
+ requestListener: approvalServer.requestHandler,
21269
+ logger: console,
21270
+ auditEventSink: recordRemotePairingAudit,
21271
+ });
21272
+ const status = runtime.remotePairingHandle.getStatus();
21273
+ if (status.enabled) {
21274
+ console.log(
21275
+ `[remote-pairing] connected relay=${status.relayUrl} ` +
21276
+ `identity=${status.identityFingerprint} ` +
21277
+ `paired=${status.sessions.length}`
21278
+ );
21279
+ }
21280
+ } catch (err) {
21281
+ console.error(`[remote-pairing] startup failed: ${err?.message}`);
21282
+ }
21283
+ }
21284
+
20071
21285
  let lastA2aEnvCheckAt = 0;
20072
21286
  const A2A_ENV_CHECK_INTERVAL_MS = 30_000; // check every 30 seconds
20073
21287
 
@@ -20154,6 +21368,13 @@ async function main() {
20154
21368
 
20155
21369
  stopRelayPolling();
20156
21370
 
21371
+ if (runtime.remotePairingHandle) {
21372
+ try {
21373
+ runtime.remotePairingHandle.close();
21374
+ } catch {}
21375
+ runtime.remotePairingHandle = null;
21376
+ }
21377
+
20157
21378
  if (runtime.ipcClient) {
20158
21379
  runtime.ipcClient.stop();
20159
21380
  }