viveworker 0.7.0-beta.3 → 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 +1696 -223
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +727 -9
- package/web/app.js +1810 -232
- package/web/i18n.js +207 -17
- package/web/index.html +115 -1
- 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
|
|
|
@@ -680,7 +696,7 @@ function withNotificationIcon(kind, title) {
|
|
|
680
696
|
|
|
681
697
|
function normalizeTimelineOutcome(value) {
|
|
682
698
|
const normalized = cleanText(value || "").toLowerCase();
|
|
683
|
-
return ["pending", "approved", "rejected", "implemented", "dismissed", "submitted"].includes(normalized)
|
|
699
|
+
return ["pending", "approved", "rejected", "failed", "implemented", "dismissed", "submitted"].includes(normalized)
|
|
684
700
|
? normalized
|
|
685
701
|
: "";
|
|
686
702
|
}
|
|
@@ -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),
|
|
@@ -2993,6 +3033,70 @@ function normalizeTimelineEntries(rawItems, maxItems) {
|
|
|
2993
3033
|
return deduped;
|
|
2994
3034
|
}
|
|
2995
3035
|
|
|
3036
|
+
// Cheap structural diff used by record* helpers to decide whether a rebuilt
|
|
3037
|
+
// timeline / history / code-events array actually changed in a way worth
|
|
3038
|
+
// persisting. The previous implementation `JSON.stringify(a) !== JSON.stringify(b)`
|
|
3039
|
+
// allocated several megabytes of intermediate strings on every call when the
|
|
3040
|
+
// projection included `diffText` (5+ KB per file_event × ~250 entries × 2
|
|
3041
|
+
// sides), which dominated scavenger GC time in busy sessions and showed up as
|
|
3042
|
+
// a 25% CPU sink in `JsonStringifier::Serialize_*` under V8 sampling. Walking
|
|
3043
|
+
// the arrays in lockstep and bailing on the first mismatched primitive lets
|
|
3044
|
+
// the common "fresh entry prepended" case exit on the very first stableId
|
|
3045
|
+
// compare with zero allocations.
|
|
3046
|
+
//
|
|
3047
|
+
// Field semantics:
|
|
3048
|
+
// - String / number / boolean fields use value-equality via `===` (works
|
|
3049
|
+
// across object reconstruction by `normalizeTimelineEntry`).
|
|
3050
|
+
// - `Array` fields (e.g. `previousFileRefs`) shallow-compare lengths and the
|
|
3051
|
+
// `path` of each element rather than serializing.
|
|
3052
|
+
// - `null` / `undefined` are treated as equal to each other but distinct from
|
|
3053
|
+
// any defined value.
|
|
3054
|
+
function timelineProjectionChanged(nextItems, previousItems, fieldKeys) {
|
|
3055
|
+
const a = Array.isArray(nextItems) ? nextItems : [];
|
|
3056
|
+
const b = Array.isArray(previousItems) ? previousItems : [];
|
|
3057
|
+
if (a.length !== b.length) {
|
|
3058
|
+
return true;
|
|
3059
|
+
}
|
|
3060
|
+
for (let i = 0; i < a.length; i++) {
|
|
3061
|
+
const ai = a[i];
|
|
3062
|
+
const bi = b[i];
|
|
3063
|
+
if (ai === bi) continue;
|
|
3064
|
+
if (!ai || !bi) return true;
|
|
3065
|
+
for (let k = 0; k < fieldKeys.length; k++) {
|
|
3066
|
+
const key = fieldKeys[k];
|
|
3067
|
+
const av = ai[key];
|
|
3068
|
+
const bv = bi[key];
|
|
3069
|
+
if (av === bv) continue;
|
|
3070
|
+
if (av == null && bv == null) continue;
|
|
3071
|
+
if (Array.isArray(av) && Array.isArray(bv)) {
|
|
3072
|
+
if (av.length !== bv.length) return true;
|
|
3073
|
+
let arrayDiffers = false;
|
|
3074
|
+
for (let j = 0; j < av.length; j++) {
|
|
3075
|
+
const aj = av[j];
|
|
3076
|
+
const bj = bv[j];
|
|
3077
|
+
if (aj === bj) continue;
|
|
3078
|
+
if (!aj || !bj) {
|
|
3079
|
+
arrayDiffers = true;
|
|
3080
|
+
break;
|
|
3081
|
+
}
|
|
3082
|
+
// Most array-of-object fields here are fileRef-shaped: identity is
|
|
3083
|
+
// captured by `path`. Falling back to JSON for unrecognised shapes
|
|
3084
|
+
// is unnecessary — a path mismatch (or a null path either side) is
|
|
3085
|
+
// sufficient to mark the projection changed.
|
|
3086
|
+
if (aj.path !== bj.path) {
|
|
3087
|
+
arrayDiffers = true;
|
|
3088
|
+
break;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
if (arrayDiffers) return true;
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
return true;
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
return false;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
2996
3100
|
function isCodeEventEntry(raw) {
|
|
2997
3101
|
if (!isPlainObject(raw)) {
|
|
2998
3102
|
return false;
|
|
@@ -3063,7 +3167,8 @@ function normalizeTimelineEntry(raw) {
|
|
|
3063
3167
|
}
|
|
3064
3168
|
|
|
3065
3169
|
const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
|
|
3066
|
-
const
|
|
3170
|
+
const rawMessageText = raw.messageText ?? "";
|
|
3171
|
+
const messageText = normalizeTimelineMessageText(rawMessageText);
|
|
3067
3172
|
const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
|
|
3068
3173
|
const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
|
|
3069
3174
|
const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
|
|
@@ -3098,6 +3203,14 @@ function normalizeTimelineEntry(raw) {
|
|
|
3098
3203
|
threadLabel ||
|
|
3099
3204
|
kindTitle(DEFAULT_LOCALE, kind);
|
|
3100
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
|
+
);
|
|
3101
3214
|
|
|
3102
3215
|
const normalized = {
|
|
3103
3216
|
stableId,
|
|
@@ -3110,8 +3223,8 @@ function normalizeTimelineEntry(raw) {
|
|
|
3110
3223
|
messageText,
|
|
3111
3224
|
fileEventType,
|
|
3112
3225
|
previousFileRefs: normalizeTimelineFileRefs(raw.previousFileRefs ?? []),
|
|
3113
|
-
imagePaths
|
|
3114
|
-
fileRefs
|
|
3226
|
+
imagePaths,
|
|
3227
|
+
fileRefs,
|
|
3115
3228
|
diffText,
|
|
3116
3229
|
diffSource,
|
|
3117
3230
|
diffAvailable: raw.diffAvailable === true || Boolean(diffText),
|
|
@@ -3158,35 +3271,21 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
|
|
|
3158
3271
|
[normalized, ...runtime.recentTimelineEntries.filter((item) => item.stableId !== normalized.stableId)],
|
|
3159
3272
|
config.maxTimelineEntries
|
|
3160
3273
|
);
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
JSON.stringify(
|
|
3177
|
-
runtime.recentTimelineEntries.map((item) => [
|
|
3178
|
-
item.stableId,
|
|
3179
|
-
item.title,
|
|
3180
|
-
item.createdAtMs,
|
|
3181
|
-
item.diffAvailable,
|
|
3182
|
-
item.diffSource,
|
|
3183
|
-
item.diffAddedLines,
|
|
3184
|
-
item.diffRemovedLines,
|
|
3185
|
-
item.diffText,
|
|
3186
|
-
item.previousFileRefs,
|
|
3187
|
-
item.cwd,
|
|
3188
|
-
])
|
|
3189
|
-
);
|
|
3274
|
+
// Note: diffText is intentionally omitted from the projection — the
|
|
3275
|
+
// line-count fields (`diffAddedLines` / `diffRemovedLines`) are a sufficient
|
|
3276
|
+
// proxy for "diff content changed" and avoid materialising a megabyte-class
|
|
3277
|
+
// intermediate string on every record call.
|
|
3278
|
+
const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
|
|
3279
|
+
"stableId",
|
|
3280
|
+
"title",
|
|
3281
|
+
"createdAtMs",
|
|
3282
|
+
"diffAvailable",
|
|
3283
|
+
"diffSource",
|
|
3284
|
+
"diffAddedLines",
|
|
3285
|
+
"diffRemovedLines",
|
|
3286
|
+
"previousFileRefs",
|
|
3287
|
+
"cwd",
|
|
3288
|
+
]);
|
|
3190
3289
|
runtime.recentTimelineEntries = nextItems;
|
|
3191
3290
|
state.recentTimelineEntries = nextItems;
|
|
3192
3291
|
return changed;
|
|
@@ -3205,35 +3304,19 @@ function recordCodeEvent({ config, runtime, state, entry }) {
|
|
|
3205
3304
|
[normalized, ...runtime.recentCodeEvents.filter((item) => item.stableId !== normalized.stableId)],
|
|
3206
3305
|
config.maxCodeEvents
|
|
3207
3306
|
);
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
])
|
|
3222
|
-
) !==
|
|
3223
|
-
JSON.stringify(
|
|
3224
|
-
runtime.recentCodeEvents.map((item) => [
|
|
3225
|
-
item.stableId,
|
|
3226
|
-
item.title,
|
|
3227
|
-
item.createdAtMs,
|
|
3228
|
-
item.diffAvailable,
|
|
3229
|
-
item.diffSource,
|
|
3230
|
-
item.diffAddedLines,
|
|
3231
|
-
item.diffRemovedLines,
|
|
3232
|
-
item.diffText,
|
|
3233
|
-
item.previousFileRefs,
|
|
3234
|
-
item.cwd,
|
|
3235
|
-
])
|
|
3236
|
-
);
|
|
3307
|
+
// See `recordTimelineEntry` for the rationale on dropping `diffText` from
|
|
3308
|
+
// the projection — same megabyte-stringify hot path; same line-count proxy.
|
|
3309
|
+
const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
|
|
3310
|
+
"stableId",
|
|
3311
|
+
"title",
|
|
3312
|
+
"createdAtMs",
|
|
3313
|
+
"diffAvailable",
|
|
3314
|
+
"diffSource",
|
|
3315
|
+
"diffAddedLines",
|
|
3316
|
+
"diffRemovedLines",
|
|
3317
|
+
"previousFileRefs",
|
|
3318
|
+
"cwd",
|
|
3319
|
+
]);
|
|
3237
3320
|
runtime.recentCodeEvents = nextItems;
|
|
3238
3321
|
state.recentCodeEvents = nextItems;
|
|
3239
3322
|
if (changed) {
|
|
@@ -3257,37 +3340,19 @@ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
|
|
|
3257
3340
|
],
|
|
3258
3341
|
config.maxCodeEvents
|
|
3259
3342
|
);
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
item.cwd,
|
|
3274
|
-
])
|
|
3275
|
-
) !==
|
|
3276
|
-
JSON.stringify(
|
|
3277
|
-
runtime.recentCodeEvents.map((item) => [
|
|
3278
|
-
item.stableId,
|
|
3279
|
-
item.title,
|
|
3280
|
-
item.createdAtMs,
|
|
3281
|
-
item.threadLabel,
|
|
3282
|
-
item.diffAvailable,
|
|
3283
|
-
item.diffSource,
|
|
3284
|
-
item.diffAddedLines,
|
|
3285
|
-
item.diffRemovedLines,
|
|
3286
|
-
item.diffText,
|
|
3287
|
-
item.previousFileRefs,
|
|
3288
|
-
item.cwd,
|
|
3289
|
-
])
|
|
3290
|
-
);
|
|
3343
|
+
// See `recordTimelineEntry` for the rationale on dropping `diffText`.
|
|
3344
|
+
const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
|
|
3345
|
+
"stableId",
|
|
3346
|
+
"title",
|
|
3347
|
+
"createdAtMs",
|
|
3348
|
+
"threadLabel",
|
|
3349
|
+
"diffAvailable",
|
|
3350
|
+
"diffSource",
|
|
3351
|
+
"diffAddedLines",
|
|
3352
|
+
"diffRemovedLines",
|
|
3353
|
+
"previousFileRefs",
|
|
3354
|
+
"cwd",
|
|
3355
|
+
]);
|
|
3291
3356
|
runtime.recentCodeEvents = nextItems;
|
|
3292
3357
|
state.recentCodeEvents = nextItems;
|
|
3293
3358
|
if (changed) {
|
|
@@ -3353,9 +3418,11 @@ function recordHistoryItem({ config, runtime, state, item }) {
|
|
|
3353
3418
|
[normalized, ...runtime.recentHistoryItems.filter((entry) => entry.stableId !== normalized.stableId)],
|
|
3354
3419
|
config.maxHistoryItems
|
|
3355
3420
|
);
|
|
3356
|
-
const changed =
|
|
3357
|
-
|
|
3358
|
-
|
|
3421
|
+
const changed = timelineProjectionChanged(nextItems, runtime.recentHistoryItems, [
|
|
3422
|
+
"stableId",
|
|
3423
|
+
"title",
|
|
3424
|
+
"createdAtMs",
|
|
3425
|
+
]);
|
|
3359
3426
|
runtime.recentHistoryItems = nextItems;
|
|
3360
3427
|
state.recentHistoryItems = nextItems;
|
|
3361
3428
|
return changed;
|
|
@@ -3784,7 +3851,7 @@ async function scanOnce({ config, runtime, state }) {
|
|
|
3784
3851
|
if (config.webUiEnabled) {
|
|
3785
3852
|
let claudeTranscriptChanged = false;
|
|
3786
3853
|
if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
|
|
3787
|
-
runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
|
|
3854
|
+
runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir, config.claudeTranscriptMaxAgeMs);
|
|
3788
3855
|
runtime.lastClaudeScanAt = now;
|
|
3789
3856
|
}
|
|
3790
3857
|
let claudeSessionTitlesChanged = false;
|
|
@@ -4122,7 +4189,16 @@ async function refreshClaudeSessionTitles(runtime) {
|
|
|
4122
4189
|
return changed;
|
|
4123
4190
|
}
|
|
4124
4191
|
|
|
4125
|
-
async function listClaudeTranscriptFiles(claudeProjectsDir) {
|
|
4192
|
+
async function listClaudeTranscriptFiles(claudeProjectsDir, maxAgeMs = 0) {
|
|
4193
|
+
// ~/.claude/projects can accumulate thousands of .jsonl files (one per
|
|
4194
|
+
// session, never garbage-collected by Claude Code itself). Returning all of
|
|
4195
|
+
// them means processClaudeTranscriptFile fs.stat()s each every poll
|
|
4196
|
+
// iteration — for a long-time user that's 3000+ stats every 2.5 s, all but
|
|
4197
|
+
// a handful of which are guaranteed-cold. Filter by mtime here so the
|
|
4198
|
+
// per-iteration working set tracks "active sessions", not "total sessions
|
|
4199
|
+
// ever". maxAgeMs=0 disables filtering for callers who explicitly want
|
|
4200
|
+
// everything (none in the bridge today).
|
|
4201
|
+
const cutoffMs = maxAgeMs > 0 ? Date.now() - maxAgeMs : 0;
|
|
4126
4202
|
try {
|
|
4127
4203
|
const result = [];
|
|
4128
4204
|
const entries = await fs.readdir(claudeProjectsDir, { withFileTypes: true });
|
|
@@ -4133,7 +4209,17 @@ async function listClaudeTranscriptFiles(claudeProjectsDir) {
|
|
|
4133
4209
|
const files = await fs.readdir(projectPath, { withFileTypes: true });
|
|
4134
4210
|
for (const file of files) {
|
|
4135
4211
|
if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
|
|
4136
|
-
|
|
4212
|
+
const fullPath = path.join(projectPath, file.name);
|
|
4213
|
+
if (cutoffMs > 0) {
|
|
4214
|
+
try {
|
|
4215
|
+
const stat = await fs.stat(fullPath);
|
|
4216
|
+
if (stat.mtimeMs < cutoffMs) continue;
|
|
4217
|
+
} catch {
|
|
4218
|
+
// skip unreadable file (vanished or permissioned out)
|
|
4219
|
+
continue;
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
result.push(fullPath);
|
|
4137
4223
|
}
|
|
4138
4224
|
} catch {
|
|
4139
4225
|
// skip unreadable project dirs
|
|
@@ -5144,8 +5230,8 @@ async function readRecentRolloutUserMessagesWithImages({ filePath, maxBytes }) {
|
|
|
5144
5230
|
async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
|
|
5145
5231
|
const conditions = [
|
|
5146
5232
|
`id > ${Math.max(0, Number(cursorId) || 0)}`,
|
|
5147
|
-
`target
|
|
5148
|
-
`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"%')`,
|
|
5149
5235
|
`feedback_log_body LIKE '%"type":"message"%'`,
|
|
5150
5236
|
`feedback_log_body LIKE '%"role":"assistant"%'`,
|
|
5151
5237
|
];
|
|
@@ -5155,28 +5241,50 @@ async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
|
|
|
5155
5241
|
}
|
|
5156
5242
|
|
|
5157
5243
|
const sql = `
|
|
5158
|
-
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
|
|
5159
5259
|
FROM logs
|
|
5160
5260
|
WHERE ${conditions.join("\n AND ")}
|
|
5161
|
-
ORDER BY id ASC
|
|
5261
|
+
ORDER BY logs.id ASC
|
|
5162
5262
|
LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
|
|
5163
5263
|
`;
|
|
5164
5264
|
|
|
5165
5265
|
return runSqliteJsonQuery(logsDbFile, sql);
|
|
5166
5266
|
}
|
|
5167
5267
|
|
|
5168
|
-
function
|
|
5169
|
-
const
|
|
5170
|
-
const marker
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
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
|
+
}
|
|
5174
5280
|
}
|
|
5281
|
+
return null;
|
|
5282
|
+
}
|
|
5175
5283
|
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5284
|
+
function buildSqliteTimelineEntry({ row, config, runtime }) {
|
|
5285
|
+
const body = String(row?.feedback_log_body ?? "");
|
|
5286
|
+
const payload = parseResponsesEventPayload(body);
|
|
5287
|
+
if (!payload) {
|
|
5180
5288
|
return null;
|
|
5181
5289
|
}
|
|
5182
5290
|
|
|
@@ -5200,7 +5308,12 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
|
|
|
5200
5308
|
return null;
|
|
5201
5309
|
}
|
|
5202
5310
|
|
|
5203
|
-
const
|
|
5311
|
+
const contextLogBody = String(row?.context_log_body ?? "");
|
|
5312
|
+
const threadId = cleanText(
|
|
5313
|
+
row.thread_id ||
|
|
5314
|
+
extractThreadIdFromLogBody(body) ||
|
|
5315
|
+
extractThreadIdFromLogBody(contextLogBody)
|
|
5316
|
+
);
|
|
5204
5317
|
if (!threadId) {
|
|
5205
5318
|
return null;
|
|
5206
5319
|
}
|
|
@@ -5328,6 +5441,25 @@ async function findReplyUploadFallback(config, sourcePath, usedPaths = new Set()
|
|
|
5328
5441
|
return candidates[0]?.filePath || "";
|
|
5329
5442
|
}
|
|
5330
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
|
+
|
|
5331
5463
|
async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
|
|
5332
5464
|
const normalizedSourcePath = resolvePath(cleanText(sourcePath || ""));
|
|
5333
5465
|
if (!normalizedSourcePath) {
|
|
@@ -5344,6 +5476,16 @@ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
|
|
|
5344
5476
|
await fs.copyFile(normalizedSourcePath, destinationPath);
|
|
5345
5477
|
return destinationPath;
|
|
5346
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
|
+
}
|
|
5347
5489
|
console.warn(`[timeline-image-copy-skipped] ${error?.message || error}`);
|
|
5348
5490
|
return "";
|
|
5349
5491
|
}
|
|
@@ -5356,8 +5498,12 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
|
|
|
5356
5498
|
}
|
|
5357
5499
|
|
|
5358
5500
|
const aliases = isPlainObject(state.timelineImagePathAliases) ? state.timelineImagePathAliases : (state.timelineImagePathAliases = {});
|
|
5501
|
+
const copyFailures = isPlainObject(state.timelineImagePathCopyFailures)
|
|
5502
|
+
? state.timelineImagePathCopyFailures
|
|
5503
|
+
: (state.timelineImagePathCopyFailures = {});
|
|
5359
5504
|
const usedFallbacks = new Set();
|
|
5360
5505
|
const nextPaths = [];
|
|
5506
|
+
const copyRetryMs = 10 * 60 * 1000;
|
|
5361
5507
|
|
|
5362
5508
|
for (const rawPath of normalizedImagePaths) {
|
|
5363
5509
|
const normalizedPath = cleanText(rawPath || "");
|
|
@@ -5367,24 +5513,38 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
|
|
|
5367
5513
|
|
|
5368
5514
|
const aliasedPath = cleanText(aliases[normalizedPath] || "");
|
|
5369
5515
|
if (aliasedPath) {
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
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
|
+
}
|
|
5376
5524
|
}
|
|
5377
5525
|
}
|
|
5378
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
|
+
|
|
5379
5537
|
let existingSourcePath = normalizedPath;
|
|
5380
5538
|
try {
|
|
5381
5539
|
await fs.access(existingSourcePath);
|
|
5382
|
-
} catch {
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5540
|
+
} catch (error) {
|
|
5541
|
+
if (!isFileAccessDeniedError(error)) {
|
|
5542
|
+
existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
|
|
5543
|
+
if (!existingSourcePath) {
|
|
5544
|
+
continue;
|
|
5545
|
+
}
|
|
5546
|
+
usedFallbacks.add(existingSourcePath);
|
|
5386
5547
|
}
|
|
5387
|
-
usedFallbacks.add(existingSourcePath);
|
|
5388
5548
|
}
|
|
5389
5549
|
|
|
5390
5550
|
let persistentPath = existingSourcePath;
|
|
@@ -5392,6 +5552,11 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
|
|
|
5392
5552
|
persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath) || existingSourcePath;
|
|
5393
5553
|
}
|
|
5394
5554
|
|
|
5555
|
+
if (persistentPath === existingSourcePath && !existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
|
|
5556
|
+
copyFailures[normalizedPath] = Date.now();
|
|
5557
|
+
} else {
|
|
5558
|
+
delete copyFailures[normalizedPath];
|
|
5559
|
+
}
|
|
5395
5560
|
aliases[normalizedPath] = persistentPath;
|
|
5396
5561
|
nextPaths.push(persistentPath);
|
|
5397
5562
|
}
|
|
@@ -5738,8 +5903,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
|
|
|
5738
5903
|
async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 }) {
|
|
5739
5904
|
const conditions = [
|
|
5740
5905
|
`id > ${Math.max(0, Number(cursorId) || 0)}`,
|
|
5741
|
-
`target
|
|
5742
|
-
`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"%')`,
|
|
5743
5908
|
`feedback_log_body LIKE '%"type":"message"%'`,
|
|
5744
5909
|
`feedback_log_body LIKE '%"role":"assistant"%'`,
|
|
5745
5910
|
`feedback_log_body LIKE '%"phase":"final_answer"%'`,
|
|
@@ -5750,10 +5915,24 @@ async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 })
|
|
|
5750
5915
|
}
|
|
5751
5916
|
|
|
5752
5917
|
const sql = `
|
|
5753
|
-
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
|
|
5754
5933
|
FROM logs
|
|
5755
5934
|
WHERE ${conditions.join("\n AND ")}
|
|
5756
|
-
ORDER BY id ASC
|
|
5935
|
+
ORDER BY logs.id ASC
|
|
5757
5936
|
LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
|
|
5758
5937
|
`;
|
|
5759
5938
|
|
|
@@ -5799,16 +5978,8 @@ async function runSqliteJsonQuery(dbFile, sql) {
|
|
|
5799
5978
|
|
|
5800
5979
|
function buildSqliteCompletionEvent({ row, config, runtime }) {
|
|
5801
5980
|
const body = String(row?.feedback_log_body ?? "");
|
|
5802
|
-
const
|
|
5803
|
-
|
|
5804
|
-
if (markerIndex === -1) {
|
|
5805
|
-
return null;
|
|
5806
|
-
}
|
|
5807
|
-
|
|
5808
|
-
let payload;
|
|
5809
|
-
try {
|
|
5810
|
-
payload = JSON.parse(body.slice(markerIndex + marker.length));
|
|
5811
|
-
} catch {
|
|
5981
|
+
const payload = parseResponsesEventPayload(body);
|
|
5982
|
+
if (!payload) {
|
|
5812
5983
|
return null;
|
|
5813
5984
|
}
|
|
5814
5985
|
|
|
@@ -5821,7 +5992,12 @@ function buildSqliteCompletionEvent({ row, config, runtime }) {
|
|
|
5821
5992
|
return null;
|
|
5822
5993
|
}
|
|
5823
5994
|
|
|
5824
|
-
const
|
|
5995
|
+
const contextLogBody = String(row?.context_log_body ?? "");
|
|
5996
|
+
const threadId = cleanText(
|
|
5997
|
+
row.thread_id ||
|
|
5998
|
+
extractThreadIdFromLogBody(body) ||
|
|
5999
|
+
extractThreadIdFromLogBody(contextLogBody)
|
|
6000
|
+
);
|
|
5825
6001
|
const turnId = cleanText(extractTurnIdFromLogBody(body) || item.id || row.id);
|
|
5826
6002
|
if (!threadId || !turnId) {
|
|
5827
6003
|
return null;
|
|
@@ -8214,6 +8390,12 @@ function normalizeIpcErrorMessage(errorValue) {
|
|
|
8214
8390
|
return cleanText(String(errorValue ?? "")) || "ipc-request-failed";
|
|
8215
8391
|
}
|
|
8216
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
|
+
|
|
8217
8399
|
function buildDefaultCollaborationMode(threadState) {
|
|
8218
8400
|
// Fallback turns must leave Plan mode unless the caller explicitly opts in.
|
|
8219
8401
|
return buildRequestedCollaborationMode(threadState, "default");
|
|
@@ -8905,6 +9087,14 @@ function deriveClaudeSdkCliThreadLabel(messageText, conversationId = "") {
|
|
|
8905
9087
|
if (/^You are scoring Moltbook posts? for an AI agent\b/iu.test(single)) {
|
|
8906
9088
|
return "Moltbook scoring";
|
|
8907
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
|
+
}
|
|
8908
9098
|
if (/^Codex from another agent:/iu.test(single)) {
|
|
8909
9099
|
return truncate(cleanText(single.replace(/^Codex from another agent:\s*/iu, "")), 90) || "Cross-agent task";
|
|
8910
9100
|
}
|
|
@@ -8925,6 +9115,46 @@ function isHiddenClaudeInternalScoringText(text) {
|
|
|
8925
9115
|
return /^You are scoring Moltbook posts? for an AI agent\b/iu.test(single);
|
|
8926
9116
|
}
|
|
8927
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
|
+
|
|
8928
9158
|
function isHiddenCodexApprovalAssessmentText(text) {
|
|
8929
9159
|
const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
|
|
8930
9160
|
if (!single) {
|
|
@@ -8934,7 +9164,7 @@ function isHiddenCodexApprovalAssessmentText(text) {
|
|
|
8934
9164
|
}
|
|
8935
9165
|
|
|
8936
9166
|
function shouldHideInternalTimelineItem(item) {
|
|
8937
|
-
return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item);
|
|
9167
|
+
return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item) || shouldHideMoltbookHarnessItem(item);
|
|
8938
9168
|
}
|
|
8939
9169
|
|
|
8940
9170
|
function shouldHideClaudeInternalItem(item) {
|
|
@@ -8956,6 +9186,29 @@ function shouldHideClaudeInternalItem(item) {
|
|
|
8956
9186
|
);
|
|
8957
9187
|
}
|
|
8958
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
|
+
|
|
8959
9212
|
function shouldSuppressInternalScannedEvent(event) {
|
|
8960
9213
|
if (!isPlainObject(event)) {
|
|
8961
9214
|
return false;
|
|
@@ -9774,6 +10027,16 @@ function base64UrlDecode(value) {
|
|
|
9774
10027
|
return Buffer.from(String(value), "base64url").toString("utf8");
|
|
9775
10028
|
}
|
|
9776
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
|
+
|
|
9777
10040
|
function signSessionPayload(payload, secret) {
|
|
9778
10041
|
const encoded = base64UrlEncode(JSON.stringify(payload));
|
|
9779
10042
|
const signature = crypto.createHmac("sha256", secret).update(encoded).digest("base64url");
|
|
@@ -10100,6 +10363,35 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
|
|
|
10100
10363
|
};
|
|
10101
10364
|
}
|
|
10102
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
|
+
|
|
10103
10395
|
// Shared between `/api/session` and `/api/bootstrap` so both return an
|
|
10104
10396
|
// identical session shape.
|
|
10105
10397
|
function buildSessionPayload({ config, state, session }) {
|
|
@@ -10110,6 +10402,7 @@ function buildSessionPayload({ config, state, session }) {
|
|
|
10110
10402
|
webPushEnabled: config.webPushEnabled,
|
|
10111
10403
|
httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
|
|
10112
10404
|
appVersion: appPackageVersion,
|
|
10405
|
+
webAppBuildId: WEB_APP_BUILD_ID,
|
|
10113
10406
|
deviceId: session.deviceId || null,
|
|
10114
10407
|
temporaryPairing: session.temporaryPairing === true,
|
|
10115
10408
|
...buildSessionLocalePayload(config, state, session.deviceId),
|
|
@@ -10143,6 +10436,38 @@ function readSession(req, config, state) {
|
|
|
10143
10436
|
};
|
|
10144
10437
|
}
|
|
10145
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
|
+
|
|
10146
10471
|
const token = parseCookies(req)[sessionCookieName];
|
|
10147
10472
|
const payload = token ? verifySessionToken(token, config.sessionSecret) : null;
|
|
10148
10473
|
if (!payload) {
|
|
@@ -10440,8 +10765,15 @@ function validatePairingPayload(payload, config, state) {
|
|
|
10440
10765
|
|
|
10441
10766
|
const code = cleanText(payload?.code ?? "").toUpperCase();
|
|
10442
10767
|
const token = cleanText(payload?.token ?? "");
|
|
10443
|
-
|
|
10444
|
-
|
|
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);
|
|
10445
10777
|
if (matchesToken) {
|
|
10446
10778
|
return { ok: true, credential: `token:${token}` };
|
|
10447
10779
|
}
|
|
@@ -10601,6 +10933,16 @@ function requestOrigin(req) {
|
|
|
10601
10933
|
}
|
|
10602
10934
|
|
|
10603
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
|
+
}
|
|
10604
10946
|
const origin = requestOrigin(req);
|
|
10605
10947
|
if (!origin) {
|
|
10606
10948
|
if (isLoopbackRequest(req)) {
|
|
@@ -11248,7 +11590,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
|
|
|
11248
11590
|
threadLabel: entry.threadLabel,
|
|
11249
11591
|
summary: entry.summary,
|
|
11250
11592
|
fileEventType: normalizeTimelineFileEventType(entry.fileEventType ?? ""),
|
|
11251
|
-
imageUrls: buildTimelineEntryImageUrls(entry),
|
|
11593
|
+
imageUrls: buildTimelineEntryImageUrls(entry, config),
|
|
11252
11594
|
fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
|
|
11253
11595
|
diffAvailable: Boolean(entry.diffAvailable),
|
|
11254
11596
|
diffAddedLines: Math.max(0, Number(entry.diffAddedLines) || 0),
|
|
@@ -11294,6 +11636,7 @@ function buildPendingApprovalDetail(runtime, state, approval, locale) {
|
|
|
11294
11636
|
: approvalKind === "hazbase_wallet_payment"
|
|
11295
11637
|
? [
|
|
11296
11638
|
{ label: t(locale, "server.action.payWithWallet"), tone: "primary", url: `/api/payments/x402/hazbase-wallet/${encodeURIComponent(approval.token)}/pay`, body: { hazbaseReauth: true } },
|
|
11639
|
+
{ label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
|
|
11297
11640
|
]
|
|
11298
11641
|
: [
|
|
11299
11642
|
{ label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
|
|
@@ -11756,7 +12099,35 @@ function buildHistoryDetail(item, locale, runtime = null) {
|
|
|
11756
12099
|
};
|
|
11757
12100
|
}
|
|
11758
12101
|
|
|
11759
|
-
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) {
|
|
11760
12131
|
const imagePaths = normalizeTimelineImagePaths(entry?.imagePaths ?? []);
|
|
11761
12132
|
if (imagePaths.length === 0) {
|
|
11762
12133
|
return [];
|
|
@@ -11765,10 +12136,14 @@ function buildTimelineEntryImageUrls(entry) {
|
|
|
11765
12136
|
if (!token) {
|
|
11766
12137
|
return [];
|
|
11767
12138
|
}
|
|
11768
|
-
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
|
+
});
|
|
11769
12144
|
}
|
|
11770
12145
|
|
|
11771
|
-
function buildTimelineMessageDetail(entry, locale, runtime = null) {
|
|
12146
|
+
function buildTimelineMessageDetail(entry, locale, runtime = null, config = null) {
|
|
11772
12147
|
return {
|
|
11773
12148
|
kind: entry.kind,
|
|
11774
12149
|
token: entry.token,
|
|
@@ -11777,7 +12152,7 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
|
|
|
11777
12152
|
threadLabel: entry.threadLabel || "",
|
|
11778
12153
|
createdAtMs: Number(entry.createdAtMs) || 0,
|
|
11779
12154
|
messageHtml: renderMessageHtml(entry.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
|
|
11780
|
-
imageUrls: buildTimelineEntryImageUrls(entry),
|
|
12155
|
+
imageUrls: buildTimelineEntryImageUrls(entry, config),
|
|
11781
12156
|
fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
|
|
11782
12157
|
previousContext: buildInterruptedTimelineContext(runtime, entry, locale),
|
|
11783
12158
|
interruptNotice: interruptedDetailNotice(entry.messageText, locale),
|
|
@@ -12269,6 +12644,18 @@ async function handleCompletionReply({
|
|
|
12269
12644
|
);
|
|
12270
12645
|
let lastError = null;
|
|
12271
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
|
+
};
|
|
12272
12659
|
|
|
12273
12660
|
for (const candidate of turnCandidates) {
|
|
12274
12661
|
try {
|
|
@@ -12291,18 +12678,19 @@ async function handleCompletionReply({
|
|
|
12291
12678
|
console.log(
|
|
12292
12679
|
`[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
|
|
12293
12680
|
);
|
|
12294
|
-
|
|
12295
|
-
const aliases = isPlainObject(state.timelineImagePathAliases)
|
|
12296
|
-
? state.timelineImagePathAliases
|
|
12297
|
-
: (state.timelineImagePathAliases = {});
|
|
12298
|
-
for (const [sourcePath, persistentPath] of timelineImageAliases) {
|
|
12299
|
-
aliases[sourcePath] = persistentPath;
|
|
12300
|
-
}
|
|
12301
|
-
await saveState(config.stateFile, state);
|
|
12302
|
-
}
|
|
12303
|
-
scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
|
|
12681
|
+
await finalizeReplyAccepted();
|
|
12304
12682
|
return;
|
|
12305
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
|
+
}
|
|
12306
12694
|
lastError = error;
|
|
12307
12695
|
console.log(
|
|
12308
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 })}`
|
|
@@ -13082,7 +13470,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
13082
13470
|
if (timelineMessageKinds.has(kind)) {
|
|
13083
13471
|
const entry = timelineEntryByToken(runtime, token, kind);
|
|
13084
13472
|
if (!entry) return null;
|
|
13085
|
-
const detail = buildTimelineMessageDetail(entry, locale, runtime);
|
|
13473
|
+
const detail = buildTimelineMessageDetail(entry, locale, runtime, config);
|
|
13086
13474
|
// Add reply support for Codex assistant_final entries only (replaces completion reply).
|
|
13087
13475
|
// Check directly against timeline entries (not history items) to avoid
|
|
13088
13476
|
// maxHistoryItems eviction issues.
|
|
@@ -13283,8 +13671,11 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
13283
13671
|
return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
|
|
13284
13672
|
}
|
|
13285
13673
|
|
|
13286
|
-
function resolveTimelineEntryImagePath(runtime, token, index) {
|
|
13287
|
-
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;
|
|
13288
13679
|
if (!entry) {
|
|
13289
13680
|
return "";
|
|
13290
13681
|
}
|
|
@@ -13480,11 +13871,125 @@ function buildWebAppHtml({ pairToken }) {
|
|
|
13480
13871
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
|
13481
13872
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
|
|
13482
13873
|
<link rel="stylesheet" href="/app.css">
|
|
13874
|
+
<style>
|
|
13875
|
+
.boot-splash {
|
|
13876
|
+
position: fixed;
|
|
13877
|
+
inset: 0;
|
|
13878
|
+
z-index: 9999;
|
|
13879
|
+
display: grid;
|
|
13880
|
+
place-items: center;
|
|
13881
|
+
padding: max(1.2rem, env(safe-area-inset-top)) 1rem max(1.2rem, env(safe-area-inset-bottom));
|
|
13882
|
+
color: #f5fbff;
|
|
13883
|
+
background:
|
|
13884
|
+
radial-gradient(circle at 50% 18%, rgba(47, 143, 103, 0.22), transparent 30%),
|
|
13885
|
+
radial-gradient(circle at 78% 78%, rgba(79, 131, 216, 0.14), transparent 28%),
|
|
13886
|
+
linear-gradient(180deg, #081015 0%, #091015 100%);
|
|
13887
|
+
transition: opacity 220ms ease, visibility 220ms ease;
|
|
13888
|
+
}
|
|
13889
|
+
.boot-splash__card {
|
|
13890
|
+
width: min(20rem, 82vw);
|
|
13891
|
+
display: grid;
|
|
13892
|
+
justify-items: center;
|
|
13893
|
+
gap: 0.9rem;
|
|
13894
|
+
text-align: center;
|
|
13895
|
+
}
|
|
13896
|
+
.boot-splash__logo {
|
|
13897
|
+
width: clamp(5.4rem, 28vw, 7rem);
|
|
13898
|
+
height: clamp(5.4rem, 28vw, 7rem);
|
|
13899
|
+
border-radius: 28%;
|
|
13900
|
+
background:
|
|
13901
|
+
radial-gradient(circle at 76% 24%, rgba(125, 211, 252, 0.22), transparent 30%),
|
|
13902
|
+
linear-gradient(180deg, rgba(23, 52, 72, 0.96), rgba(9, 17, 23, 0.96));
|
|
13903
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.32);
|
|
13904
|
+
}
|
|
13905
|
+
.boot-splash__title {
|
|
13906
|
+
margin: 0.25rem 0 0;
|
|
13907
|
+
font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
13908
|
+
font-size: clamp(1.65rem, 8vw, 2.4rem);
|
|
13909
|
+
line-height: 1;
|
|
13910
|
+
letter-spacing: -0.04em;
|
|
13911
|
+
}
|
|
13912
|
+
.boot-splash__status {
|
|
13913
|
+
margin: 0;
|
|
13914
|
+
color: rgba(205, 220, 231, 0.72);
|
|
13915
|
+
font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
13916
|
+
font-size: 0.9rem;
|
|
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);
|
|
13935
|
+
}
|
|
13936
|
+
.boot-splash__dots {
|
|
13937
|
+
display: inline-grid;
|
|
13938
|
+
grid-auto-flow: column;
|
|
13939
|
+
gap: 0.34rem;
|
|
13940
|
+
margin-top: 0.15rem;
|
|
13941
|
+
}
|
|
13942
|
+
.boot-splash__dots span {
|
|
13943
|
+
width: 0.42rem;
|
|
13944
|
+
height: 0.42rem;
|
|
13945
|
+
border-radius: 999px;
|
|
13946
|
+
background: rgba(142, 215, 255, 0.88);
|
|
13947
|
+
animation: viveworker-boot-dot 980ms ease-in-out infinite;
|
|
13948
|
+
}
|
|
13949
|
+
.boot-splash__dots span:nth-child(2) { animation-delay: 140ms; }
|
|
13950
|
+
.boot-splash__dots span:nth-child(3) { animation-delay: 280ms; }
|
|
13951
|
+
.viveworker-ready .boot-splash {
|
|
13952
|
+
opacity: 0;
|
|
13953
|
+
visibility: hidden;
|
|
13954
|
+
}
|
|
13955
|
+
@keyframes viveworker-boot-dot {
|
|
13956
|
+
0%, 80%, 100% { transform: translateY(0); opacity: 0.42; }
|
|
13957
|
+
40% { transform: translateY(-0.28rem); opacity: 1; }
|
|
13958
|
+
}
|
|
13959
|
+
@media (prefers-reduced-motion: reduce) {
|
|
13960
|
+
.boot-splash,
|
|
13961
|
+
.boot-splash__status,
|
|
13962
|
+
.boot-splash__hint,
|
|
13963
|
+
.boot-splash__dots span {
|
|
13964
|
+
transition: none;
|
|
13965
|
+
animation: none;
|
|
13966
|
+
}
|
|
13967
|
+
}
|
|
13968
|
+
</style>
|
|
13483
13969
|
<title>viveworker</title>
|
|
13484
13970
|
</head>
|
|
13485
13971
|
<body>
|
|
13972
|
+
<div id="boot-splash" class="boot-splash" role="status" aria-live="polite" aria-label="viveworker is starting">
|
|
13973
|
+
<div class="boot-splash__card">
|
|
13974
|
+
<img class="boot-splash__logo" src="/icons/viveworker-v-pulse.svg" alt="" width="112" height="112" decoding="async">
|
|
13975
|
+
<h1 class="boot-splash__title">viveworker</h1>
|
|
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>
|
|
13978
|
+
<span class="boot-splash__dots" aria-hidden="true"><span></span><span></span><span></span></span>
|
|
13979
|
+
</div>
|
|
13980
|
+
</div>
|
|
13486
13981
|
<div id="app"></div>
|
|
13487
|
-
<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>
|
|
13488
13993
|
</body>
|
|
13489
13994
|
</html>`;
|
|
13490
13995
|
}
|
|
@@ -13493,10 +13998,132 @@ function resolvePagePairingToken({ req, config, state, requestedToken }) {
|
|
|
13493
13998
|
return resolveManifestPairingToken({ config, state, requestedToken });
|
|
13494
13999
|
}
|
|
13495
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
|
+
|
|
13496
14122
|
function createNativeApprovalServer({ config, runtime, state }) {
|
|
13497
14123
|
const requestHandler = async (req, res) => {
|
|
13498
14124
|
try {
|
|
13499
14125
|
const url = new URL(req.url, config.nativeApprovalPublicBaseUrl);
|
|
14126
|
+
refreshPairingConfigFromEnvFile(config);
|
|
13500
14127
|
|
|
13501
14128
|
if (url.pathname === "/health") {
|
|
13502
14129
|
return writeJson(res, 200, { ok: true });
|
|
@@ -13549,7 +14176,16 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
13549
14176
|
url.pathname === "/i18n.js" ||
|
|
13550
14177
|
url.pathname === "/hazbase-passkey.js" ||
|
|
13551
14178
|
url.pathname === "/sw.js" ||
|
|
13552
|
-
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"
|
|
13553
14189
|
) {
|
|
13554
14190
|
const served = await serveWebAsset(res, url.pathname, req);
|
|
13555
14191
|
if (served) {
|
|
@@ -13725,9 +14361,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
13725
14361
|
}
|
|
13726
14362
|
const sessionPayload = buildSessionPayload({ config, state, session });
|
|
13727
14363
|
if (!session.authenticated) {
|
|
13728
|
-
return writeJson(res, 200, { session: sessionPayload });
|
|
14364
|
+
return writeJson(res, 200, { appBuildId: WEB_APP_BUILD_ID, session: sessionPayload });
|
|
13729
14365
|
}
|
|
13730
14366
|
return writeJson(res, 200, {
|
|
14367
|
+
appBuildId: WEB_APP_BUILD_ID,
|
|
13731
14368
|
session: sessionPayload,
|
|
13732
14369
|
inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
|
|
13733
14370
|
timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
|
|
@@ -14454,17 +15091,523 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
14454
15091
|
return writeJson(res, 200, { ok: true, acceptPublicTasks: accept });
|
|
14455
15092
|
}
|
|
14456
15093
|
|
|
14457
|
-
// ───
|
|
15094
|
+
// ─── Remote pairing (off-LAN PWA relay) ──────────────────────────
|
|
14458
15095
|
|
|
14459
|
-
|
|
14460
|
-
|
|
14461
|
-
|
|
14462
|
-
|
|
14463
|
-
|
|
14464
|
-
|
|
14465
|
-
|
|
14466
|
-
|
|
14467
|
-
|
|
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
|
+
|
|
15600
|
+
// ─── Thread sharing ──────────────────────────────────────────────
|
|
15601
|
+
|
|
15602
|
+
function buildThreadShareContent(shareType, body) {
|
|
15603
|
+
if (shareType === "plan_review") {
|
|
15604
|
+
const sections = [];
|
|
15605
|
+
if (body.context) sections.push(`## Context\n${body.context}`);
|
|
15606
|
+
if (body.plan) sections.push(`## Plan\n${body.plan}`);
|
|
15607
|
+
if (Array.isArray(body.files) && body.files.length) {
|
|
15608
|
+
sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
|
|
15609
|
+
}
|
|
15610
|
+
sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
|
|
14468
15611
|
return sections.join("\n\n");
|
|
14469
15612
|
}
|
|
14470
15613
|
if (shareType === "handoff") {
|
|
@@ -15028,9 +16171,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
15028
16171
|
const result = await completeHazbaseWalletPaymentApproval({ config, runtime, state, approval });
|
|
15029
16172
|
return writeJson(res, 200, result);
|
|
15030
16173
|
} catch (error) {
|
|
15031
|
-
|
|
16174
|
+
const failure = await finalizeHazbaseWalletPaymentFailure({ config, runtime, state, approval, error });
|
|
15032
16175
|
console.error(`[hazbase-wallet-payment-error] ${approval.requestKey} | ${error?.stack || error?.message || error}`);
|
|
15033
|
-
return writeJson(res, Number(error?.statusCode) || 500, {
|
|
16176
|
+
return writeJson(res, Number(error?.statusCode) || 500, {
|
|
16177
|
+
error: failure.error,
|
|
16178
|
+
approvalFinalized: true,
|
|
16179
|
+
retryable: true,
|
|
16180
|
+
});
|
|
15034
16181
|
}
|
|
15035
16182
|
}
|
|
15036
16183
|
|
|
@@ -15799,18 +16946,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
15799
16946
|
|
|
15800
16947
|
const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
|
|
15801
16948
|
if (apiTimelineImageMatch && req.method === "GET") {
|
|
15802
|
-
const session = requireApiSession(req, res, config, state);
|
|
15803
|
-
if (!session) {
|
|
15804
|
-
return;
|
|
15805
|
-
}
|
|
15806
16949
|
const token = decodeURIComponent(apiTimelineImageMatch[1]);
|
|
15807
16950
|
const index = Number(apiTimelineImageMatch[2]) || 0;
|
|
15808
|
-
const filePath = resolveTimelineEntryImagePath(runtime, token, index);
|
|
16951
|
+
const filePath = resolveTimelineEntryImagePath(runtime, state, token, index);
|
|
15809
16952
|
if (!filePath) {
|
|
15810
16953
|
res.statusCode = 404;
|
|
15811
16954
|
res.end("not-found");
|
|
15812
16955
|
return;
|
|
15813
16956
|
}
|
|
16957
|
+
if (!isValidTimelineEntryImageSignature(config, url, token, index, filePath)) {
|
|
16958
|
+
const session = requireApiSession(req, res, config, state);
|
|
16959
|
+
if (!session) {
|
|
16960
|
+
return;
|
|
16961
|
+
}
|
|
16962
|
+
}
|
|
15814
16963
|
try {
|
|
15815
16964
|
const body = await fs.readFile(filePath);
|
|
15816
16965
|
res.statusCode = 200;
|
|
@@ -15922,7 +17071,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
15922
17071
|
if (approval.resolved || approval.resolving) {
|
|
15923
17072
|
return writeJson(res, 409, { error: "approval-already-handled" });
|
|
15924
17073
|
}
|
|
15925
|
-
if (approval.kind === "hazbase_wallet_payment") {
|
|
17074
|
+
if (approval.kind === "hazbase_wallet_payment" && decision !== "decline") {
|
|
15926
17075
|
console.warn(`[hazbase-wallet-payment-generic-decision-blocked] ${approval.requestKey} | ${decision}`);
|
|
15927
17076
|
return writeJson(res, 409, { error: "hazbase-wallet-payment-requires-wallet-pay-route" });
|
|
15928
17077
|
}
|
|
@@ -15936,6 +17085,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
15936
17085
|
return writeJson(res, 200, { ok: true, decision });
|
|
15937
17086
|
} catch (error) {
|
|
15938
17087
|
approval.resolving = false;
|
|
17088
|
+
if (res.headersSent || res.writableEnded) {
|
|
17089
|
+
console.error(`[approval-decision-post-write-error] ${approval.requestKey} | ${error.message}`);
|
|
17090
|
+
return;
|
|
17091
|
+
}
|
|
15939
17092
|
return writeJson(res, 500, { error: error.message });
|
|
15940
17093
|
}
|
|
15941
17094
|
}
|
|
@@ -16520,7 +17673,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
16520
17673
|
if (approval.resolved || approval.resolving) {
|
|
16521
17674
|
return writeApprovalHandled(res, req, approval.title, 409, config.defaultLocale);
|
|
16522
17675
|
}
|
|
16523
|
-
if (approval.kind === "hazbase_wallet_payment") {
|
|
17676
|
+
if (approval.kind === "hazbase_wallet_payment" && decision !== "decline") {
|
|
16524
17677
|
console.warn(`[hazbase-wallet-payment-native-decision-blocked] ${approval.requestKey} | ${decision}`);
|
|
16525
17678
|
if (requestWantsHtml(req)) {
|
|
16526
17679
|
return writeHtml(
|
|
@@ -16559,6 +17712,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
16559
17712
|
});
|
|
16560
17713
|
} catch (error) {
|
|
16561
17714
|
approval.resolving = false;
|
|
17715
|
+
if (res.headersSent || res.writableEnded) {
|
|
17716
|
+
console.error(`[native-approval-decision-post-write-error] ${approval.requestKey} | ${error.message}`);
|
|
17717
|
+
return;
|
|
17718
|
+
}
|
|
16562
17719
|
if (requestWantsHtml(req)) {
|
|
16563
17720
|
return writeHtml(
|
|
16564
17721
|
res,
|
|
@@ -16573,6 +17730,9 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
16573
17730
|
return writeJson(res, 500, { error: error.message });
|
|
16574
17731
|
}
|
|
16575
17732
|
} catch (error) {
|
|
17733
|
+
if (res.headersSent || res.writableEnded) {
|
|
17734
|
+
return;
|
|
17735
|
+
}
|
|
16576
17736
|
if (requestWantsHtml(req)) {
|
|
16577
17737
|
return writeHtml(
|
|
16578
17738
|
res,
|
|
@@ -16588,14 +17748,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
16588
17748
|
}
|
|
16589
17749
|
};
|
|
16590
17750
|
|
|
17751
|
+
let server;
|
|
16591
17752
|
if (config.webPushEnabled) {
|
|
16592
|
-
|
|
17753
|
+
server = createHttpsServer({
|
|
16593
17754
|
cert: readFileSync(config.tlsCertFile, "utf8"),
|
|
16594
17755
|
key: readFileSync(config.tlsKeyFile, "utf8"),
|
|
16595
17756
|
}, requestHandler);
|
|
17757
|
+
} else {
|
|
17758
|
+
server = createHttpServer(requestHandler);
|
|
16596
17759
|
}
|
|
16597
|
-
|
|
16598
|
-
|
|
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;
|
|
16599
17767
|
}
|
|
16600
17768
|
|
|
16601
17769
|
function requestWantsHtml(req) {
|
|
@@ -17899,6 +19067,7 @@ function buildConfig(cli) {
|
|
|
17899
19067
|
const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
|
17900
19068
|
const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
|
|
17901
19069
|
return {
|
|
19070
|
+
envFile: resolveEnvFile(cli.envFile),
|
|
17902
19071
|
dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
|
|
17903
19072
|
once: cli.once,
|
|
17904
19073
|
codexHome,
|
|
@@ -17913,6 +19082,12 @@ function buildConfig(cli) {
|
|
|
17913
19082
|
a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
|
|
17914
19083
|
a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
|
|
17915
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(),
|
|
17916
19091
|
// share.viveworker.com — HTML hosting backed by the same A2A credentials.
|
|
17917
19092
|
// Override for staging / self-hosted deployments via VIVEWORKER_SHARE_URL.
|
|
17918
19093
|
a2aShareUrl: stripTrailingSlash(
|
|
@@ -17969,6 +19144,12 @@ function buildConfig(cli) {
|
|
|
17969
19144
|
maxCodeEvents: numberEnv("MAX_CODE_EVENTS", 1000),
|
|
17970
19145
|
maxTimelineThreads: numberEnv("MAX_TIMELINE_THREADS", 20),
|
|
17971
19146
|
maxReadBytes: numberEnv("MAX_READ_BYTES", 2 * 1024 * 1024),
|
|
19147
|
+
// Cap how far back the bridge looks for Claude transcripts. Default 7 days
|
|
19148
|
+
// covers a long pause without dragging the long tail of dead sessions
|
|
19149
|
+
// (which can easily reach thousands of files) into the per-iteration
|
|
19150
|
+
// stat loop. Set to 0 to disable filtering and scan everything (the
|
|
19151
|
+
// legacy behavior).
|
|
19152
|
+
claudeTranscriptMaxAgeMs: numberEnv("CLAUDE_TRANSCRIPT_MAX_AGE_DAYS", 7) * 24 * 60 * 60 * 1000,
|
|
17972
19153
|
maxMessageChars: numberEnv("MAX_MESSAGE_CHARS", 320),
|
|
17973
19154
|
maxCommandChars: numberEnv("MAX_COMMAND_CHARS", 220),
|
|
17974
19155
|
maxJustificationChars: numberEnv("MAX_JUSTIFICATION_CHARS", 220),
|
|
@@ -18012,18 +19193,31 @@ function buildConfig(cli) {
|
|
|
18012
19193
|
}
|
|
18013
19194
|
|
|
18014
19195
|
|
|
19196
|
+
function inferHazbaseDeviceBindingIdFromAccounts(accounts) {
|
|
19197
|
+
const list = Array.isArray(accounts) ? accounts : [];
|
|
19198
|
+
for (const entry of list) {
|
|
19199
|
+
const deviceBindingId = cleanText(
|
|
19200
|
+
entry?.primaryDeviceBindingId || entry?.deviceBindingId || entry?.ownerDeviceBindingId || ""
|
|
19201
|
+
);
|
|
19202
|
+
if (deviceBindingId) return deviceBindingId;
|
|
19203
|
+
}
|
|
19204
|
+
return "";
|
|
19205
|
+
}
|
|
19206
|
+
|
|
18015
19207
|
function normalizeHazbaseState(raw) {
|
|
18016
19208
|
const value = raw && typeof raw === "object" ? raw : {};
|
|
19209
|
+
const accounts = Array.isArray(value.accounts) ? value.accounts : [];
|
|
19210
|
+
const deviceBindingId = cleanText(value.deviceBindingId ?? "") || inferHazbaseDeviceBindingIdFromAccounts(accounts);
|
|
18017
19211
|
return {
|
|
18018
19212
|
email: cleanText(value.email ?? ""),
|
|
18019
19213
|
accessToken: cleanText(value.accessToken ?? ""),
|
|
18020
19214
|
sessionId: cleanText(value.sessionId ?? ""),
|
|
18021
19215
|
userId: cleanText(value.userId ?? ""),
|
|
18022
|
-
deviceBindingId
|
|
19216
|
+
deviceBindingId,
|
|
18023
19217
|
credentialId: cleanText(value.credentialId ?? ""),
|
|
18024
19218
|
highTrustToken: cleanText(value.highTrustToken ?? ""),
|
|
18025
19219
|
highTrustExpiresAt: cleanText(value.highTrustExpiresAt ?? ""),
|
|
18026
|
-
accounts
|
|
19220
|
+
accounts,
|
|
18027
19221
|
sessionInvalid: value.sessionInvalid === true,
|
|
18028
19222
|
sessionInvalidReason: cleanText(value.sessionInvalidReason ?? ""),
|
|
18029
19223
|
sessionInvalidAt: cleanText(value.sessionInvalidAt ?? ""),
|
|
@@ -18032,19 +19226,30 @@ function normalizeHazbaseState(raw) {
|
|
|
18032
19226
|
|
|
18033
19227
|
function isHazbaseInvalidAppSessionError(error) {
|
|
18034
19228
|
const details = error?.details && typeof error.details === "object" ? error.details : {};
|
|
18035
|
-
const
|
|
18036
|
-
const text = [
|
|
19229
|
+
const rawText = [
|
|
18037
19230
|
error?.code,
|
|
18038
19231
|
error?.message,
|
|
18039
19232
|
details.errorCode,
|
|
18040
19233
|
details.code,
|
|
18041
19234
|
details.error,
|
|
18042
19235
|
details.message,
|
|
19236
|
+
typeof error?.details === "string" ? error.details : "",
|
|
19237
|
+
]
|
|
19238
|
+
.map((entry) => cleanText(entry || ""))
|
|
19239
|
+
.filter(Boolean)
|
|
19240
|
+
.join(" ");
|
|
19241
|
+
const statusCode = Number(error?.statusCode || error?.status || details.statusCode || 0) ||
|
|
19242
|
+
(/statusCode["']?\s*[:=]\s*401/u.test(rawText) || /\b401\b/u.test(rawText) ? 401 : 0);
|
|
19243
|
+
const text = [
|
|
19244
|
+
rawText,
|
|
18043
19245
|
]
|
|
18044
19246
|
.map((entry) => cleanText(entry || "").toLowerCase())
|
|
18045
19247
|
.filter(Boolean)
|
|
18046
19248
|
.join(" ");
|
|
18047
|
-
return statusCode === 401 &&
|
|
19249
|
+
return statusCode === 401 && (
|
|
19250
|
+
text.includes("invalid app session") ||
|
|
19251
|
+
text.includes("invalid_app_session")
|
|
19252
|
+
);
|
|
18048
19253
|
}
|
|
18049
19254
|
|
|
18050
19255
|
function markHazbaseSessionInvalid(state, reason = "invalid_app_session") {
|
|
@@ -18274,6 +19479,51 @@ function resolveHazbaseAccountForChain(accounts, chainId) {
|
|
|
18274
19479
|
}
|
|
18275
19480
|
|
|
18276
19481
|
|
|
19482
|
+
async function finalizeHazbaseWalletPaymentFailure({ config, runtime, state, approval, error }) {
|
|
19483
|
+
const errorCode = cleanText(error?.code || error?.message || "hazbase-wallet-payment-failed") || "hazbase-wallet-payment-failed";
|
|
19484
|
+
if (approval.resolved) {
|
|
19485
|
+
return { paid: false, decision: "failed", error: errorCode };
|
|
19486
|
+
}
|
|
19487
|
+
|
|
19488
|
+
const payload = {
|
|
19489
|
+
paid: false,
|
|
19490
|
+
decision: "failed",
|
|
19491
|
+
error: errorCode,
|
|
19492
|
+
paymentRequestId: approval.paymentRequestId || approval.requestId || "",
|
|
19493
|
+
};
|
|
19494
|
+
|
|
19495
|
+
approval.resolved = true;
|
|
19496
|
+
approval.resolving = false;
|
|
19497
|
+
runtime.nativeApprovalsByToken.delete(approval.token);
|
|
19498
|
+
runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
|
|
19499
|
+
approval.resolveClaudeWaiter?.(payload);
|
|
19500
|
+
|
|
19501
|
+
const statusMessage = t(config.defaultLocale, "server.message.paymentFailed", { reason: errorCode });
|
|
19502
|
+
const stateChanged = recordActionHistoryItem({
|
|
19503
|
+
config,
|
|
19504
|
+
runtime,
|
|
19505
|
+
state,
|
|
19506
|
+
kind: "approval",
|
|
19507
|
+
stableId: `approval:${approval.requestKey}:${Date.now()}`,
|
|
19508
|
+
token: approval.token,
|
|
19509
|
+
title: approval.title,
|
|
19510
|
+
threadLabel: approval.threadLabel || "",
|
|
19511
|
+
messageText: `${statusMessage}\n\n${approval.messageText}`,
|
|
19512
|
+
summary: statusMessage,
|
|
19513
|
+
fileRefs: [],
|
|
19514
|
+
diffText: "",
|
|
19515
|
+
diffSource: "",
|
|
19516
|
+
diffAvailable: false,
|
|
19517
|
+
diffAddedLines: 0,
|
|
19518
|
+
diffRemovedLines: 0,
|
|
19519
|
+
outcome: "failed",
|
|
19520
|
+
provider: approval.provider || "viveworker",
|
|
19521
|
+
});
|
|
19522
|
+
if (stateChanged) await saveState(config.stateFile, state);
|
|
19523
|
+
console.log(`[hazbase-wallet-payment-failed] ${approval.requestKey} | ${errorCode}`);
|
|
19524
|
+
return payload;
|
|
19525
|
+
}
|
|
19526
|
+
|
|
18277
19527
|
async function completeHazbaseWalletPaymentApproval({ config, runtime, state, approval }) {
|
|
18278
19528
|
const hazbase = normalizeHazbaseState(state.hazbase);
|
|
18279
19529
|
if (!hazbase.accessToken) throw codedError("hazbase-auth-required", 401);
|
|
@@ -18476,32 +19726,70 @@ function resolveEnvFile(explicitPath) {
|
|
|
18476
19726
|
return path.join(workspaceRoot, "viveworker.env");
|
|
18477
19727
|
}
|
|
18478
19728
|
|
|
18479
|
-
function
|
|
18480
|
-
|
|
18481
|
-
|
|
18482
|
-
|
|
18483
|
-
|
|
18484
|
-
|
|
18485
|
-
|
|
18486
|
-
}
|
|
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
|
+
}
|
|
18487
19736
|
|
|
18488
|
-
|
|
18489
|
-
|
|
18490
|
-
|
|
18491
|
-
|
|
19737
|
+
const separator = line.indexOf("=");
|
|
19738
|
+
if (separator === -1) {
|
|
19739
|
+
continue;
|
|
19740
|
+
}
|
|
18492
19741
|
|
|
18493
|
-
|
|
18494
|
-
|
|
18495
|
-
|
|
18496
|
-
|
|
18497
|
-
}
|
|
18498
|
-
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);
|
|
18499
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")));
|
|
18500
19755
|
} catch {
|
|
18501
19756
|
// Optional env file.
|
|
18502
19757
|
}
|
|
18503
19758
|
}
|
|
18504
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
|
+
|
|
18505
19793
|
async function maybeRotateStartupPairingEnv(envFile) {
|
|
18506
19794
|
if (!truthy(process.env.AUTH_REQUIRED)) {
|
|
18507
19795
|
return;
|
|
@@ -19183,8 +20471,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
|
|
|
19183
20471
|
);
|
|
19184
20472
|
|
|
19185
20473
|
if (
|
|
19186
|
-
|
|
19187
|
-
|
|
20474
|
+
timelineProjectionChanged(nextHistoryItems, runtime.recentHistoryItems, [
|
|
20475
|
+
"stableId",
|
|
20476
|
+
"title",
|
|
20477
|
+
"threadLabel",
|
|
20478
|
+
])
|
|
19188
20479
|
) {
|
|
19189
20480
|
runtime.recentHistoryItems = nextHistoryItems;
|
|
19190
20481
|
state.recentHistoryItems = nextHistoryItems;
|
|
@@ -19225,8 +20516,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
|
|
|
19225
20516
|
);
|
|
19226
20517
|
|
|
19227
20518
|
if (
|
|
19228
|
-
|
|
19229
|
-
|
|
20519
|
+
timelineProjectionChanged(nextTimelineEntries, runtime.recentTimelineEntries, [
|
|
20520
|
+
"stableId",
|
|
20521
|
+
"title",
|
|
20522
|
+
"threadLabel",
|
|
20523
|
+
])
|
|
19230
20524
|
) {
|
|
19231
20525
|
runtime.recentTimelineEntries = nextTimelineEntries;
|
|
19232
20526
|
state.recentTimelineEntries = nextTimelineEntries;
|
|
@@ -19261,8 +20555,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
|
|
|
19261
20555
|
);
|
|
19262
20556
|
|
|
19263
20557
|
if (
|
|
19264
|
-
|
|
19265
|
-
|
|
20558
|
+
timelineProjectionChanged(nextCodeEvents, runtime.recentCodeEvents, [
|
|
20559
|
+
"stableId",
|
|
20560
|
+
"title",
|
|
20561
|
+
"threadLabel",
|
|
20562
|
+
])
|
|
19266
20563
|
) {
|
|
19267
20564
|
runtime.recentCodeEvents = nextCodeEvents;
|
|
19268
20565
|
state.recentCodeEvents = nextCodeEvents;
|
|
@@ -19348,21 +20645,68 @@ function isInlineImagePlaceholderText(value) {
|
|
|
19348
20645
|
return cleanText(stripInlineImagePlaceholderMarkup(value)) === "" && /<\/?image\b/iu.test(String(value || ""));
|
|
19349
20646
|
}
|
|
19350
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
|
+
|
|
19351
20661
|
function normalizeTimelineMessageText(value, locale = DEFAULT_LOCALE) {
|
|
19352
|
-
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);
|
|
19353
20684
|
}
|
|
19354
20685
|
|
|
19355
20686
|
function normalizeTimelineImagePaths(value) {
|
|
19356
20687
|
if (!Array.isArray(value)) {
|
|
19357
20688
|
return [];
|
|
19358
20689
|
}
|
|
19359
|
-
|
|
19360
|
-
|
|
19361
|
-
|
|
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;
|
|
19362
20701
|
}
|
|
19363
20702
|
|
|
19364
20703
|
function normalizeNotificationText(value, locale = DEFAULT_LOCALE) {
|
|
19365
|
-
return normalizeLongText(
|
|
20704
|
+
return normalizeLongText(
|
|
20705
|
+
replaceTurnAbortedMarkup(
|
|
20706
|
+
stripCodexUserAttachmentEnvelope(stripNotificationMarkup(value)),
|
|
20707
|
+
locale
|
|
20708
|
+
)
|
|
20709
|
+
)
|
|
19366
20710
|
.replace(/\n{2,}/gu, "\n")
|
|
19367
20711
|
.trim();
|
|
19368
20712
|
}
|
|
@@ -19423,6 +20767,65 @@ function cleanText(value) {
|
|
|
19423
20767
|
return String(value || "").replace(/\s+/gu, " ").trim();
|
|
19424
20768
|
}
|
|
19425
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
|
+
|
|
19426
20829
|
function extractTitleOnlyJsonTitle(value) {
|
|
19427
20830
|
const rawText = String(value ?? "").trim();
|
|
19428
20831
|
if (!rawText) {
|
|
@@ -19697,6 +21100,37 @@ async function main() {
|
|
|
19697
21100
|
process.on("SIGINT", handleSignal);
|
|
19698
21101
|
process.on("SIGTERM", handleSignal);
|
|
19699
21102
|
|
|
21103
|
+
// Belt-and-suspenders: a stray ERR_HTTP_HEADERS_SENT inside an async
|
|
21104
|
+
// request handler used to escape as an UnhandledPromiseRejection and crash
|
|
21105
|
+
// the process under launchd, triggering a restart loop that then re-ran all
|
|
21106
|
+
// the startup backfills (state.json parse, rollout/transcript replay) and
|
|
21107
|
+
// burned CPU + memory for ~30s per cycle. The targeted catch-handler guards
|
|
21108
|
+
// around handleNativeApprovalDecision cover the known sites; this top-level
|
|
21109
|
+
// filter swallows the same class of error from any new site we haven't
|
|
21110
|
+
// audited yet so the process keeps serving instead of dying.
|
|
21111
|
+
const isBenignHttpError = (err) =>
|
|
21112
|
+
err && typeof err === "object" && (
|
|
21113
|
+
err.code === "ERR_HTTP_HEADERS_SENT" ||
|
|
21114
|
+
err.code === "ERR_STREAM_WRITE_AFTER_END" ||
|
|
21115
|
+
err.code === "ERR_STREAM_DESTROYED" ||
|
|
21116
|
+
err.code === "ECONNRESET" ||
|
|
21117
|
+
err.code === "EPIPE"
|
|
21118
|
+
);
|
|
21119
|
+
process.on("uncaughtException", (err) => {
|
|
21120
|
+
if (isBenignHttpError(err)) {
|
|
21121
|
+
console.warn(`[uncaught-http] ${err.code} ${err.message}`);
|
|
21122
|
+
return;
|
|
21123
|
+
}
|
|
21124
|
+
console.error(`[uncaught] ${err?.stack || err}`);
|
|
21125
|
+
});
|
|
21126
|
+
process.on("unhandledRejection", (reason) => {
|
|
21127
|
+
if (isBenignHttpError(reason)) {
|
|
21128
|
+
console.warn(`[unhandled-http] ${reason.code} ${reason.message}`);
|
|
21129
|
+
return;
|
|
21130
|
+
}
|
|
21131
|
+
console.error(`[unhandled] ${reason?.stack || reason}`);
|
|
21132
|
+
});
|
|
21133
|
+
|
|
19700
21134
|
try {
|
|
19701
21135
|
if (config.webPushEnabled) {
|
|
19702
21136
|
webPush.setVapidDetails(
|
|
@@ -19816,6 +21250,38 @@ async function main() {
|
|
|
19816
21250
|
}
|
|
19817
21251
|
}
|
|
19818
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
|
+
|
|
19819
21285
|
let lastA2aEnvCheckAt = 0;
|
|
19820
21286
|
const A2A_ENV_CHECK_INTERVAL_MS = 30_000; // check every 30 seconds
|
|
19821
21287
|
|
|
@@ -19902,6 +21368,13 @@ async function main() {
|
|
|
19902
21368
|
|
|
19903
21369
|
stopRelayPolling();
|
|
19904
21370
|
|
|
21371
|
+
if (runtime.remotePairingHandle) {
|
|
21372
|
+
try {
|
|
21373
|
+
runtime.remotePairingHandle.close();
|
|
21374
|
+
} catch {}
|
|
21375
|
+
runtime.remotePairingHandle = null;
|
|
21376
|
+
}
|
|
21377
|
+
|
|
19905
21378
|
if (runtime.ipcClient) {
|
|
19906
21379
|
runtime.ipcClient.stop();
|
|
19907
21380
|
}
|