viveworker 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1324 -103
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +634 -9
  21. package/web/app.js +1731 -187
  22. package/web/i18n.js +187 -9
  23. package/web/index.html +32 -2
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. 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
- trap 'rm -f "$SCOUT_TMP"' EXIT
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('./scripts/moltbook-api.mjs');
254
- const { client: mb } = await createMoltbookClient();
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 "$WINDOW_SEC"
346
+ --timeout "$PROPOSE_TIMEOUT_SEC"
339
347
 
340
348
  exit 0
341
349
  fi
@@ -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 = parseX402Body(initialText);
776
- const requirement = selectPaymentRequirement(x402);
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 = decodePaymentResponseHeader(paid.headers.get("x-payment-response"));
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 buildXPaymentHeader(requirement, privateKey);
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 = cleanPaymentRequestId(
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) {