beachviber 1.0.34
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.
Potentially problematic release.
This version of beachviber might be problematic. Click here for more details.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/api.js +28 -0
- package/dist/approval-hook.mjs +193 -0
- package/dist/approval-server.js +186 -0
- package/dist/config.js +60 -0
- package/dist/connection-machine.js +222 -0
- package/dist/connection.js +198 -0
- package/dist/crypto.js +101 -0
- package/dist/hook-installer.js +60 -0
- package/dist/image-download.js +142 -0
- package/dist/index.js +208 -0
- package/dist/logger.js +28 -0
- package/dist/message-handler.js +661 -0
- package/dist/pairing.js +292 -0
- package/dist/projects.js +156 -0
- package/dist/secret-store.js +245 -0
- package/dist/sessions.js +406 -0
- package/dist/state.js +82 -0
- package/dist/transcripts.js +312 -0
- package/package.json +57 -0
- package/scripts/postinstall.mjs +15 -0
- package/scripts/preuninstall.mjs +20 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { resolve as dnsResolve } from "dns/promises";
|
|
6
|
+
const IMAGE_DIR = join(tmpdir(), "beachviber-images");
|
|
7
|
+
const ALLOWED_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "heic"]);
|
|
8
|
+
const ALLOWED_CONTENT_TYPES = new Set([
|
|
9
|
+
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
10
|
+
"image/svg+xml", "image/bmp", "image/heic",
|
|
11
|
+
]);
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
if (!existsSync(IMAGE_DIR)) {
|
|
14
|
+
mkdirSync(IMAGE_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function extFromUrl(url) {
|
|
18
|
+
try {
|
|
19
|
+
const pathname = new URL(url).pathname;
|
|
20
|
+
const match = pathname.match(/\.(\w+)(?:\?|$)/);
|
|
21
|
+
if (match && ALLOWED_EXTENSIONS.has(match[1].toLowerCase()))
|
|
22
|
+
return match[1].toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
return "png";
|
|
26
|
+
}
|
|
27
|
+
/** Returns true if an IP address is in a private/reserved range (SSRF targets) */
|
|
28
|
+
function isPrivateIP(ip) {
|
|
29
|
+
// IPv4 private/reserved ranges
|
|
30
|
+
if (/^127\./.test(ip))
|
|
31
|
+
return true; // loopback
|
|
32
|
+
if (/^10\./.test(ip))
|
|
33
|
+
return true; // RFC 1918
|
|
34
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip))
|
|
35
|
+
return true; // RFC 1918
|
|
36
|
+
if (/^192\.168\./.test(ip))
|
|
37
|
+
return true; // RFC 1918
|
|
38
|
+
if (/^169\.254\./.test(ip))
|
|
39
|
+
return true; // link-local
|
|
40
|
+
if (/^0\./.test(ip))
|
|
41
|
+
return true; // current network
|
|
42
|
+
if (ip === "255.255.255.255")
|
|
43
|
+
return true; // broadcast
|
|
44
|
+
// IPv6 private/reserved
|
|
45
|
+
if (ip === "::1")
|
|
46
|
+
return true; // loopback
|
|
47
|
+
if (/^fe80:/i.test(ip))
|
|
48
|
+
return true; // link-local
|
|
49
|
+
if (/^f[cd]/i.test(ip))
|
|
50
|
+
return true; // unique local (fc00::/7)
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const MAX_IMAGE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
54
|
+
const FETCH_TIMEOUT_MS = 30_000; // 30 seconds
|
|
55
|
+
export async function downloadImage(url) {
|
|
56
|
+
// Validate URL scheme — HTTPS only
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
if (parsed.protocol !== "https:") {
|
|
59
|
+
throw new Error(`Image download blocked: only HTTPS URLs are allowed (got ${parsed.protocol})`);
|
|
60
|
+
}
|
|
61
|
+
// Resolve hostname and block private/reserved IPs to prevent SSRF.
|
|
62
|
+
// Pin the resolved IP to prevent DNS rebinding between validation and fetch.
|
|
63
|
+
const hostname = parsed.hostname;
|
|
64
|
+
let resolvedIP;
|
|
65
|
+
try {
|
|
66
|
+
const addresses = await dnsResolve(hostname);
|
|
67
|
+
for (const addr of addresses) {
|
|
68
|
+
if (isPrivateIP(addr)) {
|
|
69
|
+
throw new Error(`Image download blocked: hostname "${hostname}" resolves to private IP ${addr}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
resolvedIP = addresses[0];
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof Error && err.message.startsWith("Image download blocked"))
|
|
76
|
+
throw err;
|
|
77
|
+
throw new Error(`Image download blocked: could not resolve hostname "${hostname}"`);
|
|
78
|
+
}
|
|
79
|
+
ensureDir();
|
|
80
|
+
const ext = extFromUrl(url);
|
|
81
|
+
const filename = `${randomUUID()}.${ext}`;
|
|
82
|
+
const filepath = join(IMAGE_DIR, filename);
|
|
83
|
+
// Fetch using the pinned IP to prevent DNS rebinding.
|
|
84
|
+
// Replace hostname with the resolved IP and set Host header for TLS/virtual hosting.
|
|
85
|
+
const pinnedUrl = new URL(url);
|
|
86
|
+
pinnedUrl.hostname = resolvedIP;
|
|
87
|
+
const response = await fetch(pinnedUrl.toString(), {
|
|
88
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
89
|
+
headers: { Host: hostname },
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
|
|
93
|
+
}
|
|
94
|
+
// Validate Content-Type is an image
|
|
95
|
+
const contentType = (response.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
|
|
96
|
+
if (contentType && !ALLOWED_CONTENT_TYPES.has(contentType)) {
|
|
97
|
+
throw new Error(`Image download blocked: unexpected Content-Type "${contentType}"`);
|
|
98
|
+
}
|
|
99
|
+
// Reject if Content-Length exceeds limit (when provided by server)
|
|
100
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
101
|
+
if (contentLength > MAX_IMAGE_BYTES) {
|
|
102
|
+
throw new Error(`Image too large: ${contentLength} bytes (max ${MAX_IMAGE_BYTES})`);
|
|
103
|
+
}
|
|
104
|
+
// Stream the body with a size cap in case Content-Length is missing or wrong
|
|
105
|
+
const chunks = [];
|
|
106
|
+
let totalBytes = 0;
|
|
107
|
+
const reader = response.body?.getReader();
|
|
108
|
+
if (!reader)
|
|
109
|
+
throw new Error("No response body");
|
|
110
|
+
while (true) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done)
|
|
113
|
+
break;
|
|
114
|
+
totalBytes += value.byteLength;
|
|
115
|
+
if (totalBytes > MAX_IMAGE_BYTES) {
|
|
116
|
+
reader.cancel();
|
|
117
|
+
throw new Error(`Image too large: exceeded ${MAX_IMAGE_BYTES} bytes during download`);
|
|
118
|
+
}
|
|
119
|
+
chunks.push(Buffer.from(value));
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(filepath, Buffer.concat(chunks));
|
|
122
|
+
return filepath;
|
|
123
|
+
}
|
|
124
|
+
export function cleanupOldImages() {
|
|
125
|
+
if (!existsSync(IMAGE_DIR))
|
|
126
|
+
return;
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
129
|
+
try {
|
|
130
|
+
for (const file of readdirSync(IMAGE_DIR)) {
|
|
131
|
+
const filepath = join(IMAGE_DIR, file);
|
|
132
|
+
try {
|
|
133
|
+
const stat = statSync(filepath);
|
|
134
|
+
if (now - stat.mtimeMs > ONE_HOUR) {
|
|
135
|
+
unlinkSync(filepath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { state, VERSION, RELAY_URL, setRelayUrl } from "./state.js";
|
|
5
|
+
import { connect, send } from "./connection.js";
|
|
6
|
+
import { enterSetupMode } from "./pairing.js";
|
|
7
|
+
import { discoverProjects } from "./projects.js";
|
|
8
|
+
import { loadConfig, saveConfig, deleteConfig, generateDeviceId, setProfile, getProfile, getProfileSuffix, DEFAULT_RELAY_URL, DEFAULT_API_URL, } from "./config.js";
|
|
9
|
+
import { setApiUrl } from "./api.js";
|
|
10
|
+
import { generateKeyPair, computeSharedSecret, toBase64, fromBase64, loadKeys, saveKeys, importPublicKeyRaw, exportPublicKeyRaw, } from "./crypto.js";
|
|
11
|
+
import { initSessionManager } from "./sessions.js";
|
|
12
|
+
import { startApprovalServer, getSocketPath, getAuthToken } from "./approval-server.js";
|
|
13
|
+
import { installHook } from "./hook-installer.js";
|
|
14
|
+
import { cleanupOldImages } from "./image-download.js";
|
|
15
|
+
import { getActiveBackendName } from "./secret-store.js";
|
|
16
|
+
// Parse --profile before any config loading
|
|
17
|
+
const profileArgs = process.argv.slice(2);
|
|
18
|
+
const profileIdx = profileArgs.indexOf("--profile");
|
|
19
|
+
if (profileIdx !== -1) {
|
|
20
|
+
const profileName = profileArgs[profileIdx + 1];
|
|
21
|
+
if (!profileName || profileName.startsWith("--")) {
|
|
22
|
+
console.error("Error: --profile requires a name argument");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
setProfile(profileName);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
console.error(`Error: ${err.message}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Handle --help early, before any heavy initialization
|
|
34
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
35
|
+
console.log(`
|
|
36
|
+
${chalk.bold("BeachViber Desktop")} v${VERSION}
|
|
37
|
+
|
|
38
|
+
${chalk.bold("Usage:")} beachviber [options]
|
|
39
|
+
|
|
40
|
+
${chalk.bold("Options:")}
|
|
41
|
+
--profile <name> Use a named profile
|
|
42
|
+
--reset Delete config and keys, then exit
|
|
43
|
+
-v, --version Show version
|
|
44
|
+
-h, --help Show this help
|
|
45
|
+
`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
// Handle --version early, before any heavy initialization
|
|
49
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
50
|
+
console.log(VERSION);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
// Handle --reset: delete config (and keychain secret) for the active profile, then exit
|
|
54
|
+
if (process.argv.includes("--reset")) {
|
|
55
|
+
const profile = getProfile();
|
|
56
|
+
const label = profile ? `profile "${profile}"` : "default profile";
|
|
57
|
+
const account = `default${getProfileSuffix()}`;
|
|
58
|
+
// Remove secret key from OS keychain
|
|
59
|
+
try {
|
|
60
|
+
const { removeSecret } = await import("./secret-store.js");
|
|
61
|
+
removeSecret(account);
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore if not present */ }
|
|
64
|
+
// Remove config file
|
|
65
|
+
const deleted = deleteConfig();
|
|
66
|
+
if (deleted) {
|
|
67
|
+
console.log(chalk.green(` Settings reset for ${label}. Config file and keys removed.`));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.log(chalk.yellow(` No config found for ${label}. Nothing to reset.`));
|
|
71
|
+
}
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
// Wire up late-bound callback to break circular dependency
|
|
75
|
+
state.enterSetupMode = () => enterSetupMode();
|
|
76
|
+
function initCrypto() {
|
|
77
|
+
const stored = loadKeys();
|
|
78
|
+
if (stored) {
|
|
79
|
+
try {
|
|
80
|
+
const secretKey = crypto.createPrivateKey({
|
|
81
|
+
format: 'der',
|
|
82
|
+
type: 'pkcs8',
|
|
83
|
+
key: fromBase64(stored.secretKey),
|
|
84
|
+
});
|
|
85
|
+
const publicKey = importPublicKeyRaw(fromBase64(stored.publicKey));
|
|
86
|
+
state.ownKeyPair = { publicKey, secretKey };
|
|
87
|
+
if (stored.peerPublicKey) {
|
|
88
|
+
state.peerPublicKey = importPublicKeyRaw(fromBase64(stored.peerPublicKey));
|
|
89
|
+
state.sharedSecret = computeSharedSecret(state.peerPublicKey, state.ownKeyPair.secretKey);
|
|
90
|
+
const keyFp = crypto.createHash("sha256").update(state.sharedSecret).digest("hex").slice(0, 12);
|
|
91
|
+
console.log(chalk.dim(` Key ${keyFp} (restored)`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Stored keys are in the old format (pre-v3 raw Curve25519) — regenerate
|
|
96
|
+
state.ownKeyPair = generateKeyPair();
|
|
97
|
+
saveKeys({
|
|
98
|
+
publicKey: toBase64(exportPublicKeyRaw(state.ownKeyPair.publicKey)),
|
|
99
|
+
secretKey: toBase64(state.ownKeyPair.secretKey.export({ format: 'der', type: 'pkcs8' })),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
state.ownKeyPair = generateKeyPair();
|
|
105
|
+
saveKeys({
|
|
106
|
+
publicKey: toBase64(exportPublicKeyRaw(state.ownKeyPair.publicKey)),
|
|
107
|
+
secretKey: toBase64(state.ownKeyPair.secretKey.export({ format: 'der', type: 'pkcs8' })),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// --- Start ---
|
|
112
|
+
(async () => {
|
|
113
|
+
// Clean up any stale downloaded images from previous runs
|
|
114
|
+
cleanupOldImages();
|
|
115
|
+
// Discover projects from ~/.claude/projects/
|
|
116
|
+
state.projects = await discoverProjects();
|
|
117
|
+
// Load or create global config
|
|
118
|
+
let config = loadConfig();
|
|
119
|
+
if (!config) {
|
|
120
|
+
state.deviceId = generateDeviceId();
|
|
121
|
+
config = {
|
|
122
|
+
deviceId: state.deviceId,
|
|
123
|
+
relayUrl: DEFAULT_RELAY_URL,
|
|
124
|
+
apiUrl: DEFAULT_API_URL,
|
|
125
|
+
};
|
|
126
|
+
saveConfig(config);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
state.deviceId = config.deviceId;
|
|
130
|
+
// Backfill missing fields into existing configs
|
|
131
|
+
let updated = false;
|
|
132
|
+
if (!config.relayUrl) {
|
|
133
|
+
config.relayUrl = DEFAULT_RELAY_URL;
|
|
134
|
+
updated = true;
|
|
135
|
+
}
|
|
136
|
+
if (!config.apiUrl) {
|
|
137
|
+
config.apiUrl = DEFAULT_API_URL;
|
|
138
|
+
updated = true;
|
|
139
|
+
}
|
|
140
|
+
if (updated)
|
|
141
|
+
saveConfig(config);
|
|
142
|
+
}
|
|
143
|
+
// Set URLs — env vars take precedence over config
|
|
144
|
+
setRelayUrl(process.env.RELAY_URL || config?.relayUrl || DEFAULT_RELAY_URL);
|
|
145
|
+
setApiUrl(process.env.API_URL || config?.apiUrl || DEFAULT_API_URL);
|
|
146
|
+
// Load the device token
|
|
147
|
+
// NOTE: BV_DEVICE_TOKEN env var is a dev convenience — may leak via shell history or process listings.
|
|
148
|
+
// Consider removing in favour of config-file-only loading for production use.
|
|
149
|
+
state.deviceToken = process.env.BV_DEVICE_TOKEN || config?.deviceToken || "";
|
|
150
|
+
// Initialize crypto
|
|
151
|
+
initCrypto();
|
|
152
|
+
// Determine saved pairing state
|
|
153
|
+
const hasSavedState = !!(state.deviceToken && state.sharedSecret && state.peerPublicKey);
|
|
154
|
+
// Print startup banner
|
|
155
|
+
console.log();
|
|
156
|
+
console.log(chalk.bold(" BeachViber Agent") + chalk.dim(` v${VERSION}`));
|
|
157
|
+
if (getProfile()) {
|
|
158
|
+
console.log(chalk.dim(" Profile ") + chalk.bold(getProfile()));
|
|
159
|
+
}
|
|
160
|
+
console.log();
|
|
161
|
+
const totalSessions = state.projects.reduce((sum, p) => sum + p.sessionCount, 0);
|
|
162
|
+
console.log(chalk.dim(" Projects ") + chalk.bold(String(state.projects.length)) + chalk.dim(` (${totalSessions} sessions)`));
|
|
163
|
+
for (const p of state.projects) {
|
|
164
|
+
const branch = p.git.branch !== "unknown" ? chalk.dim(` (${p.git.branch})`) : "";
|
|
165
|
+
const sessions = chalk.dim(` — ${p.sessionCount} sessions`);
|
|
166
|
+
console.log(chalk.dim(" ") + p.name + branch + sessions);
|
|
167
|
+
}
|
|
168
|
+
console.log(chalk.dim(" Relay ") + RELAY_URL);
|
|
169
|
+
const secretBackend = getActiveBackendName();
|
|
170
|
+
console.log(chalk.dim(" Secrets ") + (secretBackend ? chalk.green(secretBackend) : chalk.yellow("plaintext (no OS store available)")));
|
|
171
|
+
if (process.env.RELAY_URL) {
|
|
172
|
+
console.log(chalk.red(" WARNING ") + chalk.red("Using non-default relay URL from RELAY_URL env var"));
|
|
173
|
+
}
|
|
174
|
+
if (process.env.API_URL) {
|
|
175
|
+
console.log(chalk.red(" WARNING ") + chalk.red("Using non-default API URL from API_URL env var"));
|
|
176
|
+
}
|
|
177
|
+
if (hasSavedState) {
|
|
178
|
+
console.log(chalk.dim(" State ") + chalk.green("Saved pairing found — reconnecting..."));
|
|
179
|
+
}
|
|
180
|
+
else if (state.deviceToken) {
|
|
181
|
+
console.log(chalk.dim(" State ") + chalk.yellow("Incomplete pairing — re-entering setup"));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.log(chalk.dim(" State ") + chalk.yellow("New — pairing required"));
|
|
185
|
+
}
|
|
186
|
+
console.log();
|
|
187
|
+
// Initialize session manager and approval server
|
|
188
|
+
const sessionManager = initSessionManager();
|
|
189
|
+
startApprovalServer(sessionManager, send);
|
|
190
|
+
sessionManager.socketPath = getSocketPath();
|
|
191
|
+
sessionManager.approvalToken = getAuthToken();
|
|
192
|
+
installHook();
|
|
193
|
+
// If we have a complete saved state (token + encryption keys), reconnect directly.
|
|
194
|
+
// If the token exists but keys are incomplete, treat as needing re-pair.
|
|
195
|
+
// If no token at all, enter setup mode.
|
|
196
|
+
if (hasSavedState) {
|
|
197
|
+
console.log(chalk.green(" Reconnecting with saved pairing...") + " " + chalk.green("[encrypted]"));
|
|
198
|
+
console.log();
|
|
199
|
+
connect();
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// Clear any partial state and start fresh
|
|
203
|
+
enterSetupMode();
|
|
204
|
+
}
|
|
205
|
+
})().catch((err) => {
|
|
206
|
+
console.error(chalk.red(` Fatal error: ${err}`));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
});
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight logger with consistent prefix formatting.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const log = createLogger("sessions");
|
|
6
|
+
* log.info("Created session abc"); // → [sessions] Created session abc
|
|
7
|
+
* log.warn("Disk full"); // → [sessions] Disk full (stderr)
|
|
8
|
+
* log.error("Crash", err); // → [sessions] Crash ... (stderr)
|
|
9
|
+
*/
|
|
10
|
+
export function createLogger(prefix) {
|
|
11
|
+
const tag = `[${prefix}]`;
|
|
12
|
+
return {
|
|
13
|
+
info(msg) {
|
|
14
|
+
console.log(`${tag} ${msg}`);
|
|
15
|
+
},
|
|
16
|
+
warn(msg) {
|
|
17
|
+
console.warn(`${tag} ${msg}`);
|
|
18
|
+
},
|
|
19
|
+
error(msg, err) {
|
|
20
|
+
if (err !== undefined) {
|
|
21
|
+
console.error(`${tag} ${msg}`, err);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
console.error(`${tag} ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|