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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rpc.mjs — Application-level RPC framing carried inside the Noise channel.
|
|
3
|
+
*
|
|
4
|
+
* The transport (Noise + envelope) gives us an authenticated, encrypted,
|
|
5
|
+
* ordered byte stream between phone and bridge. On top of it we need an
|
|
6
|
+
* HTTP-shaped request/response abstraction so the bridge can keep using
|
|
7
|
+
* its existing `(req, res)` handlers and the phone PWA can keep doing
|
|
8
|
+
* `fetch()` whether it's on LAN or going through the relay.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (UTF-8 JSON, one frame per Noise transport message):
|
|
11
|
+
*
|
|
12
|
+
* // phone → bridge: HTTP-like request
|
|
13
|
+
* { "t":"req", "id":"r1", "method":"GET", "path":"/api/state",
|
|
14
|
+
* "headers":{"cookie":"viveworker_session=..."},
|
|
15
|
+
* "body":"" }
|
|
16
|
+
*
|
|
17
|
+
* // bridge → phone: full response (single frame; chunked variant TBD)
|
|
18
|
+
* { "t":"res", "id":"r1", "status":200,
|
|
19
|
+
* "headers":{"content-type":"application/json"},
|
|
20
|
+
* "body":"{...}" }
|
|
21
|
+
*
|
|
22
|
+
* // phone → bridge: cancel an in-flight request (e.g. user navigated
|
|
23
|
+
* // away during a long-poll). The bridge MAY abort and skip sending res.
|
|
24
|
+
* { "t":"cancel", "id":"r1" }
|
|
25
|
+
*
|
|
26
|
+
* // bridge → phone: server-pushed event, no client request to correlate
|
|
27
|
+
* // against (e.g., "new approval item arrived; refresh inbox").
|
|
28
|
+
* { "t":"evt", "topic":"inbox-changed", "data":{...} }
|
|
29
|
+
*
|
|
30
|
+
* Why JSON (not CBOR / Protobuf):
|
|
31
|
+
* The Noise channel already gave us the cheap transport. Most viveworker
|
|
32
|
+
* API payloads are JSON to begin with, so the only thing we save by
|
|
33
|
+
* going binary is a few characters per request — not worth the parser
|
|
34
|
+
* surface or the loss of ad-hoc debuggability. Bodies are always
|
|
35
|
+
* strings; binary bodies (rare, e.g. /uploads) base64 themselves and
|
|
36
|
+
* set `bodyEncoding: "base64"`.
|
|
37
|
+
*
|
|
38
|
+
* Validation philosophy:
|
|
39
|
+
* We enforce types and a few bounds (max id length, max headers count,
|
|
40
|
+
* max body size) so a misbehaving peer can't DoS the dispatcher. Past
|
|
41
|
+
* those bounds we trust the peer because the Noise handshake already
|
|
42
|
+
* gave us mutual auth — there's no value in being defensive against a
|
|
43
|
+
* peer that's already inside the encrypted channel.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
47
|
+
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Limits — defensive caps. Generous enough to never bite real traffic but
|
|
51
|
+
// tight enough to protect the dispatcher from a runaway peer.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/** Max ID length (UUIDs are 36 chars; 64 leaves room for prefixes). */
|
|
55
|
+
export const MAX_RPC_ID_LEN = 64;
|
|
56
|
+
|
|
57
|
+
/** Max number of header entries we accept. */
|
|
58
|
+
export const MAX_HEADERS = 64;
|
|
59
|
+
|
|
60
|
+
/** Max length of any single header name OR value. */
|
|
61
|
+
export const MAX_HEADER_LEN = 8 * 1024;
|
|
62
|
+
|
|
63
|
+
/** Max method string length (`GET`, `OPTIONS`, etc. — 16 covers everything reasonable). */
|
|
64
|
+
export const MAX_METHOD_LEN = 16;
|
|
65
|
+
|
|
66
|
+
/** Max URL path length. */
|
|
67
|
+
export const MAX_PATH_LEN = 4 * 1024;
|
|
68
|
+
|
|
69
|
+
/** Max body size — 4 MiB. Fits long-poll JSONs, inbox dumps, x402 payloads. */
|
|
70
|
+
export const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
/** Max topic name length (server-pushed events). */
|
|
73
|
+
export const MAX_TOPIC_LEN = 128;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Frame type discriminators. Frozen so application code can compare with
|
|
77
|
+
* `===` and not worry about typos surviving as silently-different strings.
|
|
78
|
+
*/
|
|
79
|
+
export const RPC = Object.freeze({
|
|
80
|
+
REQUEST: "req",
|
|
81
|
+
RESPONSE: "res",
|
|
82
|
+
CANCEL: "cancel",
|
|
83
|
+
EVENT: "evt",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const VALID_TYPES = new Set([RPC.REQUEST, RPC.RESPONSE, RPC.CANCEL, RPC.EVENT]);
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Encoders
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {Object} RpcRequest
|
|
94
|
+
* @property {string} id client-chosen correlation id
|
|
95
|
+
* @property {string} method "GET" | "POST" | …
|
|
96
|
+
* @property {string} path URL path including query, e.g. "/api/x?foo=1"
|
|
97
|
+
* @property {Record<string,string>} [headers]
|
|
98
|
+
* @property {string} [body] UTF-8 string; binary callers must base64 + set bodyEncoding
|
|
99
|
+
* @property {"utf8" | "base64"} [bodyEncoding]
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} RpcResponse
|
|
104
|
+
* @property {string} id
|
|
105
|
+
* @property {number} status HTTP status code 100..599
|
|
106
|
+
* @property {Record<string,string>} [headers]
|
|
107
|
+
* @property {string} [body]
|
|
108
|
+
* @property {"utf8" | "base64"} [bodyEncoding]
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {Object} RpcCancel
|
|
113
|
+
* @property {string} id
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {Object} RpcEvent
|
|
118
|
+
* @property {string} topic
|
|
119
|
+
* @property {unknown} [data]
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/** @param {RpcRequest} req @returns {Uint8Array} */
|
|
123
|
+
export function encodeRequest(req) {
|
|
124
|
+
if (typeof req?.id !== "string") throw new TypeError("request.id required (string)");
|
|
125
|
+
if (typeof req.method !== "string") throw new TypeError("request.method required (string)");
|
|
126
|
+
if (typeof req.path !== "string") throw new TypeError("request.path required (string)");
|
|
127
|
+
assertIdLen(req.id);
|
|
128
|
+
assertMethod(req.method);
|
|
129
|
+
assertPath(req.path);
|
|
130
|
+
const headers = normalizeHeaders(req.headers);
|
|
131
|
+
const { body, bodyEncoding } = normalizeBody(req.body, req.bodyEncoding);
|
|
132
|
+
|
|
133
|
+
const obj = { t: RPC.REQUEST, id: req.id, method: req.method, path: req.path };
|
|
134
|
+
if (headers) obj.headers = headers;
|
|
135
|
+
if (body !== undefined) {
|
|
136
|
+
obj.body = body;
|
|
137
|
+
if (bodyEncoding && bodyEncoding !== "utf8") obj.bodyEncoding = bodyEncoding;
|
|
138
|
+
}
|
|
139
|
+
return encodeJson(obj);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {RpcResponse} res @returns {Uint8Array} */
|
|
143
|
+
export function encodeResponse(res) {
|
|
144
|
+
if (typeof res?.id !== "string") throw new TypeError("response.id required (string)");
|
|
145
|
+
if (!Number.isInteger(res.status) || res.status < 100 || res.status > 599) {
|
|
146
|
+
throw new RangeError("response.status must be an integer in [100, 599]");
|
|
147
|
+
}
|
|
148
|
+
assertIdLen(res.id);
|
|
149
|
+
const headers = normalizeHeaders(res.headers);
|
|
150
|
+
const { body, bodyEncoding } = normalizeBody(res.body, res.bodyEncoding);
|
|
151
|
+
|
|
152
|
+
const obj = { t: RPC.RESPONSE, id: res.id, status: res.status };
|
|
153
|
+
if (headers) obj.headers = headers;
|
|
154
|
+
if (body !== undefined) {
|
|
155
|
+
obj.body = body;
|
|
156
|
+
if (bodyEncoding && bodyEncoding !== "utf8") obj.bodyEncoding = bodyEncoding;
|
|
157
|
+
}
|
|
158
|
+
return encodeJson(obj);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {string} id @returns {Uint8Array} */
|
|
162
|
+
export function encodeCancel(id) {
|
|
163
|
+
if (typeof id !== "string") throw new TypeError("cancel.id required (string)");
|
|
164
|
+
assertIdLen(id);
|
|
165
|
+
return encodeJson({ t: RPC.CANCEL, id });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param {RpcEvent} ev @returns {Uint8Array} */
|
|
169
|
+
export function encodeEvent(ev) {
|
|
170
|
+
if (typeof ev?.topic !== "string") throw new TypeError("event.topic required (string)");
|
|
171
|
+
if (ev.topic.length > MAX_TOPIC_LEN) {
|
|
172
|
+
throw new RangeError(`event.topic exceeds ${MAX_TOPIC_LEN} chars`);
|
|
173
|
+
}
|
|
174
|
+
const obj = { t: RPC.EVENT, topic: ev.topic };
|
|
175
|
+
if (ev.data !== undefined) obj.data = ev.data;
|
|
176
|
+
return encodeJson(obj);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Decoder
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Decode a Noise plaintext frame into a typed RPC object. Throws on
|
|
185
|
+
* malformed JSON, missing required fields, or out-of-bounds values.
|
|
186
|
+
*
|
|
187
|
+
* @param {Uint8Array} bytes
|
|
188
|
+
* @returns {{type: "req"} & RpcRequest
|
|
189
|
+
* | {type: "res"} & RpcResponse
|
|
190
|
+
* | {type: "cancel"} & RpcCancel
|
|
191
|
+
* | {type: "evt"} & RpcEvent}
|
|
192
|
+
*/
|
|
193
|
+
export function decode(bytes) {
|
|
194
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
195
|
+
throw new TypeError("decode requires Uint8Array");
|
|
196
|
+
}
|
|
197
|
+
if (bytes.length > MAX_BODY_BYTES + 64 * 1024) {
|
|
198
|
+
// 64 KiB headroom for envelope JSON beyond the body itself.
|
|
199
|
+
throw new RangeError(`rpc frame too large: ${bytes.length} bytes`);
|
|
200
|
+
}
|
|
201
|
+
let str;
|
|
202
|
+
try {
|
|
203
|
+
str = TEXT_DECODER.decode(bytes);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new Error(`rpc frame: invalid UTF-8 (${err.message})`);
|
|
206
|
+
}
|
|
207
|
+
let obj;
|
|
208
|
+
try {
|
|
209
|
+
obj = JSON.parse(str);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
throw new Error(`rpc frame: invalid JSON (${err.message})`);
|
|
212
|
+
}
|
|
213
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
214
|
+
throw new Error("rpc frame: not a JSON object");
|
|
215
|
+
}
|
|
216
|
+
const type = obj.t;
|
|
217
|
+
if (typeof type !== "string" || !VALID_TYPES.has(type)) {
|
|
218
|
+
throw new Error(`rpc frame: unknown type ${JSON.stringify(type)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
switch (type) {
|
|
222
|
+
case RPC.REQUEST: return decodeRequest(obj);
|
|
223
|
+
case RPC.RESPONSE: return decodeResponse(obj);
|
|
224
|
+
case RPC.CANCEL: return decodeCancel(obj);
|
|
225
|
+
case RPC.EVENT: return decodeEvent(obj);
|
|
226
|
+
}
|
|
227
|
+
// Unreachable — switch above is exhaustive over VALID_TYPES.
|
|
228
|
+
throw new Error("unreachable");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function decodeRequest(obj) {
|
|
232
|
+
const id = requireStr(obj.id, "id");
|
|
233
|
+
const method = requireStr(obj.method, "method");
|
|
234
|
+
const path = requireStr(obj.path, "path");
|
|
235
|
+
assertIdLen(id);
|
|
236
|
+
assertMethod(method);
|
|
237
|
+
assertPath(path);
|
|
238
|
+
const headers = decodeHeaders(obj.headers);
|
|
239
|
+
const { body, bodyEncoding } = decodeBody(obj);
|
|
240
|
+
return { type: RPC.REQUEST, id, method, path, headers, body, bodyEncoding };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function decodeResponse(obj) {
|
|
244
|
+
const id = requireStr(obj.id, "id");
|
|
245
|
+
assertIdLen(id);
|
|
246
|
+
const status = obj.status;
|
|
247
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
248
|
+
throw new RangeError("response.status must be an integer in [100, 599]");
|
|
249
|
+
}
|
|
250
|
+
const headers = decodeHeaders(obj.headers);
|
|
251
|
+
const { body, bodyEncoding } = decodeBody(obj);
|
|
252
|
+
return { type: RPC.RESPONSE, id, status, headers, body, bodyEncoding };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function decodeCancel(obj) {
|
|
256
|
+
const id = requireStr(obj.id, "id");
|
|
257
|
+
assertIdLen(id);
|
|
258
|
+
return { type: RPC.CANCEL, id };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function decodeEvent(obj) {
|
|
262
|
+
const topic = requireStr(obj.topic, "topic");
|
|
263
|
+
if (topic.length > MAX_TOPIC_LEN) {
|
|
264
|
+
throw new RangeError(`event.topic exceeds ${MAX_TOPIC_LEN} chars`);
|
|
265
|
+
}
|
|
266
|
+
return { type: RPC.EVENT, topic, data: obj.data };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Helpers
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function requireStr(v, name) {
|
|
274
|
+
if (typeof v !== "string") throw new TypeError(`${name} required (string)`);
|
|
275
|
+
return v;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function assertIdLen(id) {
|
|
279
|
+
if (id.length === 0) throw new RangeError("id must be non-empty");
|
|
280
|
+
if (id.length > MAX_RPC_ID_LEN) throw new RangeError(`id exceeds ${MAX_RPC_ID_LEN} chars`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function assertMethod(m) {
|
|
284
|
+
if (m.length === 0) throw new RangeError("method must be non-empty");
|
|
285
|
+
if (m.length > MAX_METHOD_LEN) throw new RangeError(`method exceeds ${MAX_METHOD_LEN} chars`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertPath(p) {
|
|
289
|
+
if (!p.startsWith("/")) throw new RangeError(`path must start with "/" (got ${JSON.stringify(p.slice(0, 32))})`);
|
|
290
|
+
if (p.length > MAX_PATH_LEN) throw new RangeError(`path exceeds ${MAX_PATH_LEN} chars`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Normalize headers for encoding. Returns a plain object or undefined.
|
|
295
|
+
* Lowercases keys (matches Node's IncomingMessage convention) and rejects
|
|
296
|
+
* non-string values.
|
|
297
|
+
*/
|
|
298
|
+
function normalizeHeaders(headers) {
|
|
299
|
+
if (headers == null) return undefined;
|
|
300
|
+
if (typeof headers !== "object" || Array.isArray(headers)) {
|
|
301
|
+
throw new TypeError("headers must be a plain object");
|
|
302
|
+
}
|
|
303
|
+
const entries = Object.entries(headers);
|
|
304
|
+
if (entries.length === 0) return undefined;
|
|
305
|
+
if (entries.length > MAX_HEADERS) {
|
|
306
|
+
throw new RangeError(`headers exceeds ${MAX_HEADERS} entries`);
|
|
307
|
+
}
|
|
308
|
+
const out = {};
|
|
309
|
+
for (const [k, v] of entries) {
|
|
310
|
+
if (typeof k !== "string" || typeof v !== "string") {
|
|
311
|
+
throw new TypeError(`headers must be string→string (got ${k}=${typeof v})`);
|
|
312
|
+
}
|
|
313
|
+
if (k.length > MAX_HEADER_LEN || v.length > MAX_HEADER_LEN) {
|
|
314
|
+
throw new RangeError(`header ${JSON.stringify(k)} exceeds ${MAX_HEADER_LEN} chars`);
|
|
315
|
+
}
|
|
316
|
+
out[k.toLowerCase()] = v;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function decodeHeaders(raw) {
|
|
322
|
+
if (raw == null) return {};
|
|
323
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
324
|
+
throw new Error("headers must be a JSON object");
|
|
325
|
+
}
|
|
326
|
+
const entries = Object.entries(raw);
|
|
327
|
+
if (entries.length > MAX_HEADERS) {
|
|
328
|
+
throw new RangeError(`headers exceeds ${MAX_HEADERS} entries`);
|
|
329
|
+
}
|
|
330
|
+
const out = {};
|
|
331
|
+
for (const [k, v] of entries) {
|
|
332
|
+
if (typeof k !== "string" || typeof v !== "string") {
|
|
333
|
+
throw new Error(`headers must be string→string (got ${k}=${typeof v})`);
|
|
334
|
+
}
|
|
335
|
+
if (k.length > MAX_HEADER_LEN || v.length > MAX_HEADER_LEN) {
|
|
336
|
+
throw new RangeError(`header ${JSON.stringify(k)} exceeds ${MAX_HEADER_LEN} chars`);
|
|
337
|
+
}
|
|
338
|
+
out[k.toLowerCase()] = v;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function normalizeBody(body, bodyEncoding) {
|
|
344
|
+
if (body === undefined || body === null || body === "") return { body: undefined };
|
|
345
|
+
if (typeof body !== "string") {
|
|
346
|
+
throw new TypeError("body must be a string (binary callers: base64-encode and set bodyEncoding=\"base64\")");
|
|
347
|
+
}
|
|
348
|
+
if (bodyEncoding != null && bodyEncoding !== "utf8" && bodyEncoding !== "base64") {
|
|
349
|
+
throw new RangeError(`bodyEncoding must be "utf8" or "base64" (got ${JSON.stringify(bodyEncoding)})`);
|
|
350
|
+
}
|
|
351
|
+
// Approximate size check on the encoded JSON. We allow the body string
|
|
352
|
+
// to be up to MAX_BODY_BYTES — JSON.stringify's escaping can add a few
|
|
353
|
+
// bytes, but the outer bytes-length check in `decode` is the real cap.
|
|
354
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
355
|
+
throw new RangeError(`body exceeds ${MAX_BODY_BYTES} chars`);
|
|
356
|
+
}
|
|
357
|
+
return { body, bodyEncoding: bodyEncoding ?? "utf8" };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function decodeBody(obj) {
|
|
361
|
+
if (obj.body === undefined) return { body: undefined };
|
|
362
|
+
if (typeof obj.body !== "string") throw new Error("body must be a string");
|
|
363
|
+
if (obj.body.length > MAX_BODY_BYTES) {
|
|
364
|
+
throw new RangeError(`body exceeds ${MAX_BODY_BYTES} chars`);
|
|
365
|
+
}
|
|
366
|
+
let bodyEncoding = "utf8";
|
|
367
|
+
if (obj.bodyEncoding !== undefined) {
|
|
368
|
+
if (obj.bodyEncoding !== "utf8" && obj.bodyEncoding !== "base64") {
|
|
369
|
+
throw new Error(`bodyEncoding must be "utf8" or "base64"`);
|
|
370
|
+
}
|
|
371
|
+
bodyEncoding = obj.bodyEncoding;
|
|
372
|
+
}
|
|
373
|
+
return { body: obj.body, bodyEncoding };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function encodeJson(obj) {
|
|
377
|
+
// JSON.stringify rejects undefined values automatically; we explicitly
|
|
378
|
+
// omit them above. Use TextEncoder so the wire is canonical UTF-8 (no
|
|
379
|
+
// BOM, no surprise locale handling).
|
|
380
|
+
return TEXT_ENCODER.encode(JSON.stringify(obj));
|
|
381
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
# SCOUT_HARNESS — "claude" or "codex" (default: auto-detect)
|
|
12
12
|
# SCOUT_FLAGS — extra flags for scout (e.g. "--submolts builds,general --max-daily 5")
|
|
13
13
|
# SCOUT_TIMEOUT — propose timeout in seconds (default: 900)
|
|
14
|
+
# SCOUT_DRAFT_TIMEOUT — LLM draft timeout in seconds (default: 120)
|
|
14
15
|
# SCOUT_WINDOW — batch window in seconds (default: 1800 = 30 min)
|
|
15
16
|
# COMPOSE_MAX — max original posts per day (default: 1)
|
|
16
17
|
set -euo pipefail
|
|
@@ -26,6 +27,8 @@ NODE="$(command -v node)"
|
|
|
26
27
|
VIVEWORKER="$SCRIPT_DIR/viveworker.mjs"
|
|
27
28
|
PERSONA_FILE="$HOME/.viveworker/moltbook-persona.md"
|
|
28
29
|
WINDOW_SEC="${SCOUT_WINDOW:-1800}"
|
|
30
|
+
PROPOSE_TIMEOUT_SEC="${SCOUT_TIMEOUT:-900}"
|
|
31
|
+
DRAFT_TIMEOUT_SEC="${SCOUT_DRAFT_TIMEOUT:-120}"
|
|
29
32
|
|
|
30
33
|
# Seconds until a given hour (local time). Used for compose slot timeouts.
|
|
31
34
|
seconds_until_hour() {
|
|
@@ -85,7 +88,12 @@ candidate_should_skip() {
|
|
|
85
88
|
|
|
86
89
|
# Temp file for JSON interchange
|
|
87
90
|
SCOUT_TMP=$(mktemp /tmp/viveworker-scout-XXXXXX.json)
|
|
88
|
-
|
|
91
|
+
LOCK_DIR="${TMPDIR:-/tmp}/viveworker-moltbook-scout.lock"
|
|
92
|
+
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
93
|
+
echo "[scout-auto] another scout run is already active — skipping this invocation"
|
|
94
|
+
exit 0
|
|
95
|
+
fi
|
|
96
|
+
trap 'rm -f "$SCOUT_TMP"; rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
|
|
89
97
|
|
|
90
98
|
read_field() {
|
|
91
99
|
"$NODE" -e "
|
|
@@ -243,15 +251,15 @@ if [ "$PICK_STATUS" = "picked" ]; then
|
|
|
243
251
|
echo "[scout-auto] batch complete: picked '$BEST_TITLE' by @$BEST_AUTHOR (score=$BEST_SCORE, considered=$CONSIDERED)"
|
|
244
252
|
|
|
245
253
|
# Fetch post content from Moltbook API
|
|
246
|
-
POST_DATA=$("$NODE" --input-type=module -e "
|
|
254
|
+
POST_DATA=$(MOLTBOOK_API_MODULE="$SCRIPT_DIR/moltbook-api.mjs" "$NODE" --input-type=module -e "
|
|
247
255
|
import { readFileSync } from 'fs';
|
|
248
256
|
const env = readFileSync(process.env.HOME + '/.viveworker/moltbook.env', 'utf8');
|
|
249
257
|
for(const line of env.split('\n')){
|
|
250
258
|
const m = line.match(/^(\w+)=(.*)\$/);
|
|
251
259
|
if(m) process.env[m[1]] = m[2];
|
|
252
260
|
}
|
|
253
|
-
const { createMoltbookClient } = await import('
|
|
254
|
-
const
|
|
261
|
+
const { createMoltbookClient } = await import('file://' + process.env.MOLTBOOK_API_MODULE);
|
|
262
|
+
const mb = createMoltbookClient(process.env.MOLTBOOK_API_KEY);
|
|
255
263
|
try {
|
|
256
264
|
const post = await mb('/posts/${BEST_POST_ID}');
|
|
257
265
|
const p = post?.post || post;
|
|
@@ -297,13 +305,13 @@ PROMPT_EOF
|
|
|
297
305
|
echo "[scout-auto] drafting via $HARNESS for '$BEST_TITLE'"
|
|
298
306
|
DRAFT_TEXT=""
|
|
299
307
|
if [ "$HARNESS" = "claude" ]; then
|
|
300
|
-
DRAFT_TEXT=$("$HARNESS_BIN" -p "$DRAFT_PROMPT" --output-format text) || true
|
|
308
|
+
DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" -p "$DRAFT_PROMPT" --output-format text 2>/dev/null) || true
|
|
301
309
|
elif [ "$HARNESS" = "codex" ]; then
|
|
302
|
-
DRAFT_TEXT=$("$HARNESS_BIN" exec "$DRAFT_PROMPT") || true
|
|
310
|
+
DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" exec "$DRAFT_PROMPT" 2>/dev/null) || true
|
|
303
311
|
fi
|
|
304
312
|
|
|
305
313
|
if [ -z "$DRAFT_TEXT" ]; then
|
|
306
|
-
echo "[scout-auto] error: harness returned empty draft"
|
|
314
|
+
echo "[scout-auto] error: harness returned empty draft or timed out after ${DRAFT_TIMEOUT_SEC}s"
|
|
307
315
|
exit 1
|
|
308
316
|
fi
|
|
309
317
|
|
|
@@ -335,7 +343,7 @@ PROMPT_EOF
|
|
|
335
343
|
--post-body "$POST_CONTENT" \
|
|
336
344
|
--intent "$INTENT" \
|
|
337
345
|
--text "$REPLY_BODY" \
|
|
338
|
-
--timeout "$
|
|
346
|
+
--timeout "$PROPOSE_TIMEOUT_SEC"
|
|
339
347
|
|
|
340
348
|
exit 0
|
|
341
349
|
fi
|
package/scripts/share-cli.mjs
CHANGED
|
@@ -34,27 +34,21 @@ import https from "node:https";
|
|
|
34
34
|
import os from "node:os";
|
|
35
35
|
import path from "node:path";
|
|
36
36
|
import { Blob, File } from "node:buffer";
|
|
37
|
+
import {
|
|
38
|
+
buildX402PaymentHeader,
|
|
39
|
+
decodeXPaymentResponseHeader,
|
|
40
|
+
getX402PaymentRequestId,
|
|
41
|
+
parseX402ResponseBody,
|
|
42
|
+
selectX402PaymentRequirement,
|
|
43
|
+
SUPPORTED_X402_BUYER_NETWORKS,
|
|
44
|
+
} from "@hazbase/auth";
|
|
37
45
|
|
|
38
46
|
const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
|
|
39
47
|
const CONFIG_ENV_FILE = path.join(os.homedir(), ".viveworker", "config.env");
|
|
40
48
|
const DEFAULT_SHARE_URL = "https://share.viveworker.com";
|
|
41
49
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // mirror worker
|
|
42
|
-
const X402_VERSION = 1;
|
|
43
50
|
const PAYMENT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
44
|
-
const SUPPORTED_BUYER_NETWORKS =
|
|
45
|
-
base: {
|
|
46
|
-
chainId: 8453,
|
|
47
|
-
label: "Base",
|
|
48
|
-
usdcName: "USD Coin",
|
|
49
|
-
usdcVersion: "2",
|
|
50
|
-
},
|
|
51
|
-
"base-sepolia": {
|
|
52
|
-
chainId: 84532,
|
|
53
|
-
label: "Base Sepolia",
|
|
54
|
-
usdcName: "USDC",
|
|
55
|
-
usdcVersion: "2",
|
|
56
|
-
},
|
|
57
|
-
};
|
|
51
|
+
const SUPPORTED_BUYER_NETWORKS = SUPPORTED_X402_BUYER_NETWORKS;
|
|
58
52
|
|
|
59
53
|
// Mirror share-worker/worker.js SHARE_TYPES. Keep in sync by inspection —
|
|
60
54
|
// scripts/ and share-worker/ don't share a module. Adding a new type here
|
|
@@ -772,8 +766,8 @@ async function handlePay(args) {
|
|
|
772
766
|
throw new Error(`Expected HTTP 402 payment requirements, got ${initial.status}`);
|
|
773
767
|
}
|
|
774
768
|
|
|
775
|
-
const x402 =
|
|
776
|
-
const requirement =
|
|
769
|
+
const x402 = parseX402ResponseBody(initialText);
|
|
770
|
+
const requirement = selectX402PaymentRequirement(x402);
|
|
777
771
|
const paymentSummary = summarizeRequirement(requirement);
|
|
778
772
|
if (flags["dry-run"] || flags.dryRun) {
|
|
779
773
|
const dryRun = {
|
|
@@ -802,7 +796,7 @@ async function handlePay(args) {
|
|
|
802
796
|
},
|
|
803
797
|
}, 60_000);
|
|
804
798
|
const paidBytes = Buffer.from(await paid.arrayBuffer());
|
|
805
|
-
const responsePreview =
|
|
799
|
+
const responsePreview = decodeXPaymentResponseHeader(paid.headers.get("x-payment-response"));
|
|
806
800
|
|
|
807
801
|
if (!paid.ok) {
|
|
808
802
|
let body = {};
|
|
@@ -885,46 +879,6 @@ async function handleDelete(args) {
|
|
|
885
879
|
console.log(`✅ Deleted ${slug}`);
|
|
886
880
|
}
|
|
887
881
|
|
|
888
|
-
function parseX402Body(text) {
|
|
889
|
-
try {
|
|
890
|
-
const parsed = JSON.parse(text);
|
|
891
|
-
if (parsed && typeof parsed === "object") return parsed;
|
|
892
|
-
} catch {}
|
|
893
|
-
|
|
894
|
-
const match = String(text || "").match(
|
|
895
|
-
/<script[^>]+type=["']application\/x-x402\+json["'][^>]*>([\s\S]*?)<\/script>/iu
|
|
896
|
-
);
|
|
897
|
-
if (match) {
|
|
898
|
-
try {
|
|
899
|
-
return JSON.parse(match[1]);
|
|
900
|
-
} catch {}
|
|
901
|
-
}
|
|
902
|
-
throw new Error("HTTP 402 response did not contain a valid x402 JSON body");
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function selectPaymentRequirement(x402) {
|
|
906
|
-
const accepts = Array.isArray(x402?.accepts) ? x402.accepts : [];
|
|
907
|
-
const requirement = accepts.find((item) =>
|
|
908
|
-
item &&
|
|
909
|
-
item.scheme === "exact" &&
|
|
910
|
-
Object.prototype.hasOwnProperty.call(SUPPORTED_BUYER_NETWORKS, String(item.network || ""))
|
|
911
|
-
);
|
|
912
|
-
if (!requirement) {
|
|
913
|
-
const networks = accepts.map((item) => item?.network).filter(Boolean).join(", ") || "none";
|
|
914
|
-
throw new Error(`No supported x402 exact payment option found (offered: ${networks})`);
|
|
915
|
-
}
|
|
916
|
-
if (!ETH_ADDR_REGEX.test(String(requirement.payTo || ""))) {
|
|
917
|
-
throw new Error("x402 payment requirement has an invalid payTo address");
|
|
918
|
-
}
|
|
919
|
-
if (!ETH_ADDR_REGEX.test(String(requirement.asset || ""))) {
|
|
920
|
-
throw new Error("x402 payment requirement has an invalid asset address");
|
|
921
|
-
}
|
|
922
|
-
if (!/^\d+$/u.test(String(requirement.maxAmountRequired || ""))) {
|
|
923
|
-
throw new Error("x402 payment requirement has an invalid amount");
|
|
924
|
-
}
|
|
925
|
-
return requirement;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
882
|
function summarizeRequirement(requirement) {
|
|
929
883
|
const network = SUPPORTED_BUYER_NETWORKS[String(requirement.network)];
|
|
930
884
|
return {
|
|
@@ -960,7 +914,7 @@ function resolveBuyerWalletMode(flags) {
|
|
|
960
914
|
async function requestEoaPayment({ url, requirement, paymentSummary, flags }) {
|
|
961
915
|
await requirePaymentApproval({ url, paymentSummary, flags });
|
|
962
916
|
const privateKey = resolveBuyerPrivateKey();
|
|
963
|
-
const payment = await
|
|
917
|
+
const payment = await buildX402PaymentHeader({ requirement, privateKey });
|
|
964
918
|
return {
|
|
965
919
|
header: payment.header,
|
|
966
920
|
payer: payment.payer,
|
|
@@ -970,9 +924,7 @@ async function requestEoaPayment({ url, requirement, paymentSummary, flags }) {
|
|
|
970
924
|
}
|
|
971
925
|
|
|
972
926
|
async function requestHazbaseWalletPayment({ url, x402, requirement, paymentSummary, flags }) {
|
|
973
|
-
const paymentRequestId =
|
|
974
|
-
x402?.paymentRequestId || x402?.hazbase?.paymentRequestId || requirement?.extra?.paymentRequestId || ""
|
|
975
|
-
);
|
|
927
|
+
const paymentRequestId = getX402PaymentRequestId(x402, requirement);
|
|
976
928
|
if (!paymentRequestId) {
|
|
977
929
|
throw new Error("This 402 response does not expose a hazBase paymentRequestId; --wallet hazbase requires a hazBase-backed share.");
|
|
978
930
|
}
|
|
@@ -1009,11 +961,6 @@ async function requestHazbaseWalletPayment({ url, x402, requirement, paymentSumm
|
|
|
1009
961
|
};
|
|
1010
962
|
}
|
|
1011
963
|
|
|
1012
|
-
function cleanPaymentRequestId(value) {
|
|
1013
|
-
const text = String(value || "").trim();
|
|
1014
|
-
return /^[a-zA-Z0-9:_-]{8,160}$/u.test(text) ? text : "";
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
964
|
async function requirePaymentApproval({ url, paymentSummary, flags }) {
|
|
1018
965
|
const config = await resolveApprovalBridgeConfig();
|
|
1019
966
|
if (shouldSkipPaymentApproval(flags, config.envText)) {
|
|
@@ -1203,69 +1150,6 @@ function resolveBuyerPrivateKey() {
|
|
|
1203
1150
|
return raw.startsWith("0x") ? raw : `0x${raw}`;
|
|
1204
1151
|
}
|
|
1205
1152
|
|
|
1206
|
-
async function buildXPaymentHeader(requirement, privateKey) {
|
|
1207
|
-
const ethers = await import("ethers");
|
|
1208
|
-
const network = SUPPORTED_BUYER_NETWORKS[String(requirement.network)];
|
|
1209
|
-
if (!network) throw new Error(`Unsupported buyer network: ${requirement.network}`);
|
|
1210
|
-
|
|
1211
|
-
const wallet = new ethers.Wallet(privateKey);
|
|
1212
|
-
const now = Math.floor(Date.now() / 1000);
|
|
1213
|
-
const maxTimeout = Number(requirement.maxTimeoutSeconds || 60);
|
|
1214
|
-
const validAfter = String(Math.max(0, now - 30));
|
|
1215
|
-
const validBefore = String(now + Math.max(30, Math.min(300, Number.isFinite(maxTimeout) ? maxTimeout : 60)));
|
|
1216
|
-
const authorization = {
|
|
1217
|
-
from: wallet.address,
|
|
1218
|
-
to: ethers.getAddress(String(requirement.payTo)),
|
|
1219
|
-
value: String(requirement.maxAmountRequired),
|
|
1220
|
-
validAfter,
|
|
1221
|
-
validBefore,
|
|
1222
|
-
nonce: `0x${crypto.randomBytes(32).toString("hex")}`,
|
|
1223
|
-
};
|
|
1224
|
-
const domain = {
|
|
1225
|
-
name: String(requirement.extra?.name || network.usdcName),
|
|
1226
|
-
version: String(requirement.extra?.version || network.usdcVersion),
|
|
1227
|
-
chainId: network.chainId,
|
|
1228
|
-
verifyingContract: ethers.getAddress(String(requirement.asset)),
|
|
1229
|
-
};
|
|
1230
|
-
const types = {
|
|
1231
|
-
TransferWithAuthorization: [
|
|
1232
|
-
{ name: "from", type: "address" },
|
|
1233
|
-
{ name: "to", type: "address" },
|
|
1234
|
-
{ name: "value", type: "uint256" },
|
|
1235
|
-
{ name: "validAfter", type: "uint256" },
|
|
1236
|
-
{ name: "validBefore", type: "uint256" },
|
|
1237
|
-
{ name: "nonce", type: "bytes32" },
|
|
1238
|
-
],
|
|
1239
|
-
};
|
|
1240
|
-
const signature = await wallet.signTypedData(domain, types, authorization);
|
|
1241
|
-
const payload = {
|
|
1242
|
-
x402Version: X402_VERSION,
|
|
1243
|
-
scheme: String(requirement.scheme || "exact"),
|
|
1244
|
-
network: String(requirement.network),
|
|
1245
|
-
payload: {
|
|
1246
|
-
signature,
|
|
1247
|
-
authorization,
|
|
1248
|
-
},
|
|
1249
|
-
};
|
|
1250
|
-
return {
|
|
1251
|
-
header: Buffer.from(JSON.stringify(payload)).toString("base64"),
|
|
1252
|
-
payer: wallet.address,
|
|
1253
|
-
payload,
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function decodePaymentResponseHeader(value) {
|
|
1258
|
-
const raw = String(value || "").trim();
|
|
1259
|
-
if (!raw) return null;
|
|
1260
|
-
try {
|
|
1261
|
-
const normalized = raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
1262
|
-
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
|
|
1263
|
-
return JSON.parse(Buffer.from(normalized + padding, "base64").toString("utf8"));
|
|
1264
|
-
} catch {
|
|
1265
|
-
return { raw };
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
1153
|
async function writeOrPreviewPaidBody(flags, res, bytes) {
|
|
1270
1154
|
const contentType = res.headers.get("content-type") || "";
|
|
1271
1155
|
if (flags.output) {
|