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