viveworker 0.8.6 → 0.8.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Mobile control plane for AI agents. Approve, answer, and hand off work for Codex, Claude, MCP-ready tools, and A2A from one phone.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  import { promises as fs } from "node:fs";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
- import { execSync } from "node:child_process";
21
+ import { execFileSync } from "node:child_process";
22
22
  import { upsertEnvText } from "./lib/pairing.mjs";
23
23
 
24
24
  const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
@@ -355,13 +355,30 @@ async function fetchJson(url, options = {}) {
355
355
  }
356
356
 
357
357
  function openBrowser(url) {
358
+ // authUrl comes from the relay response, so a malicious or compromised relay
359
+ // (or a user pointed at one via --relay-url) must not be able to smuggle shell
360
+ // metacharacters into a command line. Parse and require http(s), then hand the
361
+ // URL to the OS opener as a single argv entry via execFileSync (no shell) so
362
+ // it can never be reinterpreted as a command.
363
+ let target;
364
+ try {
365
+ const parsed = new URL(String(url));
366
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
367
+ console.log(` (refusing to auto-open a non-http(s) URL — open it manually)`);
368
+ return;
369
+ }
370
+ target = parsed.toString();
371
+ } catch {
372
+ console.log(` (could not parse the authorization URL — open it manually)`);
373
+ return;
374
+ }
358
375
  try {
359
376
  if (process.platform === "darwin") {
360
- execSync(`open "${url}"`, { stdio: "ignore" });
377
+ execFileSync("open", [target], { stdio: "ignore" });
361
378
  } else if (process.platform === "linux") {
362
- execSync(`xdg-open "${url}"`, { stdio: "ignore" });
379
+ execFileSync("xdg-open", [target], { stdio: "ignore" });
363
380
  } else if (process.platform === "win32") {
364
- execSync(`start "" "${url}"`, { stdio: "ignore" });
381
+ execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
365
382
  }
366
383
  } catch {
367
384
  // Browser open failed — URL is already printed for manual use
@@ -37,6 +37,7 @@ const {
37
37
  bootstrapPasskeyAccount,
38
38
  completePasskeyAssertion,
39
39
  completePasskeyRegistration,
40
+ listPasskeyDevices,
40
41
  listSupportedChains,
41
42
  requestEmailOtp: requestHazbaseEmailOtp,
42
43
  requestPasskeyAssertionChallenge,
@@ -1549,11 +1550,19 @@ function normalizeTrustedReadTokens(tokens) {
1549
1550
  }
1550
1551
  if (strippedTokens.some((token) => {
1551
1552
  const value = String(token || "");
1552
- return (
1553
- ["||", "&&", ";", "&"].includes(value) ||
1554
- value.includes("`") ||
1555
- value.includes("$(")
1556
- );
1553
+ if (value === "|") {
1554
+ // A standalone pipe is the only operator allowed here; the pipe-stage
1555
+ // split below validates that each downstream stage is an allow-listed
1556
+ // post-processor (head/tail/wc).
1557
+ return false;
1558
+ }
1559
+ // Reject any token that *contains* a command separator or command
1560
+ // substitution, not just tokens that equal one. The whitespace tokenizer
1561
+ // keeps separators attached to adjacent text (e.g. "README.md;id" or
1562
+ // "README.md|sh"), so a whole-token equality check let the shell run an
1563
+ // injected second command the classifier never saw. A substring check on
1564
+ // ; & | and backtick (plus "$(") closes that gap.
1565
+ return /[;&|`]/u.test(value) || value.includes("$(");
1557
1566
  })) {
1558
1567
  return null;
1559
1568
  }
@@ -1587,6 +1596,14 @@ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1587
1596
  const normalizedCwd = cleanText(cwd || "");
1588
1597
  const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
1589
1598
  const normalizedCommandText = unwrapShellCommand(commandText);
1599
+ // A literal newline or carriage return is a shell command separator, but
1600
+ // tokenizeShellWords() treats it as ordinary whitespace — so an injected
1601
+ // second command (e.g. "rg foo\ntouch evil") would be split into harmless
1602
+ // looking word tokens and misclassified as a single safe read. Refuse to
1603
+ // auto-classify anything carrying these so it falls back to manual approval.
1604
+ if (/[\n\r]/u.test(normalizedCommandText)) {
1605
+ return null;
1606
+ }
1590
1607
  const rawTokens = tokenizeShellWords(normalizedCommandText);
1591
1608
  if (!normalizedCommandText || !normalizedCwd || !normalizedWorkspaceRoot || rawTokens.length === 0) {
1592
1609
  return null;
@@ -10486,6 +10503,45 @@ function isHiddenCodexApprovalAssessmentText(text) {
10486
10503
  return /\bThe following is the Codex agent history(?: added since your last approval assessment)?\b/iu.test(single);
10487
10504
  }
10488
10505
 
10506
+ function isHiddenCodexApprovalDecisionJsonText(text) {
10507
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
10508
+ if (!single || !single.startsWith("{") || !single.endsWith("}")) {
10509
+ return false;
10510
+ }
10511
+
10512
+ const parsed = safeJsonParse(single);
10513
+ if (!isPlainObject(parsed)) {
10514
+ return false;
10515
+ }
10516
+
10517
+ const allowedKeys = new Set(["risk_level", "user_authorization", "outcome", "rationale"]);
10518
+ const keys = Object.keys(parsed);
10519
+ if (!keys.includes("outcome") || keys.some((key) => !allowedKeys.has(key))) {
10520
+ return false;
10521
+ }
10522
+
10523
+ const outcome = cleanText(parsed.outcome || "");
10524
+ if (outcome !== "allow" && outcome !== "deny") {
10525
+ return false;
10526
+ }
10527
+
10528
+ const riskLevel = cleanText(parsed.risk_level || "");
10529
+ if (riskLevel && !["low", "medium", "high", "critical"].includes(riskLevel)) {
10530
+ return false;
10531
+ }
10532
+
10533
+ const userAuthorization = cleanText(parsed.user_authorization || "");
10534
+ if (userAuthorization && !["unknown", "low", "medium", "high"].includes(userAuthorization)) {
10535
+ return false;
10536
+ }
10537
+
10538
+ if (parsed.rationale != null && typeof parsed.rationale !== "string") {
10539
+ return false;
10540
+ }
10541
+
10542
+ return keys.length === 1 || keys.some((key) => key === "risk_level" || key === "user_authorization" || key === "rationale");
10543
+ }
10544
+
10489
10545
  function shouldHideInternalTimelineItem(item) {
10490
10546
  return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item) || shouldHideMoltbookHarnessItem(item);
10491
10547
  }
@@ -10563,14 +10619,23 @@ function shouldHideCodexInternalApprovalItem(item) {
10563
10619
 
10564
10620
  const title = cleanText(item.title ?? "");
10565
10621
  const threadLabel = cleanText(item.threadLabel ?? "");
10566
- if (isHiddenCodexApprovalAssessmentText(title) || isHiddenCodexApprovalAssessmentText(threadLabel)) {
10622
+ if (
10623
+ isHiddenCodexApprovalAssessmentText(title) ||
10624
+ isHiddenCodexApprovalAssessmentText(threadLabel) ||
10625
+ isHiddenCodexApprovalDecisionJsonText(title) ||
10626
+ isHiddenCodexApprovalDecisionJsonText(threadLabel)
10627
+ ) {
10567
10628
  return true;
10568
10629
  }
10569
10630
  return (
10570
10631
  isHiddenCodexApprovalAssessmentText(item.messageText) ||
10571
10632
  isHiddenCodexApprovalAssessmentText(item.summary) ||
10572
10633
  isHiddenCodexApprovalAssessmentText(item.detailText) ||
10573
- isHiddenCodexApprovalAssessmentText(item.message)
10634
+ isHiddenCodexApprovalAssessmentText(item.message) ||
10635
+ isHiddenCodexApprovalDecisionJsonText(item.messageText) ||
10636
+ isHiddenCodexApprovalDecisionJsonText(item.summary) ||
10637
+ isHiddenCodexApprovalDecisionJsonText(item.detailText) ||
10638
+ isHiddenCodexApprovalDecisionJsonText(item.message)
10574
10639
  );
10575
10640
  }
10576
10641
 
@@ -15656,7 +15721,7 @@ function resolveManifestPairingToken({ config, state, requestedToken }) {
15656
15721
  if (!isPairingAvailable(config)) {
15657
15722
  return "";
15658
15723
  }
15659
- return cleanText(config.pairingToken) === token ? token : "";
15724
+ return constantTimeStringEqual(cleanText(config.pairingToken), token) ? token : "";
15660
15725
  }
15661
15726
 
15662
15727
  function buildWebManifest({ pairToken }) {
@@ -15691,7 +15756,7 @@ function buildWebManifest({ pairToken }) {
15691
15756
  );
15692
15757
  }
15693
15758
 
15694
- function buildWebAppHtml({ pairToken }) {
15759
+ function buildWebAppHtml({ pairToken, nonce }) {
15695
15760
  const manifestHref = pairToken
15696
15761
  ? `/manifest.webmanifest?pairToken=${encodeURIComponent(pairToken)}`
15697
15762
  : "/manifest.webmanifest";
@@ -15814,7 +15879,7 @@ function buildWebAppHtml({ pairToken }) {
15814
15879
  </div>
15815
15880
  </div>
15816
15881
  <div id="app"></div>
15817
- <script>
15882
+ <script nonce="${nonce}">
15818
15883
  (() => {
15819
15884
  const isJa = (navigator.language || "").toLowerCase().startsWith("ja");
15820
15885
  const message = isJa ? "同じWi-Fi内のPCを確認中..." : "Checking your trusted Wi-Fi...";
@@ -16099,10 +16164,22 @@ function createNativeApprovalServer({ config, runtime, state }) {
16099
16164
  state,
16100
16165
  requestedToken: url.searchParams.get("pairToken"),
16101
16166
  });
16167
+ const cspNonce = crypto.randomBytes(16).toString("base64url");
16102
16168
  res.statusCode = 200;
16103
16169
  res.setHeader("Content-Type", "text/html; charset=utf-8");
16104
16170
  res.setHeader("Cache-Control", "no-store, max-age=0");
16105
- res.end(buildWebAppHtml({ pairToken }));
16171
+ // Defense-in-depth against XSS in the control-plane PWA: this document
16172
+ // renders server-built HTML that can contain untrusted agent content,
16173
+ // so lock script execution to same-origin plus the single nonce'd inline
16174
+ // boot script. Without 'unsafe-inline', an injected <script>, onerror=
16175
+ // handler, or javascript: URL cannot run even if it slips past the
16176
+ // markdown sanitizer. img/style/connect are intentionally left to the
16177
+ // browser default so remote connection, wallet, and images keep working.
16178
+ res.setHeader(
16179
+ "Content-Security-Policy",
16180
+ `script-src 'self' 'nonce-${cspNonce}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'`
16181
+ );
16182
+ res.end(buildWebAppHtml({ pairToken, nonce: cspNonce }));
16106
16183
  return;
16107
16184
  }
16108
16185
 
@@ -16233,7 +16310,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
16233
16310
  const apiA2ATaskStatus = url.pathname.match(/^\/api\/providers\/a2a\/tasks\/([^/]+)\/status$/u);
16234
16311
  if (apiA2ATaskStatus && req.method === "GET") {
16235
16312
  const apiKey = req.headers["x-a2a-key"] || "";
16236
- if (!config.a2aApiKey || apiKey !== config.a2aApiKey) {
16313
+ if (!config.a2aApiKey || !constantTimeStringEqual(apiKey, config.a2aApiKey)) {
16237
16314
  return writeJson(res, 401, { error: "unauthorized" });
16238
16315
  }
16239
16316
  const token = decodeURIComponent(apiA2ATaskStatus[1]);
@@ -16702,7 +16779,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
16702
16779
 
16703
16780
  if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
16704
16781
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
16705
- const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
16782
+ const hookAuth = config.sessionSecret && constantTimeStringEqual(hookSecret, config.sessionSecret);
16706
16783
  if (!hookAuth) {
16707
16784
  const session = requireApiSession(req, res, config, state);
16708
16785
  if (!session) return;
@@ -16711,12 +16788,20 @@ if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
16711
16788
  return writeJson(res, 200, { enabled: false });
16712
16789
  }
16713
16790
  const hazbase = normalizeHazbaseState(state.hazbase);
16714
- const [paymentsResult, chainsResult] = await Promise.allSettled([
16791
+ const localPasskeyRegistered = Boolean(hazbase.deviceBindingId || hazbase.credentialId);
16792
+ const devicesPromise = hazbase.accessToken && !hazbase.sessionInvalid && !localPasskeyRegistered
16793
+ ? listPasskeyDevices({ emailSession: hazbase.accessToken })
16794
+ : Promise.resolve(null);
16795
+ const [paymentsResult, chainsResult, devicesResult] = await Promise.allSettled([
16715
16796
  fetchHazbaseMetadata(config, "/api/meta/payments"),
16716
16797
  fetchHazbaseMetadata(config, "/api/meta/chains"),
16798
+ devicesPromise,
16717
16799
  ]);
16718
16800
  const payments = paymentsResult.status === "fulfilled" ? paymentsResult.value : null;
16719
16801
  const chains = chainsResult.status === "fulfilled" ? chainsResult.value : null;
16802
+ const passkeyDevices = devicesResult.status === "fulfilled" && Array.isArray(devicesResult.value?.devices)
16803
+ ? devicesResult.value.devices
16804
+ : [];
16720
16805
  const errors = [];
16721
16806
  if (paymentsResult.status === "rejected") {
16722
16807
  errors.push(paymentsResult.reason?.message || String(paymentsResult.reason || "payments metadata unavailable"));
@@ -16724,6 +16809,10 @@ if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
16724
16809
  if (chainsResult.status === "rejected") {
16725
16810
  errors.push(chainsResult.reason?.message || String(chainsResult.reason || "chains metadata unavailable"));
16726
16811
  }
16812
+ if (devicesResult.status === "rejected") {
16813
+ if (await maybeWriteHazbaseSessionExpiredResponse({ error: devicesResult.reason, config, state, res })) return;
16814
+ errors.push(devicesResult.reason?.message || String(devicesResult.reason || "passkey devices unavailable"));
16815
+ }
16727
16816
  const supportedChains = Array.isArray(chains?.chains)
16728
16817
  ? chains.chains.filter((entry) => Boolean(paymentNetworkForChainId(Number(entry.chainId))))
16729
16818
  .filter((entry) => isPaymentNetworkAvailable(paymentNetworkForChainId(Number(entry.chainId))))
@@ -16759,6 +16848,8 @@ if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
16759
16848
  sessionId: hazbase.sessionId,
16760
16849
  deviceBindingId: hazbase.deviceBindingId,
16761
16850
  credentialId: hazbase.credentialId,
16851
+ passkeyRegistered: Boolean(localPasskeyRegistered || passkeyDevices.length),
16852
+ passkeyDeviceCount: passkeyDevices.length,
16762
16853
  highTrustExpiresAt: hazbase.highTrustExpiresAt,
16763
16854
  accounts: hazbase.accounts,
16764
16855
  paymentCapabilities,
@@ -16773,7 +16864,7 @@ if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
16773
16864
 
16774
16865
  if (url.pathname === "/api/hazbase/payout-address" && req.method === "GET") {
16775
16866
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
16776
- const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
16867
+ const hookAuth = config.sessionSecret && constantTimeStringEqual(hookSecret, config.sessionSecret);
16777
16868
  if (!hookAuth) {
16778
16869
  const session = requireApiSession(req, res, config, state);
16779
16870
  if (!session) return;
@@ -17119,7 +17210,7 @@ if (url.pathname === "/api/hazbase/agent-payment-defaults" && req.method === "PO
17119
17210
 
17120
17211
  if (url.pathname === "/api/hazbase/session/refresh" && req.method === "POST") {
17121
17212
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
17122
- const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
17213
+ const hookAuth = config.sessionSecret && constantTimeStringEqual(hookSecret, config.sessionSecret);
17123
17214
  if (!hookAuth) {
17124
17215
  const session = requireMutatingApiSession(req, res, config, state);
17125
17216
  if (!session) return;
@@ -17728,7 +17819,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17728
17819
  // List known threads (active + registry) for the share target picker.
17729
17820
  if (url.pathname === "/api/threads/list" && req.method === "GET") {
17730
17821
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
17731
- const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
17822
+ const hookAuth = config.sessionSecret && constantTimeStringEqual(hookSecret, config.sessionSecret);
17732
17823
  if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
17733
17824
  const codexConnected = Boolean(runtime.ipcClient?.clientId);
17734
17825
  const activeIds = new Set();
@@ -17772,7 +17863,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17772
17863
  // Submit a thread share request (creates an approval item on the phone).
17773
17864
  if (url.pathname === "/api/threads/share" && req.method === "POST") {
17774
17865
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
17775
- const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
17866
+ const hookAuth = config.sessionSecret && constantTimeStringEqual(hookSecret, config.sessionSecret);
17776
17867
  if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
17777
17868
  const body = await parseJsonBody(req);
17778
17869
  const shareType = cleanText(body?.shareType || "message");
@@ -18022,7 +18113,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18022
18113
  // time-slot-based compose. Defaults to full-day today.
18023
18114
  if (url.pathname === "/api/providers/moltbook/activity-summary" && req.method === "GET") {
18024
18115
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18025
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18116
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18026
18117
  return writeJson(res, 403, { error: "forbidden" });
18027
18118
  }
18028
18119
  const slot = String(url.searchParams.get("slot") || "").toLowerCase();
@@ -18197,7 +18288,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18197
18288
 
18198
18289
  if (url.pathname === "/api/payments/x402/hazbase-wallet" && req.method === "POST") {
18199
18290
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18200
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18291
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18201
18292
  return writeJson(res, 401, { error: "unauthorized" });
18202
18293
  }
18203
18294
 
@@ -18287,7 +18378,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18287
18378
 
18288
18379
  if (url.pathname === "/api/payments/x402/approval" && req.method === "POST") {
18289
18380
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18290
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18381
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18291
18382
  return writeJson(res, 401, { error: "unauthorized" });
18292
18383
  }
18293
18384
 
@@ -18355,7 +18446,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18355
18446
 
18356
18447
  if (url.pathname === "/api/providers/mcp/events" && req.method === "POST") {
18357
18448
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18358
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18449
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18359
18450
  return writeJson(res, 401, { error: "unauthorized" });
18360
18451
  }
18361
18452
 
@@ -18371,7 +18462,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18371
18462
 
18372
18463
  if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
18373
18464
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18374
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18465
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18375
18466
  return writeJson(res, 401, { error: "unauthorized" });
18376
18467
  }
18377
18468
 
@@ -18735,7 +18826,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18735
18826
 
18736
18827
  if (url.pathname === "/api/providers/moltbook/events" && req.method === "POST") {
18737
18828
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18738
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18829
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18739
18830
  return writeJson(res, 401, { error: "unauthorized" });
18740
18831
  }
18741
18832
  let body;
@@ -18887,7 +18978,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18887
18978
 
18888
18979
  if (url.pathname === "/api/providers/moltbook/draft" && req.method === "POST") {
18889
18980
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
18890
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
18981
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
18891
18982
  return writeJson(res, 401, { error: "unauthorized" });
18892
18983
  }
18893
18984
  let body;
@@ -19014,7 +19105,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
19014
19105
  );
19015
19106
  if (apiMoltbookDraftDecisionGet && req.method === "GET") {
19016
19107
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
19017
- if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
19108
+ if (!config.sessionSecret || !constantTimeStringEqual(hookSecret, config.sessionSecret)) {
19018
19109
  return writeJson(res, 401, { error: "unauthorized" });
19019
19110
  }
19020
19111
  const token = decodeURIComponent(apiMoltbookDraftDecisionGet[1]);
@@ -22439,8 +22530,13 @@ async function saveState(stateFile, state) {
22439
22530
  // single newline at the end keeps diffs/hexdumps working on the off chance
22440
22531
  // someone cats the file.
22441
22532
  const output = JSON.stringify(state);
22442
- await fs.mkdir(path.dirname(stateFile), { recursive: true });
22443
- await fs.writeFile(stateFile, `${output}\n`, "utf8");
22533
+ await fs.mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
22534
+ // state.json holds hazbase wallet tokens, push subscriptions, and device
22535
+ // records — keep it owner-only so other local users can't read it. writeFile's
22536
+ // mode only applies on create, so chmod explicitly to also tighten any file
22537
+ // that was previously written world-readable.
22538
+ await fs.writeFile(stateFile, `${output}\n`, { mode: 0o600 });
22539
+ await fs.chmod(stateFile, 0o600).catch(() => {});
22444
22540
  }
22445
22541
 
22446
22542
  // ---------------------------------------------------------------------------
@@ -210,7 +210,7 @@ async function runSetup(cliOptions) {
210
210
  : null;
211
211
 
212
212
  progress.update("cli.setup.progress.writeConfig");
213
- await fs.mkdir(path.dirname(envFile), { recursive: true });
213
+ await fs.mkdir(path.dirname(envFile), { recursive: true, mode: 0o700 });
214
214
  await fs.mkdir(path.dirname(logFile), { recursive: true });
215
215
 
216
216
  const envLines = [
@@ -248,7 +248,8 @@ async function runSetup(cliOptions) {
248
248
  enableNtfy && existing.NTFY_ACCESS_TOKEN ? `NTFY_ACCESS_TOKEN=${existing.NTFY_ACCESS_TOKEN}` : null,
249
249
  ].filter(Boolean);
250
250
 
251
- await fs.writeFile(envFile, `${envLines.join("\n")}\n`, "utf8");
251
+ await fs.writeFile(envFile, `${envLines.join("\n")}\n`, { mode: 0o600 });
252
+ await fs.chmod(envFile, 0o600).catch(() => {});
252
253
 
253
254
  progress.update("cli.setup.progress.providers");
254
255
  const providerSetup = await autoConfigureProvidersDuringSetup({
@@ -1455,8 +1456,9 @@ async function refreshPairingCredentials(envFile, config = {}, { force = false }
1455
1456
  PAIRING_TOKEN: nextPairing.pairingToken,
1456
1457
  PAIRING_EXPIRES_AT_MS: String(nextPairing.pairingExpiresAtMs),
1457
1458
  });
1458
- await fs.mkdir(path.dirname(envFile), { recursive: true });
1459
- await fs.writeFile(envFile, nextText, "utf8");
1459
+ await fs.mkdir(path.dirname(envFile), { recursive: true, mode: 0o700 });
1460
+ await fs.writeFile(envFile, nextText, { mode: 0o600 });
1461
+ await fs.chmod(envFile, 0o600).catch(() => {});
1460
1462
 
1461
1463
  return {
1462
1464
  rotated: true,
@@ -1604,7 +1606,8 @@ async function repairDoctorIssues(cliOptions, { envFile, config, locale, hostnam
1604
1606
 
1605
1607
  const currentText = (await fileExists(envFile)) ? await fs.readFile(envFile, "utf8") : "";
1606
1608
  const nextText = upsertEnvText(currentText, updates);
1607
- await fs.writeFile(envFile, nextText, "utf8");
1609
+ await fs.writeFile(envFile, nextText, { mode: 0o600 });
1610
+ await fs.chmod(envFile, 0o600).catch(() => {});
1608
1611
  return changed;
1609
1612
  }
1610
1613
 
@@ -2059,8 +2062,9 @@ async function maybeRotateStartupPairing(envFile, config = {}) {
2059
2062
  PAIRING_TOKEN: nextPairing.pairingToken,
2060
2063
  PAIRING_EXPIRES_AT_MS: String(nextPairing.pairingExpiresAtMs),
2061
2064
  });
2062
- await fs.mkdir(path.dirname(envFile), { recursive: true });
2063
- await fs.writeFile(envFile, nextText, "utf8");
2065
+ await fs.mkdir(path.dirname(envFile), { recursive: true, mode: 0o700 });
2066
+ await fs.writeFile(envFile, nextText, { mode: 0o600 });
2067
+ await fs.chmod(envFile, 0o600).catch(() => {});
2064
2068
 
2065
2069
  return {
2066
2070
  rotated: true,
package/web/app.js CHANGED
@@ -6989,7 +6989,7 @@ function renderWalletInventoryCapabilityCard(hazbase, network) {
6989
6989
  };
6990
6990
  const sessionInvalid = Boolean(hazbase.sessionInvalid);
6991
6991
  const signedIn = Boolean(hazbase.signedIn) && !sessionInvalid;
6992
- const hasPasskey = Boolean(hazbase.credentialId || hazbase.deviceBindingId);
6992
+ const hasPasskey = Boolean(hazbase.passkeyRegistered || hazbase.credentialId || hazbase.deviceBindingId);
6993
6993
  const configured = Boolean(capability.payTo || capability.payoutAddress || capability.smartAccountAddress);
6994
6994
  const needsPasskey = def?.family === "evm";
6995
6995
  const canConfigure = signedIn && (!needsPasskey || hasPasskey);
@@ -7052,7 +7052,7 @@ function deriveHazbaseWalletFlow(hazbase) {
7052
7052
  const sessionInvalid = Boolean(hazbase.sessionInvalid);
7053
7053
  const signedIn = Boolean(hazbase.signedIn) && !sessionInvalid;
7054
7054
  const passkeyHost = hazbasePasskeyHostSupport();
7055
- const hasPasskey = Boolean(hazbase.credentialId || hazbase.deviceBindingId);
7055
+ const hasPasskey = Boolean(hazbase.passkeyRegistered || hazbase.credentialId || hazbase.deviceBindingId);
7056
7056
 
7057
7057
  const actionButton = (labelKey, action, { primary = false, disabled = false, attrs = "" } = {}) => {
7058
7058
  const pending = isHazbaseActionPending(action);
@@ -10159,7 +10159,15 @@ for (const button of document.querySelectorAll("[data-hazbase-action]")) {
10159
10159
  }
10160
10160
  const { createPasskeyRegistrationCredential } = await loadHazbasePasskeyModule();
10161
10161
  const challenge = await apiPost("/api/hazbase/passkey/register/challenge", {}, { timeoutMs: HAZBASE_ACTION_TIMEOUT_MS });
10162
- const credential = await createPasskeyRegistrationCredential(challenge);
10162
+ let credential;
10163
+ try {
10164
+ credential = await createPasskeyRegistrationCredential(challenge);
10165
+ } catch (error) {
10166
+ if (error?.name === "InvalidStateError" || /invalid state/i.test(error?.message || String(error))) {
10167
+ throw new Error(L("error.hazbasePasskeyAlreadyRegistered"));
10168
+ }
10169
+ throw error;
10170
+ }
10163
10171
  await apiPost("/api/hazbase/passkey/register/complete", {
10164
10172
  challengeId: challenge.challengeId,
10165
10173
  credential,
package/web/i18n.js CHANGED
@@ -704,7 +704,7 @@ const translations = {
704
704
  "settings.hazbase.status.signedIn": "Signed in",
705
705
  "settings.hazbase.status.sessionExpired": "Session expired",
706
706
  "settings.hazbase.status.otpAwaitingVerify": "Sent. Check your inbox, then enter the code below.",
707
- "settings.hazbase.passkey.ready": "Registered on this device",
707
+ "settings.hazbase.passkey.ready": "Registered for this account",
708
708
  "settings.hazbase.passkey.missing": "Not registered yet",
709
709
  "settings.hazbase.passkey.localHostRequired": "Open viveworker on its .local hostname to register a passkey.",
710
710
  "settings.hazbase.wallet.missing": "Not issued yet",
@@ -816,6 +816,7 @@ const translations = {
816
816
  "error.hazbaseEmailRequired": "Enter your email address first.",
817
817
  "error.hazbaseOtpRequired": "Enter the one-time password.",
818
818
  "error.hazbasePasskeyLocalHostRequired": "Open viveworker on its .local hostname to use hazBase passkeys.",
819
+ "error.hazbasePasskeyAlreadyRegistered": "A passkey is already registered for this account. Use the existing passkey to continue.",
819
820
  "error.hazbaseWalletAccountMissing": "Issue a wallet on this chain first.",
820
821
  "error.hazbaseLiquidAddressRequired": "Enter a Liquid payout address first.",
821
822
  "error.hazbaseLiquidAddressInvalid": "Enter a valid Liquid payout address for this network.",
@@ -1830,7 +1831,7 @@ const translations = {
1830
1831
  "settings.hazbase.status.signedIn": "サインイン済み",
1831
1832
  "settings.hazbase.status.sessionExpired": "セッション切れ",
1832
1833
  "settings.hazbase.status.otpAwaitingVerify": "送信しました。メールを確認して、下のフィールドに入力してください。",
1833
- "settings.hazbase.passkey.ready": "この端末で登録済み",
1834
+ "settings.hazbase.passkey.ready": "このアカウントで登録済み",
1834
1835
  "settings.hazbase.passkey.missing": "まだ登録されていません",
1835
1836
  "settings.hazbase.passkey.localHostRequired": ".local ホスト名で viveworker を開くとパスキーを登録できます。",
1836
1837
  "settings.hazbase.wallet.missing": "未発行",
@@ -1942,6 +1943,7 @@ const translations = {
1942
1943
  "error.hazbaseEmailRequired": "先にメールアドレスを入力してください。",
1943
1944
  "error.hazbaseOtpRequired": "ワンタイムパスワードを入力してください。",
1944
1945
  "error.hazbasePasskeyLocalHostRequired": ".local ホスト名で viveworker を開くと hazBase パスキーを使えます。",
1946
+ "error.hazbasePasskeyAlreadyRegistered": "このアカウントには既にパスキーが登録されています。既存のパスキーで続行してください。",
1945
1947
  "error.hazbaseWalletAccountMissing": "先にこのチェーンのウォレットを発行してください。",
1946
1948
  "error.hazbaseLiquidAddressRequired": "先に Liquid payout アドレスを入力してください。",
1947
1949
  "error.hazbaseLiquidAddressInvalid": "このネットワーク用の有効な Liquid payout アドレスを入力してください。",
@@ -48,7 +48,6 @@ import {
48
48
  decode,
49
49
  encodeData,
50
50
  encodeAck,
51
- encodePing,
52
51
  encodeResumeReq,
53
52
  generateMid,
54
53
  FRAME_DATA,
@@ -79,7 +78,11 @@ export const STATE = Object.freeze({
79
78
  FAILED: "failed",
80
79
  });
81
80
 
82
- const DEFAULT_PING_INTERVAL_MS = 30_000; // CF idle timeout is ~100s
81
+ export const TEXT_KEEPALIVE_PING = "viveworker.keepalive.ping.v1";
82
+ export const TEXT_KEEPALIVE_PONG = "viveworker.keepalive.pong.v1";
83
+
84
+ const DEFAULT_PING_INTERVAL_MS = 75_000; // CF idle timeout is ~100s
85
+ const MIN_PING_INTERVAL_MS = 5_000;
83
86
  const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
84
87
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000;
85
88
  const MAX_PRE_CONNECT_BACKOFF_MS = 4_000;
@@ -91,6 +94,14 @@ const DEFAULT_MAX_CIRCUIT_BREAKER_MS = 10 * 60_000;
91
94
  const DEFAULT_STABLE_CONNECTION_MS = 15_000;
92
95
  const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
93
96
 
97
+ // The relay token is carried in the Sec-WebSocket-Protocol handshake header,
98
+ // not a `?token=` query param, so it stays out of URLs and access logs. We
99
+ // offer the fixed RELAY_SUBPROTOCOL (which the relay echoes on the 101 so the
100
+ // handshake completes) plus the token as `${TOKEN_SUBPROTOCOL_PREFIX}<token>`.
101
+ // Must match worker-pairing/worker.js and worker-pairing/pairing-do.js.
102
+ const RELAY_SUBPROTOCOL = "viveworker.relay.v1";
103
+ const TOKEN_SUBPROTOCOL_PREFIX = "vwtok.";
104
+
94
105
  // CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
95
106
  const CLOSE_NORMAL = 1000;
96
107
  const CLOSE_HANDSHAKE_TIMEOUT = 4001;
@@ -161,6 +172,7 @@ export class RemotePairingTransport {
161
172
  this._onHandshakeComplete = opts.onHandshakeComplete ?? noop;
162
173
 
163
174
  this._pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
175
+ if (Number(this._pingIntervalMs) <= 0) this._pingIntervalMs = 0;
164
176
  this._backoffMs = (opts.backoffMs ?? DEFAULT_BACKOFF_MS).slice();
165
177
  if (this._backoffMs.length === 0) this._backoffMs = [1_000];
166
178
  this._handshakeTimeoutMs = opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
@@ -199,7 +211,7 @@ export class RemotePairingTransport {
199
211
  this._reconnectAttempt = 0;
200
212
  /** @type {ReturnType<typeof setTimeout> | null} */
201
213
  this._reconnectTimer = null;
202
- /** @type {ReturnType<typeof setInterval> | null} */
214
+ /** @type {ReturnType<typeof setTimeout> | null} */
203
215
  this._pingTimer = null;
204
216
  /** @type {ReturnType<typeof setTimeout> | null} */
205
217
  this._handshakeTimer = null;
@@ -221,6 +233,7 @@ export class RemotePairingTransport {
221
233
  // ---- sequencing (monotonic across the transport lifetime) ----
222
234
  this._outboundSeq = 0;
223
235
  this._lastSeenPeerSeq = 0;
236
+ this._lastActivityAtMs = 0;
224
237
  }
225
238
 
226
239
  // -------------------------------------------------------------------------
@@ -354,12 +367,16 @@ export class RemotePairingTransport {
354
367
 
355
368
  const url =
356
369
  `${this._relayUrl}/v1/pairing/${encodeURIComponent(this._pairingId)}` +
357
- `/ws?role=${encodeURIComponent(this._role)}` +
358
- `&token=${encodeURIComponent(this._relayToken)}`;
370
+ `/ws?role=${encodeURIComponent(this._role)}`;
371
+
372
+ // Send the relay token via Sec-WebSocket-Protocol instead of the URL. Both
373
+ // the browser WebSocket and the Node `ws` package take a protocols list as
374
+ // the 2nd arg; the relay echoes only RELAY_SUBPROTOCOL back.
375
+ const subprotocols = [RELAY_SUBPROTOCOL, `${TOKEN_SUBPROTOCOL_PREFIX}${this._relayToken}`];
359
376
 
360
377
  let ws;
361
378
  try {
362
- ws = new this._WebSocketImpl(url);
379
+ ws = new this._WebSocketImpl(url, subprotocols);
363
380
  } catch (err) {
364
381
  this._onError(err);
365
382
  this._setState(STATE.DISCONNECTED, { reason: "open-threw", error: err });
@@ -383,6 +400,7 @@ export class RemotePairingTransport {
383
400
  try { this._ws?.close(CLOSE_NORMAL, "closed during open"); } catch {}
384
401
  return;
385
402
  }
403
+ this._markActivity({ reschedulePing: false });
386
404
  if (this._session) {
387
405
  // We have a live noise session from a previous connection — try to
388
406
  // resume rather than re-handshaking. `lastSeenPeerSeq` tells the relay
@@ -409,6 +427,21 @@ export class RemotePairingTransport {
409
427
  }
410
428
 
411
429
  _handleMessage(evt) {
430
+ if (typeof evt?.data === "string") {
431
+ if (evt.data === TEXT_KEEPALIVE_PONG) {
432
+ this._markActivity();
433
+ return;
434
+ }
435
+ if (evt.data === TEXT_KEEPALIVE_PING) {
436
+ this._markActivity();
437
+ this._wireSend(TEXT_KEEPALIVE_PONG);
438
+ return;
439
+ }
440
+ this._onError(new Error("unexpected text websocket frame"));
441
+ return;
442
+ }
443
+
444
+ this._markActivity();
412
445
  let frame;
413
446
  try {
414
447
  frame = decode(asU8(evt.data));
@@ -708,18 +741,41 @@ export class RemotePairingTransport {
708
741
 
709
742
  _startPing() {
710
743
  this._stopPing();
711
- this._pingTimer = setInterval(() => {
712
- this._sendPing();
713
- }, this._pingIntervalMs);
744
+ if (!this._pingIntervalMs) return;
745
+ if (!this._lastActivityAtMs) this._lastActivityAtMs = Date.now();
746
+ this._schedulePing();
714
747
  }
715
748
 
716
749
  _stopPing() {
717
750
  if (this._pingTimer != null) {
718
- clearInterval(this._pingTimer);
751
+ clearTimeout(this._pingTimer);
719
752
  this._pingTimer = null;
720
753
  }
721
754
  }
722
755
 
756
+ _schedulePing() {
757
+ if (!this._pingIntervalMs || this._closed || !this._ws || this._ws.readyState !== 1) return;
758
+ const interval = Math.max(MIN_PING_INTERVAL_MS, Number(this._pingIntervalMs) || DEFAULT_PING_INTERVAL_MS);
759
+ const idleFor = Date.now() - (this._lastActivityAtMs || Date.now());
760
+ const delay = Math.max(1, interval - idleFor);
761
+ this._pingTimer = setTimeout(() => {
762
+ this._pingTimer = null;
763
+ if (this._closed || !this._ws || this._ws.readyState !== 1) return;
764
+ const nextIdleFor = Date.now() - (this._lastActivityAtMs || Date.now());
765
+ if (nextIdleFor >= interval) {
766
+ this._sendPing();
767
+ }
768
+ this._schedulePing();
769
+ }, delay);
770
+ }
771
+
772
+ _markActivity({ reschedulePing = true } = {}) {
773
+ this._lastActivityAtMs = Date.now();
774
+ if (!reschedulePing || this._pingTimer == null) return;
775
+ this._stopPing();
776
+ this._schedulePing();
777
+ }
778
+
723
779
  // -------------------------------------------------------------------------
724
780
  // Frame sends
725
781
  // -------------------------------------------------------------------------
@@ -739,22 +795,27 @@ export class RemotePairingTransport {
739
795
  }
740
796
 
741
797
  _sendPing() {
742
- this._wireSend(encodePing());
798
+ if (this._wireSend(TEXT_KEEPALIVE_PING, { countAsActivity: false })) {
799
+ this._markActivity({ reschedulePing: false });
800
+ }
743
801
  }
744
802
 
745
803
  _sendResumeReq(lastSeenSeq) {
746
804
  this._wireSend(encodeResumeReq(lastSeenSeq));
747
805
  }
748
806
 
749
- _wireSend(bytes) {
807
+ _wireSend(bytes, { countAsActivity = true } = {}) {
750
808
  if (!this._ws || this._ws.readyState !== 1 /* OPEN */) {
751
809
  this._log.warn?.("send while WS not open — dropping");
752
- return;
810
+ return false;
753
811
  }
754
812
  try {
755
813
  this._ws.send(bytes);
814
+ if (countAsActivity) this._markActivity();
815
+ return true;
756
816
  } catch (err) {
757
817
  this._onError(err);
818
+ return false;
758
819
  }
759
820
  }
760
821