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.
- package/.agents/plugins/marketplace.json +20 -0
- package/README.md +115 -4
- package/package.json +13 -3
- package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
- package/plugins/viveworker-control-plane/.mcp.json +11 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
- package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
- package/scripts/lib/markdown-render.mjs +128 -1
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/mcp-server.mjs +891 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/stats-cli.mjs +683 -0
- package/scripts/viveworker-bridge.mjs +1676 -127
- package/scripts/viveworker.mjs +289 -7
- package/skills/viveworker-control-plane/SKILL.md +104 -0
- package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
- package/templates/CLAUDE.viveworker.md +67 -0
- package/web/app.css +683 -9
- package/web/app.js +1780 -187
- package/web/build-id.js +1 -0
- package/web/i18n.js +187 -9
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-pulse.svg +16 -1
- package/web/index.html +32 -2
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- package/web/sw.js +190 -20
- package/web/icons/viveworker-beacon-v.svg +0 -19
- package/web/icons/viveworker-icon-1024.png +0 -0
- 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
|
|
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
|
|
2896
|
-
fileRefs
|
|
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
|
|
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
|
|
3178
|
-
fileRefs
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5206
|
-
const
|
|
5207
|
-
const marker
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
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
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
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
|
|
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
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
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
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
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
|
|
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
|
|
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
|
|
5840
|
-
|
|
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
|
|
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
|
-
|
|
10481
|
-
|
|
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
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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">
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18692
|
-
|
|
18693
|
-
|
|
18694
|
-
|
|
18695
|
-
|
|
18696
|
-
|
|
18697
|
-
|
|
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
|
-
|
|
18701
|
-
|
|
18702
|
-
|
|
18703
|
-
|
|
20061
|
+
const separator = line.indexOf("=");
|
|
20062
|
+
if (separator === -1) {
|
|
20063
|
+
continue;
|
|
20064
|
+
}
|
|
18704
20065
|
|
|
18705
|
-
|
|
18706
|
-
|
|
18707
|
-
|
|
18708
|
-
|
|
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
|
-
|
|
19037
|
-
|
|
19038
|
-
|
|
19039
|
-
|
|
19040
|
-
|
|
19041
|
-
|
|
19042
|
-
|
|
19043
|
-
|
|
19044
|
-
|
|
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(
|
|
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
|
-
|
|
19581
|
-
|
|
19582
|
-
|
|
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(
|
|
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
|
}
|