viveworker 0.7.0 → 0.8.1

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 (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
@@ -16,11 +16,18 @@ import zlib from "node:zlib";
16
16
  import webPush from "web-push";
17
17
  import * as hazbaseAuth from "@hazbase/auth";
18
18
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
19
+ import { APP_BUILD_ID as WEB_APP_BUILD_ID } from "../web/build-id.js";
19
20
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
20
21
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
21
22
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
22
23
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
23
24
  import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems } from "./moltbook-api.mjs";
25
+ import { startRemotePairingRelay, DEFAULT_RELAY_URL } from "./lib/remote-pairing/orchestrator.mjs";
26
+ import { restartRemotePairingRelay, persistRemotePairingEnv, getRemotePairingStatus } from "./lib/remote-pairing/control.mjs";
27
+ import { appendRemotePairingAuditEvent, readRemotePairingAuditEvents } from "./lib/remote-pairing/audit.mjs";
28
+ import { loadPairings, removePairingPersisted, addPairingPersisted, rotateRelayTokenPersisted, buildPairing, REMOTE_PAIRINGS_FILE } from "./lib/remote-pairing/pairings.mjs";
29
+ import { ensureIdentityKeypair } from "./lib/remote-pairing/keys.mjs";
30
+ import { fingerprintIdentity, bytesToHex } from "./lib/remote-pairing/keys-core.mjs";
24
31
 
25
32
  const __filename = fileURLToPath(import.meta.url);
26
33
  const __dirname = path.dirname(__filename);
@@ -42,6 +49,8 @@ const listSupportedPayments = typeof hazbaseAuth.listSupportedPayments === "func
42
49
  ? hazbaseAuth.listSupportedPayments.bind(hazbaseAuth)
43
50
  : async () => ({ networks: [], defaultNetwork: "base-sepolia" });
44
51
  const appPackageVersion = readPackageVersion();
52
+ const WEB_APP_SCRIPT_URL = `/app.js?v=${WEB_APP_BUILD_ID}`;
53
+ const WEB_APP_BUILD_ID_PLACEHOLDER = "__VIVEWORKER_APP_BUILD_ID__";
45
54
  const sessionCookieName = "viveworker_session";
46
55
  const deviceCookieName = "viveworker_device";
47
56
  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 +61,7 @@ const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
52
61
  const MAX_PAIRED_DEVICES = 200;
53
62
  const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
54
63
  const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
64
+ const REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS = 30 * 24 * 60 * 60 * 1000;
55
65
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
56
66
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
57
67
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
@@ -209,6 +219,7 @@ const cli = parseCliArgs(process.argv.slice(2));
209
219
  const envFile = resolveEnvFile(cli.envFile);
210
220
  loadEnvFile(envFile);
211
221
  loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
222
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "remote-pairing.env"));
212
223
  await maybeRotateStartupPairingEnv(envFile);
213
224
 
214
225
  const config = buildConfig(cli);
@@ -261,6 +272,7 @@ const runtime = {
261
272
  recentCodeEvents: [],
262
273
  pairingAttemptsByRemoteAddress: new Map(),
263
274
  ipcClient: null,
275
+ remotePairingHandle: null,
264
276
  stopping: false,
265
277
  // In-memory cache for the `/api/share/status` endpoint. A 30s TTL avoids
266
278
  // hammering the share worker on every settings-page render. Purely runtime —
@@ -475,6 +487,11 @@ function buildSessionLocalePayload(config, state, deviceId) {
475
487
  a2aShareEnabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
476
488
  a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
477
489
  a2aExecutorPreference: state.a2aExecutorPreference || "ask",
490
+ // Remote pairing is always "available" — the row appears in Settings →
491
+ // Integrations regardless of whether the relay is currently enabled, so
492
+ // users can find the toggle. The actual enabled/connected state is read
493
+ // from /api/remote-pairing/status.
494
+ remotePairingAvailable: true,
478
495
  };
479
496
  }
480
497
 
@@ -808,6 +825,21 @@ function normalizeTimelineFileRefs(rawFileRefs) {
808
825
  return deduped;
809
826
  }
810
827
 
828
+ function filterTimelineFileRefsForImagePaths(fileRefs, imagePaths) {
829
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs);
830
+ const normalizedImages = normalizeTimelineImagePaths(imagePaths);
831
+ if (normalizedRefs.length === 0 || normalizedImages.length === 0) {
832
+ return normalizedRefs;
833
+ }
834
+
835
+ const imageRefKeys = new Set();
836
+ for (const imagePath of normalizedImages) {
837
+ imageRefKeys.add(imagePath);
838
+ imageRefKeys.add(path.basename(imagePath));
839
+ }
840
+ return normalizedRefs.filter((fileRef) => !imageRefKeys.has(fileRef) && !imageRefKeys.has(path.basename(fileRef)));
841
+ }
842
+
811
843
  function cleanTimelineFileRef(value) {
812
844
  let normalized = cleanText(value || "");
813
845
  if (!normalized) {
@@ -2808,6 +2840,7 @@ function normalizeProvider(value) {
2808
2840
  if (normalized === "moltbook") return "moltbook";
2809
2841
  if (normalized === "a2a") return "a2a";
2810
2842
  if (normalized === "viveworker") return "viveworker";
2843
+ if (normalized === "mcp") return "mcp";
2811
2844
  return "codex";
2812
2845
  }
2813
2846
 
@@ -2817,6 +2850,7 @@ function providerDisplayName(locale, provider) {
2817
2850
  if (p === "moltbook") return "Moltbook";
2818
2851
  if (p === "a2a") return "A2A";
2819
2852
  if (p === "viveworker") return t(locale, "common.appName");
2853
+ if (p === "mcp") return "MCP";
2820
2854
  return t(locale, "common.codex");
2821
2855
  }
2822
2856
 
@@ -2865,7 +2899,7 @@ function normalizeHistoryItem(raw) {
2865
2899
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
2866
2900
  const rawThreadLabel = cleanText(raw.threadLabel ?? "");
2867
2901
  const historyProvider = normalizeProvider(raw.provider);
2868
- const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker"
2902
+ const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker" || historyProvider === "mcp"
2869
2903
  || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
2870
2904
  const threadLabel = skipHistoryThreadLabelRewrite
2871
2905
  ? rawThreadLabel
@@ -2874,7 +2908,8 @@ function normalizeHistoryItem(raw) {
2874
2908
  const title =
2875
2909
  (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2876
2910
  (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2877
- const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
2911
+ const rawMessageText = raw.messageText ?? "";
2912
+ const messageText = normalizeTimelineMessageText(rawMessageText);
2878
2913
  const summary = normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "";
2879
2914
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
2880
2915
  if (!stableId || !historyKinds.has(kind) || !title) {
@@ -2882,6 +2917,14 @@ function normalizeHistoryItem(raw) {
2882
2917
  }
2883
2918
 
2884
2919
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2920
+ const imagePaths = normalizeTimelineImagePaths([
2921
+ ...(Array.isArray(raw.imagePaths ?? raw.localImagePaths) ? (raw.imagePaths ?? raw.localImagePaths) : []),
2922
+ ...extractTimelineImagePaths(rawMessageText),
2923
+ ]);
2924
+ const fileRefs = filterTimelineFileRefsForImagePaths(
2925
+ normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
2926
+ imagePaths
2927
+ );
2885
2928
 
2886
2929
  const normalized = {
2887
2930
  stableId,
@@ -2892,8 +2935,8 @@ function normalizeHistoryItem(raw) {
2892
2935
  threadLabel,
2893
2936
  summary,
2894
2937
  messageText,
2895
- imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
2896
- fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
2938
+ imagePaths,
2939
+ fileRefs,
2897
2940
  diffText: normalizeTimelineDiffText(raw.diffText ?? ""),
2898
2941
  diffSource: normalizeTimelineDiffSource(raw.diffSource ?? ""),
2899
2942
  diffAvailable: raw.diffAvailable === true || Boolean(raw.diffText),
@@ -3127,7 +3170,8 @@ function normalizeTimelineEntry(raw) {
3127
3170
  }
3128
3171
 
3129
3172
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
3130
- const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
3173
+ const rawMessageText = raw.messageText ?? "";
3174
+ const messageText = normalizeTimelineMessageText(rawMessageText);
3131
3175
  const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3132
3176
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
3133
3177
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
@@ -3142,7 +3186,7 @@ function normalizeTimelineEntry(raw) {
3142
3186
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
3143
3187
  "";
3144
3188
  const rawProvider = normalizeProvider(raw.provider);
3145
- const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker"
3189
+ const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker" || rawProvider === "mcp"
3146
3190
  || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
3147
3191
  const threadLabel =
3148
3192
  skipThreadLabelRewrite
@@ -3162,6 +3206,14 @@ function normalizeTimelineEntry(raw) {
3162
3206
  threadLabel ||
3163
3207
  kindTitle(DEFAULT_LOCALE, kind);
3164
3208
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
3209
+ const imagePaths = normalizeTimelineImagePaths([
3210
+ ...(Array.isArray(raw.imagePaths ?? raw.localImagePaths) ? (raw.imagePaths ?? raw.localImagePaths) : []),
3211
+ ...extractTimelineImagePaths(rawMessageText),
3212
+ ]);
3213
+ const fileRefs = filterTimelineFileRefsForImagePaths(
3214
+ normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
3215
+ imagePaths
3216
+ );
3165
3217
 
3166
3218
  const normalized = {
3167
3219
  stableId,
@@ -3174,8 +3226,8 @@ function normalizeTimelineEntry(raw) {
3174
3226
  messageText,
3175
3227
  fileEventType,
3176
3228
  previousFileRefs: normalizeTimelineFileRefs(raw.previousFileRefs ?? []),
3177
- imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
3178
- fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
3229
+ imagePaths,
3230
+ fileRefs,
3179
3231
  diffText,
3180
3232
  diffSource,
3181
3233
  diffAvailable: raw.diffAvailable === true || Boolean(diffText),
@@ -4305,7 +4357,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4305
4357
  if (block?.type === "text" && block.text) text += block.text;
4306
4358
  }
4307
4359
  }
4308
- text = cleanText(text);
4360
+ // Preserve paragraph structure for the renderer — `cleanText` collapses
4361
+ // every \s+ (newlines included) into a single space, which would flatten
4362
+ // markdown tables / code fences / lists into a single illegible line.
4363
+ text = normalizeLongText(text);
4309
4364
  if (!text) continue;
4310
4365
  if (type === "user" && record.entrypoint === "sdk-cli") {
4311
4366
  const derivedThreadLabel = deriveClaudeSdkCliThreadLabel(text, threadId);
@@ -5181,8 +5236,8 @@ async function readRecentRolloutUserMessagesWithImages({ filePath, maxBytes }) {
5181
5236
  async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5182
5237
  const conditions = [
5183
5238
  `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"%'`,
5239
+ `target IN ('codex_api::endpoint::responses_websocket', 'codex_api::sse::responses')`,
5240
+ `(feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%' OR feedback_log_body LIKE '%SSE event: {"type":"response.output_item.done"%')`,
5186
5241
  `feedback_log_body LIKE '%"type":"message"%'`,
5187
5242
  `feedback_log_body LIKE '%"role":"assistant"%'`,
5188
5243
  ];
@@ -5192,28 +5247,50 @@ async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5192
5247
  }
5193
5248
 
5194
5249
  const sql = `
5195
- SELECT id, ts, thread_id, feedback_log_body
5250
+ SELECT
5251
+ logs.id,
5252
+ logs.ts,
5253
+ logs.thread_id,
5254
+ logs.feedback_log_body,
5255
+ (
5256
+ SELECT ctx.feedback_log_body
5257
+ FROM logs AS ctx
5258
+ WHERE ctx.id < logs.id
5259
+ AND ctx.id >= logs.id - 5
5260
+ AND ctx.target = 'codex_otel.log_only'
5261
+ AND ctx.feedback_log_body LIKE '%conversation.id=%'
5262
+ ORDER BY ctx.id DESC
5263
+ LIMIT 1
5264
+ ) AS context_log_body
5196
5265
  FROM logs
5197
5266
  WHERE ${conditions.join("\n AND ")}
5198
- ORDER BY id ASC
5267
+ ORDER BY logs.id ASC
5199
5268
  LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
5200
5269
  `;
5201
5270
 
5202
5271
  return runSqliteJsonQuery(logsDbFile, sql);
5203
5272
  }
5204
5273
 
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;
5274
+ function parseResponsesEventPayload(body) {
5275
+ const raw = String(body || "");
5276
+ for (const marker of ["websocket event: ", "SSE event: "]) {
5277
+ const markerIndex = raw.indexOf(marker);
5278
+ if (markerIndex === -1) {
5279
+ continue;
5280
+ }
5281
+ try {
5282
+ return JSON.parse(raw.slice(markerIndex + marker.length));
5283
+ } catch {
5284
+ return null;
5285
+ }
5211
5286
  }
5287
+ return null;
5288
+ }
5212
5289
 
5213
- let payload;
5214
- try {
5215
- payload = JSON.parse(body.slice(markerIndex + marker.length));
5216
- } catch {
5290
+ function buildSqliteTimelineEntry({ row, config, runtime }) {
5291
+ const body = String(row?.feedback_log_body ?? "");
5292
+ const payload = parseResponsesEventPayload(body);
5293
+ if (!payload) {
5217
5294
  return null;
5218
5295
  }
5219
5296
 
@@ -5237,7 +5314,12 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
5237
5314
  return null;
5238
5315
  }
5239
5316
 
5240
- const threadId = cleanText(row.thread_id || extractThreadIdFromLogBody(body));
5317
+ const contextLogBody = String(row?.context_log_body ?? "");
5318
+ const threadId = cleanText(
5319
+ row.thread_id ||
5320
+ extractThreadIdFromLogBody(body) ||
5321
+ extractThreadIdFromLogBody(contextLogBody)
5322
+ );
5241
5323
  if (!threadId) {
5242
5324
  return null;
5243
5325
  }
@@ -5365,6 +5447,25 @@ async function findReplyUploadFallback(config, sourcePath, usedPaths = new Set()
5365
5447
  return candidates[0]?.filePath || "";
5366
5448
  }
5367
5449
 
5450
+ function isFileAccessDeniedError(error) {
5451
+ const code = cleanText(error?.code || "");
5452
+ return code === "EACCES" || code === "EPERM";
5453
+ }
5454
+
5455
+ async function copyFileWithSystemCp(sourcePath, destinationPath) {
5456
+ return new Promise((resolve, reject) => {
5457
+ const child = spawn("/bin/cp", ["-p", sourcePath, destinationPath], { stdio: "ignore" });
5458
+ child.once("error", reject);
5459
+ child.once("exit", (code, signal) => {
5460
+ if (code === 0) {
5461
+ resolve(true);
5462
+ return;
5463
+ }
5464
+ reject(new Error(`cp exited with ${signal || code}`));
5465
+ });
5466
+ });
5467
+ }
5468
+
5368
5469
  async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
5369
5470
  const normalizedSourcePath = resolvePath(cleanText(sourcePath || ""));
5370
5471
  if (!normalizedSourcePath) {
@@ -5381,6 +5482,16 @@ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
5381
5482
  await fs.copyFile(normalizedSourcePath, destinationPath);
5382
5483
  return destinationPath;
5383
5484
  } catch (error) {
5485
+ if (isFileAccessDeniedError(error)) {
5486
+ try {
5487
+ await copyFileWithSystemCp(normalizedSourcePath, destinationPath);
5488
+ return destinationPath;
5489
+ } catch (fallbackError) {
5490
+ await fs.rm(destinationPath, { force: true }).catch(() => {});
5491
+ console.warn(`[timeline-image-copy-skipped] ${error?.message || error}; cp fallback failed: ${fallbackError?.message || fallbackError}`);
5492
+ return "";
5493
+ }
5494
+ }
5384
5495
  console.warn(`[timeline-image-copy-skipped] ${error?.message || error}`);
5385
5496
  return "";
5386
5497
  }
@@ -5393,8 +5504,12 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5393
5504
  }
5394
5505
 
5395
5506
  const aliases = isPlainObject(state.timelineImagePathAliases) ? state.timelineImagePathAliases : (state.timelineImagePathAliases = {});
5507
+ const copyFailures = isPlainObject(state.timelineImagePathCopyFailures)
5508
+ ? state.timelineImagePathCopyFailures
5509
+ : (state.timelineImagePathCopyFailures = {});
5396
5510
  const usedFallbacks = new Set();
5397
5511
  const nextPaths = [];
5512
+ const copyRetryMs = 10 * 60 * 1000;
5398
5513
 
5399
5514
  for (const rawPath of normalizedImagePaths) {
5400
5515
  const normalizedPath = cleanText(rawPath || "");
@@ -5404,24 +5519,38 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5404
5519
 
5405
5520
  const aliasedPath = cleanText(aliases[normalizedPath] || "");
5406
5521
  if (aliasedPath) {
5407
- try {
5408
- await fs.access(aliasedPath);
5409
- nextPaths.push(aliasedPath);
5410
- continue;
5411
- } catch {
5412
- // Fall through and repair below.
5522
+ if (aliasedPath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
5523
+ try {
5524
+ await fs.access(aliasedPath);
5525
+ nextPaths.push(aliasedPath);
5526
+ continue;
5527
+ } catch {
5528
+ // Fall through and repair below.
5529
+ }
5413
5530
  }
5414
5531
  }
5415
5532
 
5533
+ const lastCopyFailureMs = Math.max(0, Number(copyFailures[normalizedPath]) || 0);
5534
+ if (
5535
+ lastCopyFailureMs > 0 &&
5536
+ Date.now() - lastCopyFailureMs < copyRetryMs &&
5537
+ !normalizedPath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)
5538
+ ) {
5539
+ nextPaths.push(normalizedPath);
5540
+ continue;
5541
+ }
5542
+
5416
5543
  let existingSourcePath = normalizedPath;
5417
5544
  try {
5418
5545
  await fs.access(existingSourcePath);
5419
- } catch {
5420
- existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
5421
- if (!existingSourcePath) {
5422
- continue;
5546
+ } catch (error) {
5547
+ if (!isFileAccessDeniedError(error)) {
5548
+ existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
5549
+ if (!existingSourcePath) {
5550
+ continue;
5551
+ }
5552
+ usedFallbacks.add(existingSourcePath);
5423
5553
  }
5424
- usedFallbacks.add(existingSourcePath);
5425
5554
  }
5426
5555
 
5427
5556
  let persistentPath = existingSourcePath;
@@ -5429,6 +5558,11 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5429
5558
  persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath) || existingSourcePath;
5430
5559
  }
5431
5560
 
5561
+ if (persistentPath === existingSourcePath && !existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
5562
+ copyFailures[normalizedPath] = Date.now();
5563
+ } else {
5564
+ delete copyFailures[normalizedPath];
5565
+ }
5432
5566
  aliases[normalizedPath] = persistentPath;
5433
5567
  nextPaths.push(persistentPath);
5434
5568
  }
@@ -5775,8 +5909,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
5775
5909
  async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5776
5910
  const conditions = [
5777
5911
  `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"%'`,
5912
+ `target IN ('codex_api::endpoint::responses_websocket', 'codex_api::sse::responses')`,
5913
+ `(feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%' OR feedback_log_body LIKE '%SSE event: {"type":"response.output_item.done"%')`,
5780
5914
  `feedback_log_body LIKE '%"type":"message"%'`,
5781
5915
  `feedback_log_body LIKE '%"role":"assistant"%'`,
5782
5916
  `feedback_log_body LIKE '%"phase":"final_answer"%'`,
@@ -5787,10 +5921,24 @@ async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 })
5787
5921
  }
5788
5922
 
5789
5923
  const sql = `
5790
- SELECT id, ts, thread_id, feedback_log_body
5924
+ SELECT
5925
+ logs.id,
5926
+ logs.ts,
5927
+ logs.thread_id,
5928
+ logs.feedback_log_body,
5929
+ (
5930
+ SELECT ctx.feedback_log_body
5931
+ FROM logs AS ctx
5932
+ WHERE ctx.id < logs.id
5933
+ AND ctx.id >= logs.id - 5
5934
+ AND ctx.target = 'codex_otel.log_only'
5935
+ AND ctx.feedback_log_body LIKE '%conversation.id=%'
5936
+ ORDER BY ctx.id DESC
5937
+ LIMIT 1
5938
+ ) AS context_log_body
5791
5939
  FROM logs
5792
5940
  WHERE ${conditions.join("\n AND ")}
5793
- ORDER BY id ASC
5941
+ ORDER BY logs.id ASC
5794
5942
  LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
5795
5943
  `;
5796
5944
 
@@ -5836,16 +5984,8 @@ async function runSqliteJsonQuery(dbFile, sql) {
5836
5984
 
5837
5985
  function buildSqliteCompletionEvent({ row, config, runtime }) {
5838
5986
  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 {
5987
+ const payload = parseResponsesEventPayload(body);
5988
+ if (!payload) {
5849
5989
  return null;
5850
5990
  }
5851
5991
 
@@ -5858,7 +5998,12 @@ function buildSqliteCompletionEvent({ row, config, runtime }) {
5858
5998
  return null;
5859
5999
  }
5860
6000
 
5861
- const threadId = cleanText(row.thread_id || extractThreadIdFromLogBody(body));
6001
+ const contextLogBody = String(row?.context_log_body ?? "");
6002
+ const threadId = cleanText(
6003
+ row.thread_id ||
6004
+ extractThreadIdFromLogBody(body) ||
6005
+ extractThreadIdFromLogBody(contextLogBody)
6006
+ );
5862
6007
  const turnId = cleanText(extractTurnIdFromLogBody(body) || item.id || row.id);
5863
6008
  if (!threadId || !turnId) {
5864
6009
  return null;
@@ -8251,6 +8396,12 @@ function normalizeIpcErrorMessage(errorValue) {
8251
8396
  return cleanText(String(errorValue ?? "")) || "ipc-request-failed";
8252
8397
  }
8253
8398
 
8399
+ function isStartTurnAckTimeout(errorValue) {
8400
+ const message = normalizeIpcErrorMessage(errorValue);
8401
+ return message === "thread-follower-start-turn-timeout" ||
8402
+ message === "turn/start-timeout";
8403
+ }
8404
+
8254
8405
  function buildDefaultCollaborationMode(threadState) {
8255
8406
  // Fallback turns must leave Plan mode unless the caller explicitly opts in.
8256
8407
  return buildRequestedCollaborationMode(threadState, "default");
@@ -8942,6 +9093,14 @@ function deriveClaudeSdkCliThreadLabel(messageText, conversationId = "") {
8942
9093
  if (/^You are scoring Moltbook posts? for an AI agent\b/iu.test(single)) {
8943
9094
  return "Moltbook scoring";
8944
9095
  }
9096
+ if (/^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9097
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single)) {
9098
+ return "Moltbook reply drafting";
9099
+ }
9100
+ if (/^You are composing an original post on Moltbook\b/iu.test(single) ||
9101
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)) {
9102
+ return "Moltbook post drafting";
9103
+ }
8945
9104
  if (/^Codex from another agent:/iu.test(single)) {
8946
9105
  return truncate(cleanText(single.replace(/^Codex from another agent:\s*/iu, "")), 90) || "Cross-agent task";
8947
9106
  }
@@ -8962,6 +9121,46 @@ function isHiddenClaudeInternalScoringText(text) {
8962
9121
  return /^You are scoring Moltbook posts? for an AI agent\b/iu.test(single);
8963
9122
  }
8964
9123
 
9124
+ function isMoltbookHarnessThreadLabel(text) {
9125
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9126
+ if (!single) {
9127
+ return false;
9128
+ }
9129
+ return (
9130
+ single === "Moltbook reply drafting" ||
9131
+ single === "Moltbook post drafting" ||
9132
+ /^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9133
+ /^You are composing an original post on Moltbook\b/iu.test(single) ||
9134
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single) ||
9135
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)
9136
+ );
9137
+ }
9138
+
9139
+ function isHiddenMoltbookHarnessPromptText(text) {
9140
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9141
+ if (!single) {
9142
+ return false;
9143
+ }
9144
+ return (
9145
+ /^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9146
+ /^You are composing an original post on Moltbook\b/iu.test(single) ||
9147
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single) ||
9148
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)
9149
+ );
9150
+ }
9151
+
9152
+ function isHiddenMoltbookHarnessOutputText(text) {
9153
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9154
+ if (!single) {
9155
+ return false;
9156
+ }
9157
+ return (
9158
+ /^INTENT:\s+.+\s+---\s+.+/isu.test(single) ||
9159
+ /^SUBMOLT:\s+.+\s+TITLE:\s+.+\s+INTENT:\s+.+\s+---\s+.+/isu.test(single) ||
9160
+ single === "NO_MATCH"
9161
+ );
9162
+ }
9163
+
8965
9164
  function isHiddenCodexApprovalAssessmentText(text) {
8966
9165
  const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
8967
9166
  if (!single) {
@@ -8971,7 +9170,7 @@ function isHiddenCodexApprovalAssessmentText(text) {
8971
9170
  }
8972
9171
 
8973
9172
  function shouldHideInternalTimelineItem(item) {
8974
- return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item);
9173
+ return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item) || shouldHideMoltbookHarnessItem(item);
8975
9174
  }
8976
9175
 
8977
9176
  function shouldHideClaudeInternalItem(item) {
@@ -8993,6 +9192,29 @@ function shouldHideClaudeInternalItem(item) {
8993
9192
  );
8994
9193
  }
8995
9194
 
9195
+ function shouldHideMoltbookHarnessItem(item) {
9196
+ if (!isPlainObject(item)) {
9197
+ return false;
9198
+ }
9199
+ const provider = normalizeProvider(item.provider);
9200
+ if (provider !== "claude" && provider !== "codex") {
9201
+ return false;
9202
+ }
9203
+
9204
+ return (
9205
+ isMoltbookHarnessThreadLabel(item.threadLabel) ||
9206
+ isMoltbookHarnessThreadLabel(item.title) ||
9207
+ isHiddenMoltbookHarnessPromptText(item.messageText) ||
9208
+ isHiddenMoltbookHarnessPromptText(item.summary) ||
9209
+ isHiddenMoltbookHarnessPromptText(item.detailText) ||
9210
+ isHiddenMoltbookHarnessPromptText(item.message) ||
9211
+ isHiddenMoltbookHarnessOutputText(item.messageText) ||
9212
+ isHiddenMoltbookHarnessOutputText(item.summary) ||
9213
+ isHiddenMoltbookHarnessOutputText(item.detailText) ||
9214
+ isHiddenMoltbookHarnessOutputText(item.message)
9215
+ );
9216
+ }
9217
+
8996
9218
  function shouldSuppressInternalScannedEvent(event) {
8997
9219
  if (!isPlainObject(event)) {
8998
9220
  return false;
@@ -9811,6 +10033,16 @@ function base64UrlDecode(value) {
9811
10033
  return Buffer.from(String(value), "base64url").toString("utf8");
9812
10034
  }
9813
10035
 
10036
+ function constantTimeStringEqual(a, b) {
10037
+ // Pad-then-compare so the timing leak is bounded by the longer string's
10038
+ // length rather than the matching prefix. Caller still gets a clean
10039
+ // boolean — no behavioural difference from `===`.
10040
+ const left = Buffer.from(String(a ?? ""), "utf8");
10041
+ const right = Buffer.from(String(b ?? ""), "utf8");
10042
+ if (left.length !== right.length) return false;
10043
+ return crypto.timingSafeEqual(left, right);
10044
+ }
10045
+
9814
10046
  function signSessionPayload(payload, secret) {
9815
10047
  const encoded = base64UrlEncode(JSON.stringify(payload));
9816
10048
  const signature = crypto.createHmac("sha256", secret).update(encoded).digest("base64url");
@@ -10137,6 +10369,35 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
10137
10369
  };
10138
10370
  }
10139
10371
 
10372
+ function inferLegacyRelayDeviceId(state, config, pairing) {
10373
+ const active = activeTrustedDevices(state, config);
10374
+ if (active.length === 1) {
10375
+ return active[0].deviceId;
10376
+ }
10377
+
10378
+ const addedAtMs = Number(pairing?.addedAtMs) || 0;
10379
+ if (addedAtMs <= 0) {
10380
+ return "";
10381
+ }
10382
+ const nearby = active.filter(({ record }) => {
10383
+ const pairedAtMs = Number(record?.pairedAtMs) || 0;
10384
+ return pairedAtMs > 0 && Math.abs(pairedAtMs - addedAtMs) <= 10 * 60 * 1000;
10385
+ });
10386
+ return nearby.length === 1 ? nearby[0].deviceId : "";
10387
+ }
10388
+
10389
+ function resolveRelaySessionDeviceId(state, config, pairing, fallbackDeviceId) {
10390
+ const explicit = cleanText(pairing?.deviceId || "");
10391
+ if (explicit) {
10392
+ return explicit;
10393
+ }
10394
+ const inferred = inferLegacyRelayDeviceId(state, config, pairing);
10395
+ if (inferred) {
10396
+ return inferred;
10397
+ }
10398
+ return cleanText(pairing?.phoneFingerprint || "") || cleanText(fallbackDeviceId || "") || null;
10399
+ }
10400
+
10140
10401
  // Shared between `/api/session` and `/api/bootstrap` so both return an
10141
10402
  // identical session shape.
10142
10403
  function buildSessionPayload({ config, state, session }) {
@@ -10147,6 +10408,7 @@ function buildSessionPayload({ config, state, session }) {
10147
10408
  webPushEnabled: config.webPushEnabled,
10148
10409
  httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
10149
10410
  appVersion: appPackageVersion,
10411
+ webAppBuildId: WEB_APP_BUILD_ID,
10150
10412
  deviceId: session.deviceId || null,
10151
10413
  temporaryPairing: session.temporaryPairing === true,
10152
10414
  ...buildSessionLocalePayload(config, state, session.deviceId),
@@ -10180,6 +10442,38 @@ function readSession(req, config, state) {
10180
10442
  };
10181
10443
  }
10182
10444
 
10445
+ // Relay-dispatched requests authenticate via the Noise channel
10446
+ // binding + pairing identity, set on the synthetic IncomingMessage by
10447
+ // scripts/lib/remote-pairing/http-dispatch.mjs. They have no cookies
10448
+ // (HttpOnly session cookie can't be forwarded by the PWA's JS layer,
10449
+ // and the RPC frame doesn't carry browser cookies), so the cookie
10450
+ // path below would always 401 them.
10451
+ //
10452
+ // Trust model: only this process's relay orchestrator can attach this
10453
+ // marker — it's set on a synthetic req object that never leaves the
10454
+ // bridge. An attacker would need code-exec inside the bridge to forge
10455
+ // it, which is past the threat model anyway. The orchestrator only
10456
+ // dispatches requests for pairings whose Noise IK handshake succeeded
10457
+ // against the bridge's static key, so reaching this branch implies
10458
+ // the phone proved possession of `pairing.phonePub`.
10459
+ const relay = req.viveworker;
10460
+ if (relay?.fromRelay && relay.pairing?.pairingId) {
10461
+ const relayDeviceId = resolveRelaySessionDeviceId(state, config, relay.pairing, deviceId);
10462
+ return {
10463
+ authenticated: true,
10464
+ sessionId: `relay:${relay.pairing.pairingId}`,
10465
+ pairedAtMs: Number(relay.pairing.addedAtMs) || Date.now(),
10466
+ // Relay sessions live only as long as the underlying Noise channel
10467
+ // — there's no cookie expiry to track. 0 = "no fixed expiry, the
10468
+ // transport layer manages liveness".
10469
+ expiresAtMs: 0,
10470
+ deviceId: relayDeviceId,
10471
+ fromRelay: true,
10472
+ pairingId: relay.pairing.pairingId,
10473
+ relayPhoneFingerprint: relay.pairing.phoneFingerprint || "",
10474
+ };
10475
+ }
10476
+
10183
10477
  const token = parseCookies(req)[sessionCookieName];
10184
10478
  const payload = token ? verifySessionToken(token, config.sessionSecret) : null;
10185
10479
  if (!payload) {
@@ -10356,6 +10650,273 @@ function buildPushStatusResponse(config, state, session) {
10356
10650
  };
10357
10651
  }
10358
10652
 
10653
+ async function buildMcpStatusResponse(config, runtime, state) {
10654
+ const remote = getRemotePairingStatus(runtime);
10655
+ let remotePairings = 0;
10656
+ try {
10657
+ remotePairings = (await loadPairings()).length;
10658
+ } catch {
10659
+ remotePairings = 0;
10660
+ }
10661
+ const now = Date.now();
10662
+ const pairedDevices = isPlainObject(state.pairedDevices) ? state.pairedDevices : {};
10663
+ const trustedDevices = Object.values(pairedDevices).filter((raw) => {
10664
+ const record = normalizeDeviceTrustRecord(raw, { trustTtlMs: config.deviceTrustTtlMs, now });
10665
+ return record && !record.revokedAtMs && Number(record.trustedUntilMs) > now;
10666
+ }).length;
10667
+ const a2aRelay = getRelayStatus();
10668
+ return {
10669
+ ok: true,
10670
+ bridge: {
10671
+ running: true,
10672
+ publicBaseUrl: config.nativeApprovalPublicBaseUrl,
10673
+ locale: config.defaultLocale,
10674
+ codexConnected: Boolean(runtime.ipcClient?.clientId),
10675
+ },
10676
+ pairing: {
10677
+ trustedDevices,
10678
+ webPushEnabled: Boolean(config.webPushEnabled),
10679
+ pushSubscriptions: listPushSubscriptions(state).length,
10680
+ },
10681
+ remote: {
10682
+ enabled: Boolean(remote.enabled),
10683
+ relayUrl: remote.relayUrl || config.remotePairingRelayUrl || "",
10684
+ identityFingerprint: remote.identityFingerprint || null,
10685
+ pairings: remotePairings,
10686
+ sessions: Array.isArray(remote.sessions)
10687
+ ? remote.sessions.map((session) => ({
10688
+ state: cleanText(session?.state || ""),
10689
+ lastRoute: cleanText(session?.lastRoute || ""),
10690
+ connected: cleanText(session?.state || "") === "connected",
10691
+ phoneFingerprint: cleanText(session?.phoneFingerprint || ""),
10692
+ lastSeenAtMs: Number(session?.lastSeenAtMs) || 0,
10693
+ }))
10694
+ : [],
10695
+ },
10696
+ a2a: {
10697
+ enabled: Boolean(config.a2aApiKey),
10698
+ relayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
10699
+ relayConnected: Boolean(a2aRelay.polling && a2aRelay.lastPollOk),
10700
+ userIdConfigured: Boolean(config.a2aRelayUserId),
10701
+ },
10702
+ share: {
10703
+ enabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
10704
+ shareUrl: config.a2aShareUrl || "",
10705
+ },
10706
+ moltbook: {
10707
+ enabled: Boolean(config.moltbookApiKey),
10708
+ },
10709
+ };
10710
+ }
10711
+
10712
+ function mcpTimeoutMs(value) {
10713
+ const n = Number(value);
10714
+ if (!Number.isFinite(n)) return 600_000;
10715
+ return Math.max(10_000, Math.min(900_000, Math.floor(n)));
10716
+ }
10717
+
10718
+ function mcpText(value, maxChars = 50_000) {
10719
+ const text = String(value ?? "");
10720
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n\n[truncated by viveworker MCP]` : text;
10721
+ }
10722
+
10723
+ function normalizeMcpStringArray(value, maxItems = 20) {
10724
+ if (!Array.isArray(value)) return [];
10725
+ return value
10726
+ .map((item) => cleanText(item ?? ""))
10727
+ .filter(Boolean)
10728
+ .slice(0, maxItems);
10729
+ }
10730
+
10731
+ function normalizeMcpQuestionOptions(value) {
10732
+ if (!Array.isArray(value)) return [];
10733
+ return value
10734
+ .map((option, index) => {
10735
+ if (typeof option === "string") {
10736
+ const label = cleanText(option);
10737
+ return label ? { label } : null;
10738
+ }
10739
+ if (!isPlainObject(option)) return null;
10740
+ const label = cleanText(option.label ?? option.title ?? `Option ${index + 1}`);
10741
+ if (!label) return null;
10742
+ const description = cleanText(option.description ?? option.detail ?? "");
10743
+ return { label, ...(description ? { description } : {}) };
10744
+ })
10745
+ .filter(Boolean)
10746
+ .slice(0, 10);
10747
+ }
10748
+
10749
+ function normalizeMcpApprovalKind(value) {
10750
+ const normalized = cleanText(value || "mcp").toLowerCase();
10751
+ if (!/^[a-z0-9_-]{1,40}$/u.test(normalized)) {
10752
+ return "mcp";
10753
+ }
10754
+ // Keep MCP in the normal approval lane. These kinds have provider-specific
10755
+ // UI/actions elsewhere and should not be spoofable from a generic MCP client.
10756
+ if (normalized === "payment" || normalized === "hazbase_wallet_payment" || normalized === "plan" || normalized === "question") {
10757
+ return "mcp";
10758
+ }
10759
+ return normalized;
10760
+ }
10761
+
10762
+ function createMcpPendingApproval(body, kind) {
10763
+ const token = crypto.randomBytes(18).toString("hex");
10764
+ const requestId = cleanText(body.requestId || crypto.randomUUID());
10765
+ const title = cleanText(body.title || (kind === "question" ? "MCP question" : "MCP approval"));
10766
+ const messageText = kind === "question"
10767
+ ? mcpText(body.question || body.message || "")
10768
+ : mcpText(body.messageText || body.message || "");
10769
+ const requestKey = `mcp:${requestId}`;
10770
+ const approval = {
10771
+ token,
10772
+ requestKey,
10773
+ conversationId: "mcp",
10774
+ requestId,
10775
+ ownerClientId: null,
10776
+ kind,
10777
+ threadLabel: title || "MCP",
10778
+ title,
10779
+ messageText,
10780
+ fileRefs: normalizeMcpStringArray(body.fileRefs),
10781
+ previousFileRefs: [],
10782
+ diffText: kind === "question" ? "" : mcpText(body.diffText || "", 100_000),
10783
+ diffAvailable: kind !== "question" && Boolean(body.diffText),
10784
+ diffSource: kind === "question" ? "" : "mcp",
10785
+ diffAddedLines: 0,
10786
+ diffRemovedLines: 0,
10787
+ cwd: "",
10788
+ workspaceRoot: "",
10789
+ createdAtMs: Date.now(),
10790
+ resolved: false,
10791
+ resolving: false,
10792
+ resolveMcpWaiter: null,
10793
+ provider: "mcp",
10794
+ planText: "",
10795
+ questions: [],
10796
+ notifyOnly: false,
10797
+ readOnly: false,
10798
+ };
10799
+ if (kind === "question") {
10800
+ approval.questions = [
10801
+ {
10802
+ question: mcpText(body.question || body.message || "", 2000),
10803
+ options: normalizeMcpQuestionOptions(body.options),
10804
+ multiSelect: body.multiSelect === true,
10805
+ },
10806
+ ];
10807
+ }
10808
+ return approval;
10809
+ }
10810
+
10811
+ async function waitForMcpApprovalResult(runtime, approval, timeoutMs) {
10812
+ const waitMs = mcpTimeoutMs(timeoutMs);
10813
+ const result = await new Promise((resolve) => {
10814
+ const timer = setTimeout(() => {
10815
+ resolve({ approved: false, decision: "timeout" });
10816
+ }, waitMs);
10817
+ approval.resolveMcpWaiter = (value) => {
10818
+ clearTimeout(timer);
10819
+ resolve(value);
10820
+ };
10821
+ });
10822
+ if (!approval.resolved) {
10823
+ approval.resolved = true;
10824
+ approval.resolving = false;
10825
+ runtime.nativeApprovalsByToken.delete(approval.token);
10826
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
10827
+ }
10828
+ return result;
10829
+ }
10830
+
10831
+ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
10832
+ const eventType = cleanText(body.eventType || "");
10833
+ if (eventType === "status") {
10834
+ return writeJson(res, 200, await buildMcpStatusResponse(config, runtime, state));
10835
+ }
10836
+
10837
+ if (eventType === "notify") {
10838
+ const title = cleanText(body.title || "MCP");
10839
+ const messageText = mcpText(body.message || body.messageText || "");
10840
+ if (!messageText) {
10841
+ return writeJson(res, 400, { error: "missing-message" });
10842
+ }
10843
+ const stableId = `mcp_notify:${historyToken(`${title}:${messageText}:${Date.now()}`)}`;
10844
+ const token = historyToken(stableId);
10845
+ const entry = {
10846
+ stableId,
10847
+ token,
10848
+ kind: "assistant_commentary",
10849
+ threadId: "mcp",
10850
+ threadLabel: cleanText(body.threadLabel || "MCP"),
10851
+ title,
10852
+ summary: formatNotificationBody(messageText, 160) || messageText,
10853
+ messageText,
10854
+ createdAtMs: Date.now(),
10855
+ readOnly: true,
10856
+ provider: "mcp",
10857
+ };
10858
+ const timelineChanged = recordTimelineEntry({ config, runtime, state, entry });
10859
+ const historyChanged = recordHistoryItem({ config, runtime, state, item: entry });
10860
+ const pushChanged = await deliverWebPushItem({
10861
+ config,
10862
+ state,
10863
+ kind: "assistant_commentary",
10864
+ tab: "timeline",
10865
+ token,
10866
+ stableId,
10867
+ title,
10868
+ body: messageText,
10869
+ }).catch((error) => {
10870
+ console.error(`[mcp-notify-push] ${error.message}`);
10871
+ return false;
10872
+ });
10873
+ if (timelineChanged || historyChanged || pushChanged) {
10874
+ await saveState(config.stateFile, state);
10875
+ }
10876
+ return writeJson(res, 200, { ok: true, token });
10877
+ }
10878
+
10879
+ if (eventType === "approval_request" || eventType === "choice_request") {
10880
+ const kind = eventType === "choice_request" ? "question" : normalizeMcpApprovalKind(body.approvalKind);
10881
+ if (kind !== "question" && !mcpText(body.message || body.messageText || "")) {
10882
+ return writeJson(res, 400, { error: "missing-message" });
10883
+ }
10884
+ if (kind === "question" && !mcpText(body.question || body.message || "")) {
10885
+ return writeJson(res, 400, { error: "missing-question" });
10886
+ }
10887
+ const approval = createMcpPendingApproval(body, kind);
10888
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
10889
+ runtime.nativeApprovalsByRequestKey.set(approval.requestKey, approval);
10890
+ deliverWebPushItem({
10891
+ config,
10892
+ state,
10893
+ kind: "approval",
10894
+ tab: "inbox",
10895
+ subtab: "pending",
10896
+ token: approval.token,
10897
+ stableId: pendingApprovalStableId(approval),
10898
+ title: approval.title || "MCP",
10899
+ body: approval.messageText,
10900
+ buildLocalizedContent: () => ({
10901
+ title: approval.title || "MCP",
10902
+ body: approval.messageText,
10903
+ }),
10904
+ }).catch((error) => {
10905
+ console.error(`[mcp-approval-push] ${approval.requestKey} | ${error.message}`);
10906
+ });
10907
+ const result = await waitForMcpApprovalResult(runtime, approval, body.timeoutMs);
10908
+ return writeJson(res, 200, {
10909
+ ok: true,
10910
+ approved: result?.approved === true || result?.decision === "accept",
10911
+ decision: result?.decision || (result?.approved ? "accept" : "reject"),
10912
+ ...(result?.answerText ? { answerText: result.answerText } : {}),
10913
+ ...(Array.isArray(result?.answers) ? { answers: result.answers } : {}),
10914
+ });
10915
+ }
10916
+
10917
+ return writeJson(res, 400, { error: "unsupported-mcp-event" });
10918
+ }
10919
+
10359
10920
  function requestUserAgent(req) {
10360
10921
  return cleanText(req.headers?.["user-agent"] ?? "");
10361
10922
  }
@@ -10477,8 +11038,15 @@ function validatePairingPayload(payload, config, state) {
10477
11038
 
10478
11039
  const code = cleanText(payload?.code ?? "").toUpperCase();
10479
11040
  const token = cleanText(payload?.token ?? "");
10480
- const matchesCode = code && cleanText(config.pairingCode).toUpperCase() === code;
10481
- const matchesToken = token && cleanText(config.pairingToken) === token;
11041
+ // Constant-time compare both sides even with the 8/15min lockout the
11042
+ // pairing token is long enough that a measurable timing prefix-attack
11043
+ // could meaningfully reduce the brute-force search space.
11044
+ const matchesCode =
11045
+ !!code &&
11046
+ constantTimeStringEqual(cleanText(config.pairingCode).toUpperCase(), code);
11047
+ const matchesToken =
11048
+ !!token &&
11049
+ constantTimeStringEqual(cleanText(config.pairingToken), token);
10482
11050
  if (matchesToken) {
10483
11051
  return { ok: true, credential: `token:${token}` };
10484
11052
  }
@@ -10638,6 +11206,16 @@ function requestOrigin(req) {
10638
11206
  }
10639
11207
 
10640
11208
  function requireTrustedMutationOrigin(req, res, config) {
11209
+ // Relay-dispatched requests have no Origin / Referer (the PWA's RPC
11210
+ // frame doesn't synthesize one) and a non-loopback synthetic
11211
+ // remoteAddress, so they would always 403 here. They're authenticated
11212
+ // by the Noise channel binding instead — no browser-side CSRF surface
11213
+ // exists because an attacker can't establish a Noise channel without
11214
+ // the pairing's static key. See readSession() for the trust-model
11215
+ // notes.
11216
+ if (req.viveworker?.fromRelay) {
11217
+ return true;
11218
+ }
10641
11219
  const origin = requestOrigin(req);
10642
11220
  if (!origin) {
10643
11221
  if (isLoopbackRequest(req)) {
@@ -11285,7 +11863,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
11285
11863
  threadLabel: entry.threadLabel,
11286
11864
  summary: entry.summary,
11287
11865
  fileEventType: normalizeTimelineFileEventType(entry.fileEventType ?? ""),
11288
- imageUrls: buildTimelineEntryImageUrls(entry),
11866
+ imageUrls: buildTimelineEntryImageUrls(entry, config),
11289
11867
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
11290
11868
  diffAvailable: Boolean(entry.diffAvailable),
11291
11869
  diffAddedLines: Math.max(0, Number(entry.diffAddedLines) || 0),
@@ -11794,7 +12372,35 @@ function buildHistoryDetail(item, locale, runtime = null) {
11794
12372
  };
11795
12373
  }
11796
12374
 
11797
- function buildTimelineEntryImageUrls(entry) {
12375
+ function signTimelineEntryImageUrl(config, token, index, filePath) {
12376
+ const secret = cleanText(config?.sessionSecret || "");
12377
+ const normalizedToken = cleanText(token || "");
12378
+ const normalizedPath = filePath ? path.resolve(String(filePath)) : "";
12379
+ const normalizedIndex = Math.max(0, Number(index) || 0);
12380
+ if (!secret || !normalizedToken || !normalizedPath) {
12381
+ return "";
12382
+ }
12383
+ return crypto
12384
+ .createHmac("sha256", secret)
12385
+ .update(`${normalizedToken}\n${normalizedIndex}\n${normalizedPath}`)
12386
+ .digest("base64url");
12387
+ }
12388
+
12389
+ function isValidTimelineEntryImageSignature(config, url, token, index, filePath) {
12390
+ const provided = cleanText(url?.searchParams?.get("imageToken") || "");
12391
+ if (!provided) {
12392
+ return false;
12393
+ }
12394
+ const expected = signTimelineEntryImageUrl(config, token, index, filePath);
12395
+ if (!expected) {
12396
+ return false;
12397
+ }
12398
+ const left = Buffer.from(provided, "utf8");
12399
+ const right = Buffer.from(expected, "utf8");
12400
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
12401
+ }
12402
+
12403
+ function buildTimelineEntryImageUrls(entry, config = null) {
11798
12404
  const imagePaths = normalizeTimelineImagePaths(entry?.imagePaths ?? []);
11799
12405
  if (imagePaths.length === 0) {
11800
12406
  return [];
@@ -11803,10 +12409,14 @@ function buildTimelineEntryImageUrls(entry) {
11803
12409
  if (!token) {
11804
12410
  return [];
11805
12411
  }
11806
- return imagePaths.map((_, index) => `/api/timeline/${encodeURIComponent(token)}/images/${index}`);
12412
+ return imagePaths.map((filePath, index) => {
12413
+ const imageToken = signTimelineEntryImageUrl(config, token, index, filePath);
12414
+ const suffix = imageToken ? `?imageToken=${encodeURIComponent(imageToken)}` : "";
12415
+ return `/api/timeline/${encodeURIComponent(token)}/images/${index}${suffix}`;
12416
+ });
11807
12417
  }
11808
12418
 
11809
- function buildTimelineMessageDetail(entry, locale, runtime = null) {
12419
+ function buildTimelineMessageDetail(entry, locale, runtime = null, config = null) {
11810
12420
  return {
11811
12421
  kind: entry.kind,
11812
12422
  token: entry.token,
@@ -11815,7 +12425,7 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
11815
12425
  threadLabel: entry.threadLabel || "",
11816
12426
  createdAtMs: Number(entry.createdAtMs) || 0,
11817
12427
  messageHtml: renderMessageHtml(entry.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
11818
- imageUrls: buildTimelineEntryImageUrls(entry),
12428
+ imageUrls: buildTimelineEntryImageUrls(entry, config),
11819
12429
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
11820
12430
  previousContext: buildInterruptedTimelineContext(runtime, entry, locale),
11821
12431
  interruptNotice: interruptedDetailNotice(entry.messageText, locale),
@@ -12307,6 +12917,18 @@ async function handleCompletionReply({
12307
12917
  );
12308
12918
  let lastError = null;
12309
12919
  const ownerClientId = runtime.threadOwnerClientIds.get(conversationId) ?? null;
12920
+ const finalizeReplyAccepted = async () => {
12921
+ if (timelineImageAliases.length > 0) {
12922
+ const aliases = isPlainObject(state.timelineImagePathAliases)
12923
+ ? state.timelineImagePathAliases
12924
+ : (state.timelineImagePathAliases = {});
12925
+ for (const [sourcePath, persistentPath] of timelineImageAliases) {
12926
+ aliases[sourcePath] = persistentPath;
12927
+ }
12928
+ await saveState(config.stateFile, state);
12929
+ }
12930
+ scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
12931
+ };
12310
12932
 
12311
12933
  for (const candidate of turnCandidates) {
12312
12934
  try {
@@ -12329,21 +12951,22 @@ async function handleCompletionReply({
12329
12951
  console.log(
12330
12952
  `[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
12331
12953
  );
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);
12954
+ await finalizeReplyAccepted();
12342
12955
  return;
12343
12956
  } catch (error) {
12344
- lastError = error;
12345
- console.log(
12346
- `[completion-reply] failed candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} error=${normalizeIpcErrorMessage(error)} raw=${inspect(error?.ipcError ?? error, { depth: 6, breakLength: 160 })}`
12957
+ if (isStartTurnAckTimeout(error)) {
12958
+ // Codex can create the turn but fail to send the bridge an ACK before
12959
+ // its internal follower timeout fires. Retrying risks duplicate user
12960
+ // turns, so treat this as accepted and let the timeline catch up.
12961
+ console.log(
12962
+ `[completion-reply] accepted candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
12963
+ );
12964
+ await finalizeReplyAccepted();
12965
+ return;
12966
+ }
12967
+ lastError = error;
12968
+ console.log(
12969
+ `[completion-reply] failed candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} error=${normalizeIpcErrorMessage(error)} raw=${inspect(error?.ipcError ?? error, { depth: 6, breakLength: 160 })}`
12347
12970
  );
12348
12971
  }
12349
12972
  }
@@ -13030,7 +13653,12 @@ async function autoApproveTrustedWrite({
13030
13653
  }
13031
13654
 
13032
13655
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
13033
- if (approval.resolveClaudeWaiter) {
13656
+ if (approval.resolveMcpWaiter) {
13657
+ approval.resolveMcpWaiter({
13658
+ approved: decision === "accept",
13659
+ decision,
13660
+ });
13661
+ } else if (approval.resolveClaudeWaiter) {
13034
13662
  if (approval.provider === "claude" && approval.kind === "plan") {
13035
13663
  // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
13036
13664
  // (Claude still shows the native PC plan dialog). Instead, deny the tool
@@ -13120,7 +13748,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13120
13748
  if (timelineMessageKinds.has(kind)) {
13121
13749
  const entry = timelineEntryByToken(runtime, token, kind);
13122
13750
  if (!entry) return null;
13123
- const detail = buildTimelineMessageDetail(entry, locale, runtime);
13751
+ const detail = buildTimelineMessageDetail(entry, locale, runtime, config);
13124
13752
  // Add reply support for Codex assistant_final entries only (replaces completion reply).
13125
13753
  // Check directly against timeline entries (not history items) to avoid
13126
13754
  // maxHistoryItems eviction issues.
@@ -13321,8 +13949,11 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13321
13949
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
13322
13950
  }
13323
13951
 
13324
- function resolveTimelineEntryImagePath(runtime, token, index) {
13325
- const entry = timelineEntryByToken(runtime, token);
13952
+ function resolveTimelineEntryImagePath(runtime, state, token, index) {
13953
+ const normalizedToken = cleanText(token || "");
13954
+ const entry = timelineEntryByToken(runtime, normalizedToken)
13955
+ || (state?.recentTimelineEntries ?? []).find((candidate) => cleanText(candidate?.token || "") === normalizedToken)
13956
+ || null;
13326
13957
  if (!entry) {
13327
13958
  return "";
13328
13959
  }
@@ -13398,13 +14029,32 @@ const GZIPPABLE_EXTENSIONS = new Set([
13398
14029
  ]);
13399
14030
  const MIN_GZIP_BYTES = 512;
13400
14031
 
14032
+ function renderWebAssetBuffer(filePath, raw) {
14033
+ const basename = path.basename(filePath);
14034
+ if (basename !== "index.html" && basename !== "sw.js" && basename !== "app.js") {
14035
+ return raw;
14036
+ }
14037
+
14038
+ const text = raw.toString("utf8");
14039
+ if (!text.includes(WEB_APP_BUILD_ID_PLACEHOLDER)) {
14040
+ return raw;
14041
+ }
14042
+
14043
+ return Buffer.from(text.replaceAll(WEB_APP_BUILD_ID_PLACEHOLDER, WEB_APP_BUILD_ID), "utf8");
14044
+ }
14045
+
13401
14046
  async function loadWebAssetEntry(filePath) {
13402
14047
  const stat = await fs.stat(filePath);
13403
14048
  const cached = WEB_ASSET_CACHE.get(filePath);
13404
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
14049
+ if (
14050
+ cached &&
14051
+ cached.mtimeMs === stat.mtimeMs &&
14052
+ cached.sourceSize === stat.size &&
14053
+ cached.buildId === WEB_APP_BUILD_ID
14054
+ ) {
13405
14055
  return cached;
13406
14056
  }
13407
- const raw = await fs.readFile(filePath);
14057
+ const raw = renderWebAssetBuffer(filePath, await fs.readFile(filePath));
13408
14058
  const extension = path.extname(filePath).toLowerCase();
13409
14059
  let gzip = null;
13410
14060
  if (GZIPPABLE_EXTENSIONS.has(extension) && raw.length >= MIN_GZIP_BYTES) {
@@ -13418,7 +14068,8 @@ async function loadWebAssetEntry(filePath) {
13418
14068
  raw,
13419
14069
  gzip,
13420
14070
  mtimeMs: stat.mtimeMs,
13421
- size: stat.size,
14071
+ sourceSize: stat.size,
14072
+ buildId: WEB_APP_BUILD_ID,
13422
14073
  contentType: contentTypeForFile(filePath),
13423
14074
  };
13424
14075
  WEB_ASSET_CACHE.set(filePath, entry);
@@ -13562,6 +14213,23 @@ function buildWebAppHtml({ pairToken }) {
13562
14213
  font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13563
14214
  font-size: 0.9rem;
13564
14215
  letter-spacing: 0.02em;
14216
+ min-height: 1.25em;
14217
+ transition: opacity 160ms ease, transform 160ms ease;
14218
+ }
14219
+ .boot-splash__hint {
14220
+ max-width: 15rem;
14221
+ margin: -0.35rem 0 0;
14222
+ color: rgba(178, 196, 210, 0.58);
14223
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
14224
+ font-size: 0.78rem;
14225
+ line-height: 1.45;
14226
+ opacity: 0;
14227
+ transform: translateY(-0.2rem);
14228
+ transition: opacity 220ms ease, transform 220ms ease;
14229
+ }
14230
+ .boot-splash__hint.is-visible {
14231
+ opacity: 1;
14232
+ transform: translateY(0);
13565
14233
  }
13566
14234
  .boot-splash__dots {
13567
14235
  display: inline-grid;
@@ -13588,6 +14256,8 @@ function buildWebAppHtml({ pairToken }) {
13588
14256
  }
13589
14257
  @media (prefers-reduced-motion: reduce) {
13590
14258
  .boot-splash,
14259
+ .boot-splash__status,
14260
+ .boot-splash__hint,
13591
14261
  .boot-splash__dots span {
13592
14262
  transition: none;
13593
14263
  animation: none;
@@ -13601,12 +14271,23 @@ function buildWebAppHtml({ pairToken }) {
13601
14271
  <div class="boot-splash__card">
13602
14272
  <img class="boot-splash__logo" src="/icons/viveworker-v-pulse.svg" alt="" width="112" height="112" decoding="async">
13603
14273
  <h1 class="boot-splash__title">viveworker</h1>
13604
- <p class="boot-splash__status">Starting</p>
14274
+ <p id="boot-splash-status" class="boot-splash__status">Checking your trusted Wi-Fi...</p>
14275
+ <p id="boot-splash-hint" class="boot-splash__hint" hidden>The first remote connection can take tens of seconds.</p>
13605
14276
  <span class="boot-splash__dots" aria-hidden="true"><span></span><span></span><span></span></span>
13606
14277
  </div>
13607
14278
  </div>
13608
14279
  <div id="app"></div>
13609
- <script type="module" src="/app.js"></script>
14280
+ <script>
14281
+ (() => {
14282
+ const isJa = (navigator.language || "").toLowerCase().startsWith("ja");
14283
+ const message = isJa ? "同じWi-Fi内のPCを確認中..." : "Checking your trusted Wi-Fi...";
14284
+ const status = document.getElementById("boot-splash-status");
14285
+ const splash = document.getElementById("boot-splash");
14286
+ if (status) status.textContent = message;
14287
+ if (splash) splash.setAttribute("aria-label", \`viveworker \${message}\`);
14288
+ })();
14289
+ </script>
14290
+ <script type="module" src="${WEB_APP_SCRIPT_URL}"></script>
13610
14291
  </body>
13611
14292
  </html>`;
13612
14293
  }
@@ -13615,10 +14296,132 @@ function resolvePagePairingToken({ req, config, state, requestedToken }) {
13615
14296
  return resolveManifestPairingToken({ config, state, requestedToken });
13616
14297
  }
13617
14298
 
14299
+ function normalizeBootTraceEvent(event) {
14300
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
14301
+ return null;
14302
+ }
14303
+ const normalized = {
14304
+ tMs: Math.max(0, Math.min(600_000, Math.round(Number(event.tMs) || 0))),
14305
+ type: cleanText(event.type || "").slice(0, 80),
14306
+ };
14307
+ if (!normalized.type) {
14308
+ return null;
14309
+ }
14310
+ const phase = cleanText(event.phase || "").slice(0, 80);
14311
+ const stateValue = cleanText(event.state || "").slice(0, 80);
14312
+ const previousState = cleanText(event.previousState || "").slice(0, 80);
14313
+ const reason = cleanText(event.reason || "").slice(0, 120);
14314
+ const urlPath = cleanText(event.url || "").split("?")[0].slice(0, 120);
14315
+ if (phase) normalized.phase = phase;
14316
+ if (stateValue) normalized.state = stateValue;
14317
+ if (previousState) normalized.previousState = previousState;
14318
+ if (reason) normalized.reason = reason;
14319
+ if (urlPath) normalized.url = urlPath;
14320
+ if (event.sticky === true || event.sticky === false) normalized.sticky = event.sticky;
14321
+ if (Number.isFinite(Number(event.code))) normalized.code = Math.round(Number(event.code));
14322
+ if (event.resumed === true || event.resumed === false) normalized.resumed = event.resumed;
14323
+ return normalized;
14324
+ }
14325
+
14326
+ function formatBootTraceEvent(event) {
14327
+ const parts = [`${event.tMs}ms`, event.type];
14328
+ if (event.phase) parts.push(event.phase);
14329
+ if (event.state) {
14330
+ parts.push(event.previousState ? `${event.previousState}->${event.state}` : event.state);
14331
+ }
14332
+ if (event.url) parts.push(event.url);
14333
+ if (event.reason) parts.push(`reason=${event.reason}`);
14334
+ if (event.sticky === true) parts.push("sticky=1");
14335
+ if (event.code) parts.push(`code=${event.code}`);
14336
+ if (event.resumed === true || event.resumed === false) parts.push(`resumed=${event.resumed ? 1 : 0}`);
14337
+ return parts.join(":");
14338
+ }
14339
+
14340
+ function logRemotePairingBootTrace(body, session, req) {
14341
+ const traceId = cleanText(body?.traceId || "").slice(0, 80) || "unknown";
14342
+ const reason = cleanText(body?.reason || "").slice(0, 80) || "unknown";
14343
+ const appBuildId = cleanText(body?.appBuildId || "").slice(0, 80) || "unknown";
14344
+ const totalMs = Math.max(0, Math.min(600_000, Math.round(Number(body?.totalMs) || 0)));
14345
+ const remoteRouteSeen = body?.remoteRouteSeen === true;
14346
+ const eventList = Array.isArray(body?.events) ? body.events : [];
14347
+ const events = eventList
14348
+ .slice(-90)
14349
+ .map(normalizeBootTraceEvent)
14350
+ .filter(Boolean);
14351
+ const phases = events
14352
+ .filter((event) => event.type === "route" && event.phase)
14353
+ .map((event) => `${event.tMs}:${event.phase}`)
14354
+ .join(" > ");
14355
+ const transport = events
14356
+ .filter((event) => event.phase === "remote-transport-state" || event.phase === "remote-transport-error")
14357
+ .map((event) => {
14358
+ if (event.phase === "remote-transport-error") return `${event.tMs}:error(${event.reason || "unknown"})`;
14359
+ return `${event.tMs}:${event.previousState || "?"}->${event.state || "?"}${event.reason ? `(${event.reason})` : ""}`;
14360
+ })
14361
+ .join(" > ");
14362
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
14363
+ const ua = requestUserAgent(req).slice(0, 90);
14364
+
14365
+ console.log(
14366
+ `[remote-pairing-boot] trace=${traceId} device=${deviceId} reason=${reason} ` +
14367
+ `total=${totalMs}ms remote=${remoteRouteSeen ? 1 : 0} build=${appBuildId} ` +
14368
+ `events=${events.length} ua=${JSON.stringify(ua)}`
14369
+ );
14370
+ if (phases) {
14371
+ console.log(`[remote-pairing-boot-phases] trace=${traceId} ${phases}`);
14372
+ }
14373
+ if (transport) {
14374
+ console.log(`[remote-pairing-boot-transport] trace=${traceId} ${transport}`);
14375
+ }
14376
+ if (totalMs >= 5_000 || remoteRouteSeen) {
14377
+ console.log(`[remote-pairing-boot-detail] trace=${traceId} ${events.map(formatBootTraceEvent).join(" | ")}`);
14378
+ }
14379
+ }
14380
+
14381
+ function recordRemotePairingAudit(event) {
14382
+ appendRemotePairingAuditEvent({
14383
+ atMs: Date.now(),
14384
+ ...event,
14385
+ }).catch((err) => {
14386
+ console.warn(`[remote-pairing-audit] write failed: ${err?.message}`);
14387
+ });
14388
+ }
14389
+
14390
+ function remotePairingAuditFieldsForPairing(pairing) {
14391
+ if (!pairing) return {};
14392
+ return {
14393
+ pairingId: redactShortId(pairing.pairingId),
14394
+ phoneFingerprint: pairing.phoneFingerprint || "",
14395
+ label: pairing.label || "",
14396
+ deviceId: pairing.deviceId || "",
14397
+ };
14398
+ }
14399
+
14400
+ function redactShortId(value) {
14401
+ const text = cleanText(value || "").slice(0, 80);
14402
+ return text ? `${text.slice(0, 6)}…` : "";
14403
+ }
14404
+
14405
+ function relayHostForAudit(value) {
14406
+ try {
14407
+ return new URL(value || DEFAULT_RELAY_URL).host;
14408
+ } catch {
14409
+ return "";
14410
+ }
14411
+ }
14412
+
14413
+ function shouldAutoRotateRemotePairingToken(pairing, now = Date.now()) {
14414
+ if (!pairing) return false;
14415
+ const updatedAt = Number(pairing.relayTokenUpdatedAtMs) || 0;
14416
+ if (!updatedAt) return true;
14417
+ return now - updatedAt >= REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS;
14418
+ }
14419
+
13618
14420
  function createNativeApprovalServer({ config, runtime, state }) {
13619
14421
  const requestHandler = async (req, res) => {
13620
14422
  try {
13621
14423
  const url = new URL(req.url, config.nativeApprovalPublicBaseUrl);
14424
+ refreshPairingConfigFromEnvFile(config);
13622
14425
 
13623
14426
  if (url.pathname === "/health") {
13624
14427
  return writeJson(res, 200, { ok: true });
@@ -13668,10 +14471,20 @@ function createNativeApprovalServer({ config, runtime, state }) {
13668
14471
  if (
13669
14472
  url.pathname === "/app.js" ||
13670
14473
  url.pathname === "/app.css" ||
14474
+ url.pathname === "/build-id.js" ||
13671
14475
  url.pathname === "/i18n.js" ||
13672
14476
  url.pathname === "/hazbase-passkey.js" ||
13673
14477
  url.pathname === "/sw.js" ||
13674
- url.pathname.startsWith("/icons/")
14478
+ url.pathname.startsWith("/icons/") ||
14479
+ // Remote-pairing PWA modules + their generated crypto bundle.
14480
+ // The bundle is auto-built by scripts/build-remote-pairing-bundle.mjs
14481
+ // and the per-module wrappers under web/remote-pairing/ import from it.
14482
+ // resolveWebAsset() still enforces the webRoot containment check, so
14483
+ // a wide-prefix allowlist here is safe against path traversal.
14484
+ url.pathname.startsWith("/remote-pairing/") ||
14485
+ url.pathname === "/remote-pairing.bundle.js" ||
14486
+ url.pathname === "/remote-pairing.bundle.js.map" ||
14487
+ url.pathname === "/remote-pairing.bundle.js.LEGAL.txt"
13675
14488
  ) {
13676
14489
  const served = await serveWebAsset(res, url.pathname, req);
13677
14490
  if (served) {
@@ -13847,9 +14660,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
13847
14660
  }
13848
14661
  const sessionPayload = buildSessionPayload({ config, state, session });
13849
14662
  if (!session.authenticated) {
13850
- return writeJson(res, 200, { session: sessionPayload });
14663
+ return writeJson(res, 200, { appBuildId: WEB_APP_BUILD_ID, session: sessionPayload });
13851
14664
  }
13852
14665
  return writeJson(res, 200, {
14666
+ appBuildId: WEB_APP_BUILD_ID,
13853
14667
  session: sessionPayload,
13854
14668
  inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
13855
14669
  timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
@@ -14576,6 +15390,512 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
14576
15390
  return writeJson(res, 200, { ok: true, acceptPublicTasks: accept });
14577
15391
  }
14578
15392
 
15393
+ // ─── Remote pairing (off-LAN PWA relay) ──────────────────────────
15394
+
15395
+ // GET /api/remote-pairing/status — settings UI bootstrap.
15396
+ //
15397
+ // Returns the current orchestrator state plus the (read-only) list of
15398
+ // pairings on disk. Sessions only exist when `enabled: true`; when the
15399
+ // relay is off we still surface the pairings so the user can revoke
15400
+ // before re-enabling.
15401
+ if (url.pathname === "/api/remote-pairing/status" && req.method === "GET") {
15402
+ const session = requireApiSession(req, res, config, state);
15403
+ if (!session) return;
15404
+ const status = getRemotePairingStatus(runtime);
15405
+ let pairings = [];
15406
+ let auditEvents = [];
15407
+ try {
15408
+ const list = await loadPairings();
15409
+ pairings = list.map((p) => ({
15410
+ pairingId: p.pairingId,
15411
+ label: p.label || "",
15412
+ phonePub: p.phonePub,
15413
+ phoneFingerprint: p.phoneFingerprint,
15414
+ deviceId: p.deviceId || null,
15415
+ addedAtMs: p.addedAtMs ?? null,
15416
+ relayTokenUpdatedAtMs: p.relayTokenUpdatedAtMs ?? null,
15417
+ lastSeenAtMs: p.lastSeenAtMs ?? null,
15418
+ }));
15419
+ } catch (err) {
15420
+ console.warn(`[remote-pairing] status: failed to load pairings: ${err?.message}`);
15421
+ }
15422
+ try {
15423
+ auditEvents = await readRemotePairingAuditEvents({ limit: 12 });
15424
+ } catch (err) {
15425
+ console.warn(`[remote-pairing] status: failed to load audit log: ${err?.message}`);
15426
+ }
15427
+ return writeJson(res, 200, {
15428
+ enabled: status.enabled,
15429
+ relayUrl: status.relayUrl,
15430
+ configuredRelayUrl: config.remotePairingRelayUrl || "",
15431
+ identityFingerprint: status.identityFingerprint,
15432
+ identityPubHex: status.identityPubHex,
15433
+ sessions: status.sessions,
15434
+ pairings,
15435
+ auditEvents,
15436
+ pairingsFile: REMOTE_PAIRINGS_FILE,
15437
+ });
15438
+ }
15439
+
15440
+ // POST /api/remote-pairing/boot-trace — PWA cold-start timing.
15441
+ //
15442
+ // This is intentionally diagnostics-only: it does not mutate state and
15443
+ // only logs a sanitized, capped phase timeline so off-LAN boot latency
15444
+ // can be debugged without attaching Safari Web Inspector to the phone.
15445
+ if (url.pathname === "/api/remote-pairing/boot-trace" && req.method === "POST") {
15446
+ const session = requireApiSession(req, res, config, state);
15447
+ if (!session) return;
15448
+ let body;
15449
+ try {
15450
+ body = await parseJsonBody(req);
15451
+ } catch (err) {
15452
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15453
+ }
15454
+ try {
15455
+ logRemotePairingBootTrace(body, session, req);
15456
+ } catch (err) {
15457
+ console.warn(`[remote-pairing-boot] failed to log trace: ${err?.message}`);
15458
+ }
15459
+ return writeJson(res, 200, { ok: true });
15460
+ }
15461
+
15462
+ // POST /api/remote-pairing/toggle — flip on/off without restart.
15463
+ //
15464
+ // Body: { enabled: boolean }
15465
+ // Persists to ~/.viveworker/remote-pairing.env (so the next bridge
15466
+ // launch starts in the new state) and hot-restarts the orchestrator.
15467
+ // The same approval server's request handler is reused, so cookies /
15468
+ // session auth behave identically over LAN HTTP and the relay.
15469
+ if (url.pathname === "/api/remote-pairing/toggle" && req.method === "POST") {
15470
+ const session = requireMutatingApiSession(req, res, config, state);
15471
+ if (!session) return;
15472
+ let body;
15473
+ try {
15474
+ body = await parseJsonBody(req);
15475
+ } catch (err) {
15476
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15477
+ }
15478
+ if (typeof body?.enabled !== "boolean") {
15479
+ return writeJson(res, 400, { error: "enabled-required", message: "body.enabled must be a boolean" });
15480
+ }
15481
+ if (typeof requestHandler !== "function") {
15482
+ return writeJson(res, 503, { error: "request-handler-unavailable" });
15483
+ }
15484
+ const next = body.enabled;
15485
+ config.remotePairingEnabled = next;
15486
+ recordRemotePairingAudit({
15487
+ type: next ? "relay_enabled" : "relay_disabled",
15488
+ outcome: "info",
15489
+ deviceId: session.deviceId || "",
15490
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15491
+ });
15492
+ try {
15493
+ await persistRemotePairingEnv({ enabled: next });
15494
+ } catch (err) {
15495
+ // Persistence failure shouldn't roll back the in-memory config —
15496
+ // the user can retry from the UI and the runtime is still in the
15497
+ // intended state. Surface the error clearly.
15498
+ console.error(`[remote-pairing] env persist failed: ${err?.message}`);
15499
+ }
15500
+ try {
15501
+ await restartRemotePairingRelay({
15502
+ runtime,
15503
+ config,
15504
+ requestListener: requestHandler,
15505
+ logger: console,
15506
+ auditEventSink: recordRemotePairingAudit,
15507
+ });
15508
+ } catch (err) {
15509
+ console.error(`[remote-pairing] hot-restart failed: ${err?.message}`);
15510
+ return writeJson(res, 500, { error: "restart-failed", message: err.message });
15511
+ }
15512
+ const status = getRemotePairingStatus(runtime);
15513
+ return writeJson(res, 200, {
15514
+ ok: true,
15515
+ enabled: status.enabled,
15516
+ relayUrl: status.relayUrl,
15517
+ identityFingerprint: status.identityFingerprint,
15518
+ sessions: status.sessions,
15519
+ });
15520
+ }
15521
+
15522
+ // POST /api/remote-pairing/relay-url — change the relay endpoint.
15523
+ //
15524
+ // Body: { relayUrl: string | null }
15525
+ // - empty string / null → falls back to DEFAULT_RELAY_URL
15526
+ // - non-empty string → must be wss://, except ws:// loopback for local dev
15527
+ // Persists + hot-restarts (only restarts if currently enabled, since
15528
+ // a dormant handle has nothing to reconnect).
15529
+ if (url.pathname === "/api/remote-pairing/relay-url" && req.method === "POST") {
15530
+ const session = requireMutatingApiSession(req, res, config, state);
15531
+ if (!session) return;
15532
+ let body;
15533
+ try {
15534
+ body = await parseJsonBody(req);
15535
+ } catch (err) {
15536
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15537
+ }
15538
+ const raw = body?.relayUrl;
15539
+ const relayUrlResult = normalizeRemotePairingRelayUrl(raw ?? "");
15540
+ if (!relayUrlResult.ok) {
15541
+ return writeJson(res, 400, {
15542
+ error: "invalid-relay-url",
15543
+ message: relayUrlResult.message,
15544
+ });
15545
+ }
15546
+ const normalized = relayUrlResult.value;
15547
+ config.remotePairingRelayUrl = normalized;
15548
+ recordRemotePairingAudit({
15549
+ type: "relay_url_changed",
15550
+ outcome: "info",
15551
+ deviceId: session.deviceId || "",
15552
+ relayHost: relayHostForAudit(normalized || DEFAULT_RELAY_URL),
15553
+ });
15554
+ try {
15555
+ await persistRemotePairingEnv({ relayUrl: normalized || null });
15556
+ } catch (err) {
15557
+ console.error(`[remote-pairing] env persist failed: ${err?.message}`);
15558
+ }
15559
+ if (config.remotePairingEnabled && typeof requestHandler === "function") {
15560
+ try {
15561
+ await restartRemotePairingRelay({
15562
+ runtime,
15563
+ config,
15564
+ requestListener: requestHandler,
15565
+ logger: console,
15566
+ auditEventSink: recordRemotePairingAudit,
15567
+ });
15568
+ } catch (err) {
15569
+ console.error(`[remote-pairing] hot-restart failed: ${err?.message}`);
15570
+ return writeJson(res, 500, { error: "restart-failed", message: err.message });
15571
+ }
15572
+ }
15573
+ const status = getRemotePairingStatus(runtime);
15574
+ return writeJson(res, 200, {
15575
+ ok: true,
15576
+ enabled: status.enabled,
15577
+ relayUrl: status.relayUrl,
15578
+ configuredRelayUrl: config.remotePairingRelayUrl || "",
15579
+ });
15580
+ }
15581
+
15582
+ // POST /api/remote-pairing/revoke — drop a pairing by phonePub.
15583
+ //
15584
+ // Body: { phonePub: string } (64-char hex, the phone's static X25519 pub)
15585
+ // Removes the entry from `~/.viveworker/remote-pairings.json` and
15586
+ // calls reloadNow() so the orchestrator tears the live session down.
15587
+ // No-op if the pub isn't on file (idempotent).
15588
+ if (url.pathname === "/api/remote-pairing/revoke" && req.method === "POST") {
15589
+ const session = requireMutatingApiSession(req, res, config, state);
15590
+ if (!session) return;
15591
+ let body;
15592
+ try {
15593
+ body = await parseJsonBody(req);
15594
+ } catch (err) {
15595
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15596
+ }
15597
+ const phonePub = typeof body?.phonePub === "string" ? body.phonePub.trim().toLowerCase() : "";
15598
+ if (!/^[0-9a-f]{64}$/u.test(phonePub)) {
15599
+ return writeJson(res, 400, {
15600
+ error: "invalid-phone-pub",
15601
+ message: "phonePub must be a 64-char hex string",
15602
+ });
15603
+ }
15604
+ let beforeLen = 0;
15605
+ let afterLen = 0;
15606
+ let removedPairing = null;
15607
+ try {
15608
+ const before = await loadPairings();
15609
+ beforeLen = before.length;
15610
+ removedPairing = before.find((p) => p.phonePub === phonePub) || null;
15611
+ const after = await removePairingPersisted(phonePub);
15612
+ afterLen = after.length;
15613
+ } catch (err) {
15614
+ return writeJson(res, 500, { error: "revoke-failed", message: err.message });
15615
+ }
15616
+ const removed = beforeLen !== afterLen;
15617
+ if (removed && runtime.remotePairingHandle) {
15618
+ try {
15619
+ await runtime.remotePairingHandle.reloadNow();
15620
+ } catch (err) {
15621
+ console.warn(`[remote-pairing] reloadNow after revoke failed: ${err?.message}`);
15622
+ }
15623
+ }
15624
+ if (removed) {
15625
+ recordRemotePairingAudit({
15626
+ type: "pairing_revoked",
15627
+ outcome: "info",
15628
+ deviceId: session.deviceId || "",
15629
+ ...remotePairingAuditFieldsForPairing(removedPairing),
15630
+ });
15631
+ }
15632
+ return writeJson(res, 200, { ok: true, removed, remaining: afterLen });
15633
+ }
15634
+
15635
+ // POST /api/remote-pairing/rotate-token — rotate the relay capability
15636
+ // for the current LAN-reachable paired phone. This endpoint is also
15637
+ // denied inside the relay dispatcher, so an off-LAN phone cannot rotate
15638
+ // itself into a split-brain state where the bridge updated but the PWA
15639
+ // failed to persist the new token.
15640
+ if (url.pathname === "/api/remote-pairing/rotate-token" && req.method === "POST") {
15641
+ const session = requireMutatingApiSession(req, res, config, state);
15642
+ if (!session) return;
15643
+ let body;
15644
+ try {
15645
+ body = await parseJsonBody(req);
15646
+ } catch (err) {
15647
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15648
+ }
15649
+ const phonePub = typeof body?.phonePub === "string" ? body.phonePub.trim().toLowerCase() : "";
15650
+ if (!/^[0-9a-f]{64}$/u.test(phonePub)) {
15651
+ return writeJson(res, 400, {
15652
+ error: "invalid-phone-pub",
15653
+ message: "phonePub must be a 64-char hex string",
15654
+ });
15655
+ }
15656
+
15657
+ let current = [];
15658
+ try {
15659
+ current = await loadPairings();
15660
+ } catch (err) {
15661
+ return writeJson(res, 500, { error: "load-pairings-failed", message: err.message });
15662
+ }
15663
+ const existing = current.find((p) => p.phonePub === phonePub) || null;
15664
+ if (!existing) {
15665
+ return writeJson(res, 404, { error: "pairing-not-found" });
15666
+ }
15667
+ if (existing.deviceId && session.deviceId && existing.deviceId !== session.deviceId) {
15668
+ recordRemotePairingAudit({
15669
+ type: "token_rotate_denied",
15670
+ outcome: "failure",
15671
+ deviceId: session.deviceId || "",
15672
+ reason: "device mismatch",
15673
+ ...remotePairingAuditFieldsForPairing(existing),
15674
+ });
15675
+ return writeJson(res, 403, { error: "device-mismatch" });
15676
+ }
15677
+
15678
+ let rotated;
15679
+ try {
15680
+ ({ rotated } = await rotateRelayTokenPersisted(phonePub));
15681
+ } catch (err) {
15682
+ recordRemotePairingAudit({
15683
+ type: "token_rotate_failed",
15684
+ outcome: "failure",
15685
+ deviceId: session.deviceId || "",
15686
+ reason: err.message,
15687
+ ...remotePairingAuditFieldsForPairing(existing),
15688
+ });
15689
+ return writeJson(res, 500, { error: "rotate-token-failed", message: err.message });
15690
+ }
15691
+ if (!rotated) {
15692
+ return writeJson(res, 404, { error: "pairing-not-found" });
15693
+ }
15694
+
15695
+ if (runtime.remotePairingHandle) {
15696
+ try {
15697
+ await runtime.remotePairingHandle.reloadNow();
15698
+ } catch (err) {
15699
+ console.warn(`[remote-pairing] reloadNow after token rotation failed: ${err?.message}`);
15700
+ }
15701
+ }
15702
+
15703
+ let bridgeKeypair;
15704
+ try {
15705
+ bridgeKeypair = await ensureIdentityKeypair();
15706
+ } catch (err) {
15707
+ return writeJson(res, 500, {
15708
+ error: "bridge-keypair-failed",
15709
+ message: err.message,
15710
+ });
15711
+ }
15712
+ const relayUrl = (config.remotePairingRelayUrl || "").trim() || DEFAULT_RELAY_URL;
15713
+ recordRemotePairingAudit({
15714
+ type: "token_rotated",
15715
+ outcome: "success",
15716
+ deviceId: session.deviceId || "",
15717
+ relayHost: relayHostForAudit(relayUrl),
15718
+ ...remotePairingAuditFieldsForPairing(rotated),
15719
+ });
15720
+
15721
+ return writeJson(res, 200, {
15722
+ ok: true,
15723
+ pairingId: rotated.pairingId,
15724
+ relayToken: rotated.relayToken,
15725
+ phonePub: rotated.phonePub,
15726
+ phoneFingerprint: rotated.phoneFingerprint,
15727
+ deviceId: rotated.deviceId || null,
15728
+ bridgePubHex: bytesToHex(bridgeKeypair.pub),
15729
+ bridgeFingerprint: fingerprintIdentity(bridgeKeypair.pub),
15730
+ relayUrl,
15731
+ label: rotated.label,
15732
+ addedAtMs: rotated.addedAtMs,
15733
+ relayTokenUpdatedAtMs: rotated.relayTokenUpdatedAtMs ?? null,
15734
+ });
15735
+ }
15736
+
15737
+ // POST /api/remote-pairing/lan-enroll — finalize a LAN pairing by
15738
+ // exchanging X25519 static pubkeys so the same phone can later reach
15739
+ // the bridge via the relay (when off-LAN).
15740
+ //
15741
+ // Body: { phonePubHex: string (64-hex), label?: string (≤200 chars) }
15742
+ // Returns: { ok, pairingId, relayToken, phonePub, phoneFingerprint, bridgePubHex,
15743
+ // bridgeFingerprint, relayUrl, label, addedAtMs }
15744
+ //
15745
+ // Side effects:
15746
+ // - Adds (or refreshes) the phone in ~/.viveworker/remote-pairings.json.
15747
+ // Idempotent on phonePub: re-pairing the same device keeps the
15748
+ // existing pairingId so the relay slot doesn't churn (any in-flight
15749
+ // remote session for that phone stays valid).
15750
+ // - Triggers runtime.remotePairingHandle.reloadNow() so the
15751
+ // orchestrator spawns a session for the new phone immediately
15752
+ // when the relay is currently on. Safe no-op when the relay is off
15753
+ // — the next toggle-on will pick up the new entry from disk.
15754
+ // - Calls ensureIdentityKeypair(), which generates the bridge's
15755
+ // static keypair on first run. After this endpoint succeeds the
15756
+ // bridge always has a stable identity.
15757
+ //
15758
+ // Auth: requires an authenticated session. The natural caller is the
15759
+ // LAN-paired phone right after /api/session/pair succeeds — the
15760
+ // session cookie set by that endpoint is what gates this one.
15761
+ if (url.pathname === "/api/remote-pairing/lan-enroll" && req.method === "POST") {
15762
+ const session = requireMutatingApiSession(req, res, config, state);
15763
+ if (!session) return;
15764
+ let body;
15765
+ try {
15766
+ body = await parseJsonBody(req);
15767
+ } catch (err) {
15768
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15769
+ }
15770
+ const phonePubHex = typeof body?.phonePubHex === "string"
15771
+ ? body.phonePubHex.trim().toLowerCase()
15772
+ : "";
15773
+ if (!/^[0-9a-f]{64}$/u.test(phonePubHex)) {
15774
+ return writeJson(res, 400, {
15775
+ error: "invalid-phone-pub",
15776
+ message: "phonePubHex must be a 64-char hex string",
15777
+ });
15778
+ }
15779
+ // Cap label at 200 chars — the persisted JSON has no hard limit but
15780
+ // long strings make the settings UI ugly and there's no legitimate
15781
+ // reason for a device label to exceed this.
15782
+ const label = typeof body?.label === "string"
15783
+ ? body.label.trim().slice(0, 200)
15784
+ : "";
15785
+
15786
+ // Idempotent re-enroll: if the same phone re-pairs (e.g. user
15787
+ // cleared site data on the phone, regenerated the keypair, then
15788
+ // happened to land on the same hex — vanishingly unlikely; far more
15789
+ // commonly: same keypair, fresh label), keep the original pairingId.
15790
+ let existingPairings = [];
15791
+ try {
15792
+ existingPairings = await loadPairings();
15793
+ } catch (err) {
15794
+ return writeJson(res, 500, {
15795
+ error: "load-pairings-failed",
15796
+ message: err.message,
15797
+ });
15798
+ }
15799
+ const existing = existingPairings.find((p) => p.phonePub === phonePubHex);
15800
+ const pairingId = existing?.pairingId || crypto.randomUUID();
15801
+ const autoRotateToken = shouldAutoRotateRemotePairingToken(existing);
15802
+ const relayToken = autoRotateToken ? undefined : existing?.relayToken || undefined;
15803
+ const relayTokenUpdatedAtMs = autoRotateToken ? Date.now() : existing?.relayTokenUpdatedAtMs;
15804
+
15805
+ // Bridge identity — generates on first call. We can't lean on
15806
+ // runtime.remotePairingHandle here because the relay may be OFF
15807
+ // (in which case the handle is dormant and identityKeypair is
15808
+ // null), but enrollment still has to expose a stable bridge pub
15809
+ // so the phone can store it.
15810
+ let bridgeKeypair;
15811
+ try {
15812
+ bridgeKeypair = await ensureIdentityKeypair();
15813
+ } catch (err) {
15814
+ return writeJson(res, 500, {
15815
+ error: "bridge-keypair-failed",
15816
+ message: err.message,
15817
+ });
15818
+ }
15819
+
15820
+ let entry;
15821
+ try {
15822
+ entry = buildPairing({
15823
+ pairingId,
15824
+ relayToken,
15825
+ relayTokenUpdatedAtMs,
15826
+ phonePub: phonePubHex,
15827
+ label,
15828
+ deviceId: session.deviceId,
15829
+ });
15830
+ } catch (err) {
15831
+ return writeJson(res, 400, {
15832
+ error: "build-pairing-failed",
15833
+ message: err.message,
15834
+ });
15835
+ }
15836
+
15837
+ try {
15838
+ await addPairingPersisted(entry);
15839
+ } catch (err) {
15840
+ return writeJson(res, 500, {
15841
+ error: "save-pairings-failed",
15842
+ message: err.message,
15843
+ });
15844
+ }
15845
+
15846
+ // Kick the orchestrator if it's running so the new pairing's
15847
+ // session spawns without waiting for the fs.watch debounce. When
15848
+ // the relay is off this is a no-op (dormant handle's reloadNow is
15849
+ // safe to call).
15850
+ if (runtime.remotePairingHandle) {
15851
+ try {
15852
+ await runtime.remotePairingHandle.reloadNow();
15853
+ } catch (err) {
15854
+ console.warn(`[remote-pairing] reloadNow after lan-enroll failed: ${err?.message}`);
15855
+ }
15856
+ }
15857
+ recordRemotePairingAudit({
15858
+ type: existing ? "pairing_refreshed" : "pairing_enrolled",
15859
+ outcome: "success",
15860
+ deviceId: session.deviceId || "",
15861
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15862
+ ...remotePairingAuditFieldsForPairing(entry),
15863
+ });
15864
+ if (existing && autoRotateToken) {
15865
+ recordRemotePairingAudit({
15866
+ type: "token_auto_rotated",
15867
+ outcome: "success",
15868
+ deviceId: session.deviceId || "",
15869
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15870
+ reason: existing.relayTokenUpdatedAtMs ? "scheduled LAN refresh" : "legacy token metadata",
15871
+ ...remotePairingAuditFieldsForPairing(entry),
15872
+ });
15873
+ }
15874
+
15875
+ const bridgePubHex = bytesToHex(bridgeKeypair.pub);
15876
+ const bridgeFingerprint = fingerprintIdentity(bridgeKeypair.pub);
15877
+ // Relay URL: explicit config value > orchestrator default. We
15878
+ // surface the resolved URL (not the configured one) so the phone
15879
+ // doesn't have to know about the fallback chain.
15880
+ const relayUrl = (config.remotePairingRelayUrl || "").trim() || DEFAULT_RELAY_URL;
15881
+
15882
+ return writeJson(res, 200, {
15883
+ ok: true,
15884
+ pairingId: entry.pairingId,
15885
+ relayToken: entry.relayToken,
15886
+ phonePub: entry.phonePub,
15887
+ phoneFingerprint: entry.phoneFingerprint,
15888
+ deviceId: entry.deviceId || null,
15889
+ bridgePubHex,
15890
+ bridgeFingerprint,
15891
+ relayUrl,
15892
+ label: entry.label,
15893
+ addedAtMs: entry.addedAtMs,
15894
+ relayTokenUpdatedAtMs: entry.relayTokenUpdatedAtMs ?? null,
15895
+ relayTokenRotated: existing ? autoRotateToken : false,
15896
+ });
15897
+ }
15898
+
14579
15899
  // ─── Thread sharing ──────────────────────────────────────────────
14580
15900
 
14581
15901
  function buildThreadShareContent(shareType, body) {
@@ -14727,7 +16047,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
14727
16047
  messageText: content,
14728
16048
  createdAtMs: share.createdAtMs,
14729
16049
  readOnly: false,
14730
- provider: "viveworker",
16050
+ provider: sourceTool === "mcp" ? "mcp" : "viveworker",
14731
16051
  },
14732
16052
  });
14733
16053
  await saveState(config.stateFile, state);
@@ -15231,6 +16551,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15231
16551
  return writeJson(res, 403, { approved: false, decision: "decline", error: "payment-approval-declined" });
15232
16552
  }
15233
16553
 
16554
+ if (url.pathname === "/api/providers/mcp/events" && req.method === "POST") {
16555
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
16556
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
16557
+ return writeJson(res, 401, { error: "unauthorized" });
16558
+ }
16559
+
16560
+ let body;
16561
+ try {
16562
+ body = await parseJsonBody(req);
16563
+ } catch {
16564
+ return writeJson(res, 400, { error: "invalid-json-body" });
16565
+ }
16566
+
16567
+ return handleMcpProviderEvent({ config, runtime, state, body, res });
16568
+ }
16569
+
15234
16570
  if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
15235
16571
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
15236
16572
  if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
@@ -15925,18 +17261,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15925
17261
 
15926
17262
  const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
15927
17263
  if (apiTimelineImageMatch && req.method === "GET") {
15928
- const session = requireApiSession(req, res, config, state);
15929
- if (!session) {
15930
- return;
15931
- }
15932
17264
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
15933
17265
  const index = Number(apiTimelineImageMatch[2]) || 0;
15934
- const filePath = resolveTimelineEntryImagePath(runtime, token, index);
17266
+ const filePath = resolveTimelineEntryImagePath(runtime, state, token, index);
15935
17267
  if (!filePath) {
15936
17268
  res.statusCode = 404;
15937
17269
  res.end("not-found");
15938
17270
  return;
15939
17271
  }
17272
+ if (!isValidTimelineEntryImageSignature(config, url, token, index, filePath)) {
17273
+ const session = requireApiSession(req, res, config, state);
17274
+ if (!session) {
17275
+ return;
17276
+ }
17277
+ }
15940
17278
  try {
15941
17279
  const body = await fs.readFile(filePath);
15942
17280
  res.statusCode = 200;
@@ -16104,7 +17442,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16104
17442
 
16105
17443
  approval.resolving = true;
16106
17444
  try {
16107
- if (approval.resolveClaudeWaiter) {
17445
+ if (approval.resolveMcpWaiter) {
17446
+ approval.resolveMcpWaiter({
17447
+ approved: true,
17448
+ decision: "answered",
17449
+ answers,
17450
+ answerText: reasonText,
17451
+ });
17452
+ } else if (approval.resolveClaudeWaiter) {
16108
17453
  approval.resolveClaudeWaiter({
16109
17454
  permissionDecision: "deny",
16110
17455
  permissionDecisionReason: reasonText,
@@ -16140,8 +17485,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16140
17485
  if (stateChanged) {
16141
17486
  await saveState(config.stateFile, state);
16142
17487
  }
16143
- console.log(`[claude-question-answer] ${approval.requestKey}`);
16144
- activateClaudeDesktopIfMac(req);
17488
+ console.log(`[${approval.provider === "mcp" ? "mcp" : "claude"}-question-answer] ${approval.requestKey}`);
17489
+ if (approval.provider === "claude") {
17490
+ activateClaudeDesktopIfMac(req);
17491
+ }
16145
17492
  return writeJson(res, 200, { ok: true });
16146
17493
  } catch (error) {
16147
17494
  approval.resolving = false;
@@ -16725,14 +18072,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16725
18072
  }
16726
18073
  };
16727
18074
 
18075
+ let server;
16728
18076
  if (config.webPushEnabled) {
16729
- return createHttpsServer({
18077
+ server = createHttpsServer({
16730
18078
  cert: readFileSync(config.tlsCertFile, "utf8"),
16731
18079
  key: readFileSync(config.tlsKeyFile, "utf8"),
16732
18080
  }, requestHandler);
18081
+ } else {
18082
+ server = createHttpServer(requestHandler);
16733
18083
  }
16734
-
16735
- return createHttpServer(requestHandler);
18084
+ // Expose the handler on the server so the remote-pairing relay client can
18085
+ // reuse it: RPC frames arriving over the encrypted relay channel get
18086
+ // wrapped in synthetic req/res objects and dispatched through the SAME
18087
+ // listener LAN HTTP requests use, so authn/authz/cookies/CSRF behave
18088
+ // identically off-LAN.
18089
+ server.requestHandler = requestHandler;
18090
+ return server;
16736
18091
  }
16737
18092
 
16738
18093
  function requestWantsHtml(req) {
@@ -18036,6 +19391,7 @@ function buildConfig(cli) {
18036
19391
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
18037
19392
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
18038
19393
  return {
19394
+ envFile: resolveEnvFile(cli.envFile),
18039
19395
  dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
18040
19396
  once: cli.once,
18041
19397
  codexHome,
@@ -18050,6 +19406,12 @@ function buildConfig(cli) {
18050
19406
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
18051
19407
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
18052
19408
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
19409
+ // Remote-pairing relay (PWA off-LAN). Off by default — bridges that
19410
+ // never leave the trusted LAN don't need to open an outbound connection
19411
+ // to the relay. Toggle with REMOTE_PAIRING_ENABLED=true in
19412
+ // ~/.viveworker/remote-pairing.env (or any source loadEnvFile reads).
19413
+ remotePairingEnabled: boolEnv("REMOTE_PAIRING_ENABLED", false),
19414
+ remotePairingRelayUrl: remotePairingRelayUrlFromEnv(),
18053
19415
  // share.viveworker.com — HTML hosting backed by the same A2A credentials.
18054
19416
  // Override for staging / self-hosted deployments via VIVEWORKER_SHARE_URL.
18055
19417
  a2aShareUrl: stripTrailingSlash(
@@ -18688,32 +20050,70 @@ function resolveEnvFile(explicitPath) {
18688
20050
  return path.join(workspaceRoot, "viveworker.env");
18689
20051
  }
18690
20052
 
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
- }
20053
+ function parseEnvText(text) {
20054
+ const output = {};
20055
+ for (const rawLine of String(text || "").split(/\r?\n/u)) {
20056
+ const line = rawLine.trim();
20057
+ if (!line || line.startsWith("#")) {
20058
+ continue;
20059
+ }
18699
20060
 
18700
- const separator = line.indexOf("=");
18701
- if (separator === -1) {
18702
- continue;
18703
- }
20061
+ const separator = line.indexOf("=");
20062
+ if (separator === -1) {
20063
+ continue;
20064
+ }
18704
20065
 
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;
20066
+ const key = line.slice(0, separator).trim();
20067
+ let value = line.slice(separator + 1).trim();
20068
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
20069
+ value = value.slice(1, -1);
18711
20070
  }
20071
+ output[key] = value;
20072
+ }
20073
+ return output;
20074
+ }
20075
+
20076
+ function loadEnvFile(filePath) {
20077
+ try {
20078
+ Object.assign(process.env, parseEnvText(readFileSync(filePath, "utf8")));
18712
20079
  } catch {
18713
20080
  // Optional env file.
18714
20081
  }
18715
20082
  }
18716
20083
 
20084
+ function refreshPairingConfigFromEnvFile(config) {
20085
+ if (!config?.envFile) {
20086
+ return false;
20087
+ }
20088
+
20089
+ let entries;
20090
+ try {
20091
+ entries = parseEnvText(readFileSync(config.envFile, "utf8"));
20092
+ } catch {
20093
+ return false;
20094
+ }
20095
+
20096
+ const nextCode = entries.PAIRING_CODE ?? "";
20097
+ const nextToken = entries.PAIRING_TOKEN ?? "";
20098
+ const nextExpiresAtMs = Number(entries.PAIRING_EXPIRES_AT_MS) || 0;
20099
+ if (
20100
+ config.pairingCode === nextCode &&
20101
+ config.pairingToken === nextToken &&
20102
+ Number(config.pairingExpiresAtMs) === nextExpiresAtMs
20103
+ ) {
20104
+ return false;
20105
+ }
20106
+
20107
+ config.pairingCode = nextCode;
20108
+ config.pairingToken = nextToken;
20109
+ config.pairingExpiresAtMs = nextExpiresAtMs;
20110
+ process.env.PAIRING_CODE = nextCode;
20111
+ process.env.PAIRING_TOKEN = nextToken;
20112
+ process.env.PAIRING_EXPIRES_AT_MS = String(nextExpiresAtMs);
20113
+ console.log(`[pairing] refreshed credentials from ${config.envFile}`);
20114
+ return true;
20115
+ }
20116
+
18717
20117
  async function maybeRotateStartupPairingEnv(envFile) {
18718
20118
  if (!truthy(process.env.AUTH_REQUIRED)) {
18719
20119
  return;
@@ -19033,16 +20433,20 @@ function extractRolloutMessageText(content) {
19033
20433
  if (!Array.isArray(content)) {
19034
20434
  return "";
19035
20435
  }
19036
- return cleanText(
19037
- content
19038
- .map((entry) =>
19039
- isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
19040
- ? normalizeTimelineMessageText(entry.text ?? "")
19041
- : ""
19042
- )
19043
- .filter(Boolean)
19044
- .join("\n")
19045
- );
20436
+ // The per-entry text is already passed through `normalizeTimelineMessageText`
20437
+ // (which preserves newlines for the markdown renderer). The final wrap used
20438
+ // to be `cleanText`, which collapses every \s+ — newlines included — into
20439
+ // a single space, flattening tables / code fences / lists into one
20440
+ // illegible line. Use a plain trim instead so the structure survives.
20441
+ return content
20442
+ .map((entry) =>
20443
+ isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
20444
+ ? normalizeTimelineMessageText(entry.text ?? "")
20445
+ : ""
20446
+ )
20447
+ .filter(Boolean)
20448
+ .join("\n")
20449
+ .trim();
19046
20450
  }
19047
20451
 
19048
20452
  function extractTitleOnlyJsonTitleFromRolloutContent(content) {
@@ -19569,21 +20973,68 @@ function isInlineImagePlaceholderText(value) {
19569
20973
  return cleanText(stripInlineImagePlaceholderMarkup(value)) === "" && /<\/?image\b/iu.test(String(value || ""));
19570
20974
  }
19571
20975
 
20976
+ function stripCodexUserAttachmentEnvelope(value) {
20977
+ const source = String(value || "");
20978
+ if (!/Files mentioned by the user:/iu.test(source) || !/My request for Codex:/iu.test(source)) {
20979
+ return source;
20980
+ }
20981
+
20982
+ const marker = source.match(/\bMy request for Codex:\s*/iu);
20983
+ if (!marker || marker.index == null) {
20984
+ return source;
20985
+ }
20986
+ return source.slice(marker.index + marker[0].length).trim();
20987
+ }
20988
+
19572
20989
  function normalizeTimelineMessageText(value, locale = DEFAULT_LOCALE) {
19573
- return normalizeLongText(replaceTurnAbortedMarkup(stripInlineImagePlaceholderMarkup(value), locale));
20990
+ return normalizeLongText(
20991
+ replaceTurnAbortedMarkup(
20992
+ stripCodexUserAttachmentEnvelope(stripInlineImagePlaceholderMarkup(value)),
20993
+ locale
20994
+ )
20995
+ );
20996
+ }
20997
+
20998
+ function extractTimelineImagePaths(value) {
20999
+ const source = String(value || "");
21000
+ if (!/Files mentioned by the user:/iu.test(source)) {
21001
+ return [];
21002
+ }
21003
+
21004
+ const paths = [];
21005
+ for (const match of source.matchAll(/(\/Users\/[^\n\r]+?\.(?:png|jpe?g|webp|gif|heic|heif))(?:\s|$)/giu)) {
21006
+ const filePath = cleanText(match[1] || "");
21007
+ if (filePath) {
21008
+ paths.push(filePath);
21009
+ }
21010
+ }
21011
+ return normalizeTimelineImagePaths(paths);
19574
21012
  }
19575
21013
 
19576
21014
  function normalizeTimelineImagePaths(value) {
19577
21015
  if (!Array.isArray(value)) {
19578
21016
  return [];
19579
21017
  }
19580
- return value
19581
- .map((entry) => cleanText(entry || ""))
19582
- .filter(Boolean);
21018
+ const output = [];
21019
+ const seen = new Set();
21020
+ for (const entry of value) {
21021
+ const normalized = cleanText(entry || "");
21022
+ if (!normalized || seen.has(normalized)) {
21023
+ continue;
21024
+ }
21025
+ seen.add(normalized);
21026
+ output.push(normalized);
21027
+ }
21028
+ return output;
19583
21029
  }
19584
21030
 
19585
21031
  function normalizeNotificationText(value, locale = DEFAULT_LOCALE) {
19586
- return normalizeLongText(replaceTurnAbortedMarkup(stripNotificationMarkup(value), locale))
21032
+ return normalizeLongText(
21033
+ replaceTurnAbortedMarkup(
21034
+ stripCodexUserAttachmentEnvelope(stripNotificationMarkup(value)),
21035
+ locale
21036
+ )
21037
+ )
19587
21038
  .replace(/\n{2,}/gu, "\n")
19588
21039
  .trim();
19589
21040
  }
@@ -19644,6 +21095,65 @@ function cleanText(value) {
19644
21095
  return String(value || "").replace(/\s+/gu, " ").trim();
19645
21096
  }
19646
21097
 
21098
+ function normalizeRemotePairingRelayUrl(value) {
21099
+ const trimmed = cleanText(value);
21100
+ if (!trimmed) {
21101
+ return { ok: true, value: "" };
21102
+ }
21103
+
21104
+ let parsed;
21105
+ try {
21106
+ parsed = new URL(trimmed);
21107
+ } catch {
21108
+ return {
21109
+ ok: false,
21110
+ message: "relayUrl must be a valid URL",
21111
+ };
21112
+ }
21113
+
21114
+ if (parsed.username || parsed.password) {
21115
+ return {
21116
+ ok: false,
21117
+ message: "relayUrl must not include credentials",
21118
+ };
21119
+ }
21120
+
21121
+ if (parsed.protocol === "wss:") {
21122
+ return { ok: true, value: trimmed };
21123
+ }
21124
+
21125
+ if (parsed.protocol === "ws:" && isLoopbackRelayHost(parsed.hostname)) {
21126
+ return { ok: true, value: trimmed };
21127
+ }
21128
+
21129
+ return {
21130
+ ok: false,
21131
+ message: "relayUrl must use wss://; ws:// is allowed only for localhost or loopback development",
21132
+ };
21133
+ }
21134
+
21135
+ function isLoopbackRelayHost(hostname) {
21136
+ const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/gu, "");
21137
+ return (
21138
+ host === "localhost" ||
21139
+ host.endsWith(".localhost") ||
21140
+ host === "::1" ||
21141
+ /^127(?:\.\d{1,3}){3}$/u.test(host)
21142
+ );
21143
+ }
21144
+
21145
+ function remotePairingRelayUrlFromEnv() {
21146
+ const raw = process.env.REMOTE_PAIRING_RELAY_URL || "";
21147
+ const result = normalizeRemotePairingRelayUrl(raw);
21148
+ if (result.ok) {
21149
+ return result.value;
21150
+ }
21151
+ if (cleanText(raw)) {
21152
+ console.warn(`[remote-pairing] ignoring REMOTE_PAIRING_RELAY_URL: ${result.message}`);
21153
+ }
21154
+ return "";
21155
+ }
21156
+
19647
21157
  function extractTitleOnlyJsonTitle(value) {
19648
21158
  const rawText = String(value ?? "").trim();
19649
21159
  if (!rawText) {
@@ -20068,6 +21578,38 @@ async function main() {
20068
21578
  }
20069
21579
  }
20070
21580
 
21581
+ // --- Remote-pairing relay (PWA off-LAN) ---
21582
+ // Optional. When enabled, the bridge keeps an outbound WebSocket open per
21583
+ // paired phone (`~/.viveworker/remote-pairings.json`) to the remote-pairing
21584
+ // relay (Cloudflare Worker + Durable Object). RPC frames arriving over
21585
+ // those channels are dispatched through the same `requestHandler` LAN HTTP
21586
+ // uses, so cookies / auth / CSRF behave identically off-LAN.
21587
+ //
21588
+ // If disabled (default), the orchestrator returns a dormant handle and
21589
+ // does no I/O. Pairings file changes are auto-reloaded via fs.watch — no
21590
+ // outer loop needed here.
21591
+ if (approvalServer && approvalServer.requestHandler) {
21592
+ try {
21593
+ runtime.remotePairingHandle = await startRemotePairingRelay({
21594
+ enabled: config.remotePairingEnabled,
21595
+ relayUrl: config.remotePairingRelayUrl || undefined,
21596
+ requestListener: approvalServer.requestHandler,
21597
+ logger: console,
21598
+ auditEventSink: recordRemotePairingAudit,
21599
+ });
21600
+ const status = runtime.remotePairingHandle.getStatus();
21601
+ if (status.enabled) {
21602
+ console.log(
21603
+ `[remote-pairing] connected relay=${status.relayUrl} ` +
21604
+ `identity=${status.identityFingerprint} ` +
21605
+ `paired=${status.sessions.length}`
21606
+ );
21607
+ }
21608
+ } catch (err) {
21609
+ console.error(`[remote-pairing] startup failed: ${err?.message}`);
21610
+ }
21611
+ }
21612
+
20071
21613
  let lastA2aEnvCheckAt = 0;
20072
21614
  const A2A_ENV_CHECK_INTERVAL_MS = 30_000; // check every 30 seconds
20073
21615
 
@@ -20154,6 +21696,13 @@ async function main() {
20154
21696
 
20155
21697
  stopRelayPolling();
20156
21698
 
21699
+ if (runtime.remotePairingHandle) {
21700
+ try {
21701
+ runtime.remotePairingHandle.close();
21702
+ } catch {}
21703
+ runtime.remotePairingHandle = null;
21704
+ }
21705
+
20157
21706
  if (runtime.ipcClient) {
20158
21707
  runtime.ipcClient.stop();
20159
21708
  }