viveworker 0.8.6 → 0.8.7
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 +1 -1
- package/scripts/a2a-cli.mjs +21 -4
- package/scripts/viveworker-bridge.mjs +124 -28
- package/scripts/viveworker.mjs +11 -7
- package/web/app.js +11 -3
- package/web/i18n.js +4 -2
- package/web/remote-pairing/transport.js +15 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
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",
|
package/scripts/a2a-cli.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
377
|
+
execFileSync("open", [target], { stdio: "ignore" });
|
|
361
378
|
} else if (process.platform === "linux") {
|
|
362
|
-
|
|
379
|
+
execFileSync("xdg-open", [target], { stdio: "ignore" });
|
|
363
380
|
} else if (process.platform === "win32") {
|
|
364
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/scripts/viveworker.mjs
CHANGED
|
@@ -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`,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 アドレスを入力してください。",
|
|
@@ -91,6 +91,14 @@ const DEFAULT_MAX_CIRCUIT_BREAKER_MS = 10 * 60_000;
|
|
|
91
91
|
const DEFAULT_STABLE_CONNECTION_MS = 15_000;
|
|
92
92
|
const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
|
|
93
93
|
|
|
94
|
+
// The relay token is carried in the Sec-WebSocket-Protocol handshake header,
|
|
95
|
+
// not a `?token=` query param, so it stays out of URLs and access logs. We
|
|
96
|
+
// offer the fixed RELAY_SUBPROTOCOL (which the relay echoes on the 101 so the
|
|
97
|
+
// handshake completes) plus the token as `${TOKEN_SUBPROTOCOL_PREFIX}<token>`.
|
|
98
|
+
// Must match worker-pairing/worker.js and worker-pairing/pairing-do.js.
|
|
99
|
+
const RELAY_SUBPROTOCOL = "viveworker.relay.v1";
|
|
100
|
+
const TOKEN_SUBPROTOCOL_PREFIX = "vwtok.";
|
|
101
|
+
|
|
94
102
|
// CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
|
|
95
103
|
const CLOSE_NORMAL = 1000;
|
|
96
104
|
const CLOSE_HANDSHAKE_TIMEOUT = 4001;
|
|
@@ -354,12 +362,16 @@ export class RemotePairingTransport {
|
|
|
354
362
|
|
|
355
363
|
const url =
|
|
356
364
|
`${this._relayUrl}/v1/pairing/${encodeURIComponent(this._pairingId)}` +
|
|
357
|
-
`/ws?role=${encodeURIComponent(this._role)}
|
|
358
|
-
|
|
365
|
+
`/ws?role=${encodeURIComponent(this._role)}`;
|
|
366
|
+
|
|
367
|
+
// Send the relay token via Sec-WebSocket-Protocol instead of the URL. Both
|
|
368
|
+
// the browser WebSocket and the Node `ws` package take a protocols list as
|
|
369
|
+
// the 2nd arg; the relay echoes only RELAY_SUBPROTOCOL back.
|
|
370
|
+
const subprotocols = [RELAY_SUBPROTOCOL, `${TOKEN_SUBPROTOCOL_PREFIX}${this._relayToken}`];
|
|
359
371
|
|
|
360
372
|
let ws;
|
|
361
373
|
try {
|
|
362
|
-
ws = new this._WebSocketImpl(url);
|
|
374
|
+
ws = new this._WebSocketImpl(url, subprotocols);
|
|
363
375
|
} catch (err) {
|
|
364
376
|
this._onError(err);
|
|
365
377
|
this._setState(STATE.DISCONNECTED, { reason: "open-threw", error: err });
|