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.
- 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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.10
|
|
4
|
+
|
|
5
|
+
Initial open-source release.
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **Remote Claude Code control** — send prompts and monitor sessions from your phone
|
|
10
|
+
- **End-to-end encryption** — NaCl public-key cryptography (Curve25519 + XSalsa20-Poly1305) between phone and desktop
|
|
11
|
+
- **QR code pairing** — scan to pair, verify with a code, reconnect automatically
|
|
12
|
+
- **Tool approval** — approve or deny Claude's file writes and shell commands from your phone
|
|
13
|
+
- **Multi-project support** — scan a directory tree for projects with `.git/` or `.claude/`
|
|
14
|
+
- **Session management** — create, resume, and browse Claude Code sessions remotely
|
|
15
|
+
- **Session history** — read Claude CLI transcripts with tool use details
|
|
16
|
+
- **Image attachments** — attach images from your phone to prompts
|
|
17
|
+
- **Auto-reconnect** — exponential backoff with automatic re-pairing on token revocation
|
|
18
|
+
- **Fail-closed security** — tool use denied if approval socket is unreachable or phone doesn't respond
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Krokosz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# BeachViber
|
|
2
|
+
|
|
3
|
+
Control [Claude Code](https://docs.anthropic.com/en/docs/claude-code) from your phone. Send prompts, approve tools, and monitor sessions remotely through an end-to-end encrypted connection.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Phone App ←—(encrypted)—→ Relay Server ←—(encrypted)—→ Computer Agent ←—→ Claude Code
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g beachviber
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js 20+ and [Claude Code](https://docs.anthropic.com/en/docs/claude-code) on your PATH.
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
beachviber
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On first run:
|
|
24
|
+
|
|
25
|
+
1. A QR code appears — scan it with the [BeachViber App](https://app.beachviber.com)
|
|
26
|
+
3. In your terminal, enter the verification code shown on your BeachViber App
|
|
27
|
+
4. Done — you're paired and encrypted end-to-end
|
|
28
|
+
|
|
29
|
+
On subsequent runs, the agent reconnects automatically.
|
|
30
|
+
|
|
31
|
+
## What You Can Do
|
|
32
|
+
|
|
33
|
+
From your phone:
|
|
34
|
+
|
|
35
|
+
- Browse projects on your machine
|
|
36
|
+
- Start Claude Code sessions and send prompts
|
|
37
|
+
- Approve or deny tool use (file writes, shell commands, etc.)
|
|
38
|
+
- View session history and transcripts
|
|
39
|
+
- Manage multiple desktops from one phone
|
|
40
|
+
|
|
41
|
+
## Security
|
|
42
|
+
|
|
43
|
+
**End-to-end encrypted.** All sensitive messages (prompts, responses, tool approvals) are encrypted with X25519 + AES-256-GCM. The relay server cannot read message contents.
|
|
44
|
+
|
|
45
|
+
**Keys stay on your machine.** Private keys are stored in the OS keychain (macOS Keychain, Linux Secret Service, or Windows DPAPI fallback) as PKCS8 DER — never in plaintext config files.
|
|
46
|
+
|
|
47
|
+
**Tool approval.** A `PreToolUse` hook intercepts Claude Code tool calls. Tools already in your Claude allow list are auto-approved. Everything else is sent to your phone for approval. If the agent isn't running, the hook is a no-op.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
The agent stores config in `~/.beachviber/`.
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
.beachviber/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Uninstall
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm uninstall -g beachviber
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This removes the `PreToolUse` hook from `~/.claude/settings.json` automatically.
|
|
64
|
+
|
|
65
|
+
## Protocol
|
|
66
|
+
|
|
67
|
+
The agent communicates over WebSocket using JSON messages. Each message has a `type`, optional `sessionId`, `timestamp`, and `payload`. When paired, the `payload` is replaced with an `encrypted` field containing an AES-256-GCM envelope.
|
|
68
|
+
|
|
69
|
+
| Type | Direction | Description |
|
|
70
|
+
|------|-----------|-------------|
|
|
71
|
+
| `register` | Desktop → Relay | Register with device token and public key |
|
|
72
|
+
| `registered` | Relay → Desktop | Registration confirmed |
|
|
73
|
+
| `projects_request` | Phone → Desktop | List available projects |
|
|
74
|
+
| `projects_response` | Desktop → Phone | Project list with git info |
|
|
75
|
+
| `session_create` | Phone → Desktop | Start a Claude Code session |
|
|
76
|
+
| `session_created` | Desktop → Phone | Session started confirmation |
|
|
77
|
+
| `session_end` | Phone → Desktop | End a session |
|
|
78
|
+
| `prompt` | Phone → Desktop | Send a prompt to Claude |
|
|
79
|
+
| `stream_start` | Desktop → Phone | Claude started responding |
|
|
80
|
+
| `stream_delta` | Desktop → Phone | Streaming text/tool-use chunk |
|
|
81
|
+
| `stream_end` | Desktop → Phone | Claude finished responding |
|
|
82
|
+
| `tool_approval_request` | Desktop → Phone | Tool needs approval |
|
|
83
|
+
| `tool_approval_response` | Phone → Desktop | Approve/deny tool use |
|
|
84
|
+
| `sessions_request` | Phone → Desktop | List all sessions |
|
|
85
|
+
| `sessions_response` | Desktop → Phone | Session list with metadata |
|
|
86
|
+
| `session_history_request` | Phone → Desktop | Get session transcript |
|
|
87
|
+
| `session_history_response` | Desktop → Phone | Transcript messages |
|
|
88
|
+
| `verify_code` | Desktop → Phone | Pairing verification code |
|
|
89
|
+
| `verify_code_ack` | Phone → Desktop | Verification result + public key |
|
|
90
|
+
| `heartbeat` | Desktop → Relay | Keep-alive |
|
|
91
|
+
|
|
92
|
+
## Learn More
|
|
93
|
+
https://www.beachviber.com
|
|
94
|
+
|
|
95
|
+
## Contributing
|
|
96
|
+
|
|
97
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure, and guidelines.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DEFAULT_API_URL } from "./config.js";
|
|
2
|
+
// API_URL is set after config is loaded; env var takes precedence
|
|
3
|
+
let API_URL = DEFAULT_API_URL;
|
|
4
|
+
export function setApiUrl(url) {
|
|
5
|
+
API_URL = url;
|
|
6
|
+
}
|
|
7
|
+
export function getApiUrl() {
|
|
8
|
+
return API_URL;
|
|
9
|
+
}
|
|
10
|
+
export async function requestPairing(publicKey, deviceId) {
|
|
11
|
+
const res = await fetch(`${API_URL}/v1/pairing/request`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ publicKey, deviceId }),
|
|
15
|
+
signal: AbortSignal.timeout(15_000),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
throw new Error(`Pairing request failed: ${res.status} ${res.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
return res.json();
|
|
21
|
+
}
|
|
22
|
+
export async function pollPairingStatus(code, token) {
|
|
23
|
+
const res = await fetch(`${API_URL}/v1/pairing/status?code=${encodeURIComponent(code)}&token=${encodeURIComponent(token)}`, { signal: AbortSignal.timeout(10_000) });
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`Pairing status check failed: ${res.status} ${res.statusText}`);
|
|
26
|
+
}
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook — asks phone for approval via desktop agent's IPC channel
|
|
3
|
+
// (Unix domain socket on macOS/Linux, named pipe on Windows)
|
|
4
|
+
// Respects Claude's own permission settings: tools the user has already
|
|
5
|
+
// allowed in their Claude settings are auto-approved without going to the phone.
|
|
6
|
+
|
|
7
|
+
import net from "net";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
|
|
12
|
+
const SOCKET_PATH = process.env.BEACHVIBER_SOCKET_PATH;
|
|
13
|
+
const SESSION_ID = process.env.BEACHVIBER_SESSION_ID;
|
|
14
|
+
const AUTH_TOKEN = process.env.BEACHVIBER_APPROVAL_TOKEN;
|
|
15
|
+
|
|
16
|
+
// hookEventName is REQUIRED for Claude Code to recognize the output
|
|
17
|
+
const ALLOW = JSON.stringify({
|
|
18
|
+
hookSpecificOutput: {
|
|
19
|
+
hookEventName: "PreToolUse",
|
|
20
|
+
permissionDecision: "allow",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// If not running in BeachViber context, let Claude handle permissions normally
|
|
25
|
+
if (!SOCKET_PATH || !SESSION_ID || !AUTH_TOKEN) {
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Load Claude's permission settings from all config layers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
function readJsonSafe(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadPermissions(cwd) {
|
|
41
|
+
const allow = [];
|
|
42
|
+
const deny = [];
|
|
43
|
+
|
|
44
|
+
// User-level settings (lowest precedence for allow, but we collect all)
|
|
45
|
+
const userSettings = readJsonSafe(path.join(homedir(), ".claude", "settings.json"));
|
|
46
|
+
if (userSettings?.permissions?.allow) allow.push(...userSettings.permissions.allow);
|
|
47
|
+
if (userSettings?.permissions?.deny) deny.push(...userSettings.permissions.deny);
|
|
48
|
+
|
|
49
|
+
// Project-level settings
|
|
50
|
+
const projectSettings = readJsonSafe(path.join(cwd, ".claude", "settings.json"));
|
|
51
|
+
if (projectSettings?.permissions?.allow) allow.push(...projectSettings.permissions.allow);
|
|
52
|
+
if (projectSettings?.permissions?.deny) deny.push(...projectSettings.permissions.deny);
|
|
53
|
+
|
|
54
|
+
// Local project settings (gitignored, personal overrides)
|
|
55
|
+
const localSettings = readJsonSafe(path.join(cwd, ".claude", "settings.local.json"));
|
|
56
|
+
if (localSettings?.permissions?.allow) allow.push(...localSettings.permissions.allow);
|
|
57
|
+
if (localSettings?.permissions?.deny) deny.push(...localSettings.permissions.deny);
|
|
58
|
+
|
|
59
|
+
return { allow, deny };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Pattern matching — mirrors Claude's permission pattern format
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a tool call matches a permission pattern.
|
|
68
|
+
*
|
|
69
|
+
* Pattern formats:
|
|
70
|
+
* "Read" → matches all Read tool uses
|
|
71
|
+
* "Bash(npm test *)" → matches Bash where command starts with "npm test "
|
|
72
|
+
* "Bash(*)" → matches all Bash commands
|
|
73
|
+
* "Edit(/src/**)" → matches Edit on paths under /src/
|
|
74
|
+
*/
|
|
75
|
+
function matchesPattern(pattern, toolName, toolInput) {
|
|
76
|
+
// Parse "ToolName(arg pattern)" or just "ToolName"
|
|
77
|
+
const parenIdx = pattern.indexOf("(");
|
|
78
|
+
if (parenIdx === -1) {
|
|
79
|
+
// Bare tool name — matches all uses of this tool
|
|
80
|
+
return pattern === toolName;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const patternTool = pattern.slice(0, parenIdx);
|
|
84
|
+
if (patternTool !== toolName) return false;
|
|
85
|
+
|
|
86
|
+
// Extract the argument pattern inside parentheses
|
|
87
|
+
const argPattern = pattern.slice(parenIdx + 1, -1); // strip ( and )
|
|
88
|
+
|
|
89
|
+
// Determine the relevant input value to match against
|
|
90
|
+
let inputValue = "";
|
|
91
|
+
if (toolName === "Bash" && toolInput?.command) {
|
|
92
|
+
inputValue = toolInput.command;
|
|
93
|
+
} else if (toolInput?.file_path) {
|
|
94
|
+
inputValue = toolInput.file_path;
|
|
95
|
+
} else if (toolInput?.pattern) {
|
|
96
|
+
inputValue = toolInput.pattern;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return globMatch(argPattern, inputValue);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Simple glob matching: * matches any sequence of characters within a segment.
|
|
104
|
+
* Handles patterns like "npm test *", "git *", "*.ts", etc.
|
|
105
|
+
*/
|
|
106
|
+
function globMatch(pattern, str) {
|
|
107
|
+
// Convert glob to regex: escape regex chars, then replace * with .*
|
|
108
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
109
|
+
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
110
|
+
try {
|
|
111
|
+
return new RegExp(regexStr).test(str);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isAllowedBySettings(toolName, toolInput, permissions) {
|
|
118
|
+
// Deny takes precedence — if explicitly denied, do NOT auto-allow
|
|
119
|
+
for (const pattern of permissions.deny) {
|
|
120
|
+
if (matchesPattern(pattern, toolName, toolInput)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check allow list
|
|
126
|
+
for (const pattern of permissions.allow) {
|
|
127
|
+
if (matchesPattern(pattern, toolName, toolInput)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Main hook logic
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// Read JSON from stdin
|
|
140
|
+
const chunks = [];
|
|
141
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
142
|
+
const input = JSON.parse(Buffer.concat(chunks).toString());
|
|
143
|
+
|
|
144
|
+
// Load Claude's permission settings
|
|
145
|
+
const cwd = input.cwd || process.cwd();
|
|
146
|
+
const permissions = loadPermissions(cwd);
|
|
147
|
+
|
|
148
|
+
// If Claude's settings already allow this tool, auto-approve
|
|
149
|
+
if (isAllowedBySettings(input.tool_name, input.tool_input, permissions)) {
|
|
150
|
+
process.stdout.write(ALLOW);
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Connect to desktop agent via IPC (Unix socket or named pipe) for phone approval
|
|
155
|
+
try {
|
|
156
|
+
const response = await new Promise((resolve, reject) => {
|
|
157
|
+
const client = net.createConnection(SOCKET_PATH, () => {
|
|
158
|
+
client.write(JSON.stringify({
|
|
159
|
+
token: AUTH_TOKEN,
|
|
160
|
+
sessionId: SESSION_ID,
|
|
161
|
+
tool: input.tool_name,
|
|
162
|
+
toolInput: input.tool_input,
|
|
163
|
+
}));
|
|
164
|
+
client.end(); // half-close: done writing, still reading
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
let data = "";
|
|
168
|
+
client.on("data", (chunk) => (data += chunk));
|
|
169
|
+
client.on("end", () => {
|
|
170
|
+
try { resolve(JSON.parse(data)); }
|
|
171
|
+
catch { reject(new Error("bad response")); }
|
|
172
|
+
});
|
|
173
|
+
client.on("error", reject);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (response.approved) {
|
|
177
|
+
// Explicitly grant permission
|
|
178
|
+
process.stdout.write(ALLOW);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Denied by phone user
|
|
183
|
+
process.stdout.write(JSON.stringify({
|
|
184
|
+
hookSpecificOutput: {
|
|
185
|
+
hookEventName: "PreToolUse",
|
|
186
|
+
permissionDecision: "deny",
|
|
187
|
+
permissionDecisionReason: response.reason || "Denied by phone user",
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Desktop agent not reachable — fall through to Claude's normal permissions
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { getBeachViberDir, getProfileSuffix } from "./config.js";
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
const log = createLogger("approval");
|
|
8
|
+
const APPROVAL_TIMEOUT_MS = 300_000; // 5 minutes
|
|
9
|
+
const MAX_REQUEST_BYTES = 64 * 1024; // 64 KB
|
|
10
|
+
const CONN_TIMEOUT_MS = 30_000; // 30 seconds
|
|
11
|
+
const MAX_CONCURRENT_CONNS = 10;
|
|
12
|
+
const isWin = process.platform === "win32";
|
|
13
|
+
// Random token generated per desktop agent session — only processes
|
|
14
|
+
// spawned by the desktop agent (Claude CLI → hook) know this token
|
|
15
|
+
const AUTH_TOKEN = crypto.randomBytes(32).toString("hex");
|
|
16
|
+
let socketPath = "";
|
|
17
|
+
export function getSocketPath() {
|
|
18
|
+
return socketPath;
|
|
19
|
+
}
|
|
20
|
+
export function getAuthToken() {
|
|
21
|
+
return AUTH_TOKEN;
|
|
22
|
+
}
|
|
23
|
+
/** Build the IPC endpoint path — named pipe on Windows, Unix socket elsewhere */
|
|
24
|
+
function buildIpcPath() {
|
|
25
|
+
const suffix = getProfileSuffix();
|
|
26
|
+
if (isWin) {
|
|
27
|
+
// Named pipes are globally namespaced; include a hash of the user's
|
|
28
|
+
// BeachViber directory to avoid collisions between users on the same machine
|
|
29
|
+
const id = crypto
|
|
30
|
+
.createHash("sha256")
|
|
31
|
+
.update(getBeachViberDir())
|
|
32
|
+
.digest("hex")
|
|
33
|
+
.slice(0, 8);
|
|
34
|
+
return `\\\\.\\pipe\\beachviber-approval-${id}${suffix}`;
|
|
35
|
+
}
|
|
36
|
+
return path.join(getBeachViberDir(), `approval${suffix}.sock`);
|
|
37
|
+
}
|
|
38
|
+
export function startApprovalServer(sessionMgr, sendFn) {
|
|
39
|
+
socketPath = buildIpcPath();
|
|
40
|
+
if (!isWin) {
|
|
41
|
+
const bvDir = getBeachViberDir();
|
|
42
|
+
// Ensure directory exists with owner-only permissions
|
|
43
|
+
fs.mkdirSync(bvDir, { recursive: true, mode: 0o700 });
|
|
44
|
+
// Clean up stale socket from a previous crash
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(socketPath);
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
}
|
|
50
|
+
const activeConns = new Set();
|
|
51
|
+
const server = net.createServer({ allowHalfOpen: true }, (conn) => {
|
|
52
|
+
// Reject if too many concurrent connections
|
|
53
|
+
if (activeConns.size >= MAX_CONCURRENT_CONNS) {
|
|
54
|
+
conn.destroy();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
activeConns.add(conn);
|
|
58
|
+
conn.on("close", () => activeConns.delete(conn));
|
|
59
|
+
let data = "";
|
|
60
|
+
let totalBytes = 0;
|
|
61
|
+
// Connection timeout — destroy if client doesn't finish within limit
|
|
62
|
+
conn.setTimeout(CONN_TIMEOUT_MS, () => {
|
|
63
|
+
conn.destroy();
|
|
64
|
+
});
|
|
65
|
+
conn.on("data", (chunk) => {
|
|
66
|
+
totalBytes += chunk.length;
|
|
67
|
+
if (totalBytes > MAX_REQUEST_BYTES) {
|
|
68
|
+
conn.write(JSON.stringify({ approved: false, reason: "request too large" }));
|
|
69
|
+
conn.end();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
data += chunk.toString();
|
|
73
|
+
});
|
|
74
|
+
conn.on("end", () => {
|
|
75
|
+
// Full request received (hook called conn.end() after writing)
|
|
76
|
+
let request;
|
|
77
|
+
try {
|
|
78
|
+
request = JSON.parse(data);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
conn.write(JSON.stringify({ approved: false, reason: "invalid request" }));
|
|
82
|
+
conn.end();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Validate auth token (constant-time comparison to prevent timing attacks)
|
|
86
|
+
const tokenBuf = Buffer.from(String(request.token || ""));
|
|
87
|
+
const expectedBuf = Buffer.from(AUTH_TOKEN);
|
|
88
|
+
if (tokenBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(tokenBuf, expectedBuf)) {
|
|
89
|
+
conn.write(JSON.stringify({ approved: false, reason: "unauthorized" }));
|
|
90
|
+
conn.end();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const approvalId = `apr_${crypto.randomBytes(12).toString("hex")}`;
|
|
94
|
+
// Build human-readable description
|
|
95
|
+
let description = request.tool;
|
|
96
|
+
let command;
|
|
97
|
+
const input = request.toolInput || {};
|
|
98
|
+
if (request.tool === "Bash" && input.command) {
|
|
99
|
+
description = `Run: ${input.command}`;
|
|
100
|
+
command = input.command;
|
|
101
|
+
}
|
|
102
|
+
else if (input.file_path) {
|
|
103
|
+
description = `${request.tool}: ${input.file_path}`;
|
|
104
|
+
}
|
|
105
|
+
// Send tool_approval_request to phone via existing WSS connection
|
|
106
|
+
log.info(`Sending tool_approval_request: approvalId=${approvalId} session=${request.sessionId} tool=${request.tool}`);
|
|
107
|
+
const sent = sendFn({
|
|
108
|
+
type: "tool_approval_request",
|
|
109
|
+
sessionId: request.sessionId,
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
payload: { approvalId, tool: request.tool, description, command },
|
|
112
|
+
});
|
|
113
|
+
// Phone not connected — deny immediately instead of waiting 5 minutes
|
|
114
|
+
if (!sent) {
|
|
115
|
+
log.warn(`Phone not connected — auto-denying approvalId=${approvalId}`);
|
|
116
|
+
conn.write(JSON.stringify({ approved: false, reason: "phone not connected" }));
|
|
117
|
+
conn.end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Timeout: deny if phone doesn't respond in 5 minutes
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
sessionMgr.resolveApproval(approvalId, false);
|
|
123
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
124
|
+
// Register callback — fires when phone responds via WSS
|
|
125
|
+
// (handleMessage in index.ts receives tool_approval_response,
|
|
126
|
+
// calls sessionMgr.resolveApproval, which triggers this callback)
|
|
127
|
+
sessionMgr.registerApprovalCallback(approvalId, (approved) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
log.info(`Approval resolved: approvalId=${approvalId} approved=${approved}`);
|
|
130
|
+
conn.write(JSON.stringify({ approved }));
|
|
131
|
+
conn.end();
|
|
132
|
+
}, { approvalId, sessionId: request.sessionId, tool: request.tool, description, command });
|
|
133
|
+
});
|
|
134
|
+
conn.on("error", () => {
|
|
135
|
+
// Hook process died, nothing to do
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
if (isWin) {
|
|
139
|
+
// Windows named pipes don't use file permissions — just listen.
|
|
140
|
+
// Handle EADDRINUSE: a previous instance may still hold the pipe.
|
|
141
|
+
server.on("error", (err) => {
|
|
142
|
+
if (err.code !== "EADDRINUSE")
|
|
143
|
+
throw err;
|
|
144
|
+
// Probe the existing pipe to check if something is actually listening
|
|
145
|
+
const probe = net.createConnection(socketPath, () => {
|
|
146
|
+
// Connection succeeded — another instance is genuinely running
|
|
147
|
+
probe.destroy();
|
|
148
|
+
log.error("Another BeachViber instance is already running (pipe in use). " +
|
|
149
|
+
"Stop the other instance first, or restart your terminal.");
|
|
150
|
+
process.exit(1);
|
|
151
|
+
});
|
|
152
|
+
probe.on("error", () => {
|
|
153
|
+
// Pipe exists but nobody is listening — stale. Retry after brief delay.
|
|
154
|
+
log.info("Stale pipe detected, retrying...");
|
|
155
|
+
setTimeout(() => server.listen(socketPath), 500);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
server.listen(socketPath);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Set restrictive umask before creating socket to eliminate TOCTOU race
|
|
162
|
+
const oldUmask = process.umask(0o177);
|
|
163
|
+
server.listen(socketPath, () => {
|
|
164
|
+
process.umask(oldUmask);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Cleanup on process exit (Unix sockets need file removal; named pipes do not)
|
|
168
|
+
if (!isWin) {
|
|
169
|
+
const cleanup = () => {
|
|
170
|
+
try {
|
|
171
|
+
fs.unlinkSync(socketPath);
|
|
172
|
+
}
|
|
173
|
+
catch { }
|
|
174
|
+
};
|
|
175
|
+
process.on("exit", cleanup);
|
|
176
|
+
process.on("SIGINT", () => {
|
|
177
|
+
cleanup();
|
|
178
|
+
process.exit(0);
|
|
179
|
+
});
|
|
180
|
+
process.on("SIGTERM", () => {
|
|
181
|
+
cleanup();
|
|
182
|
+
process.exit(0);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return server;
|
|
186
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
export const DEFAULT_RELAY_URL = "wss://relay.beachviber.com";
|
|
6
|
+
export const DEFAULT_API_URL = "https://api.beachviber.com";
|
|
7
|
+
// --- Profile support ---
|
|
8
|
+
const PROFILE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$/;
|
|
9
|
+
let _profile = null;
|
|
10
|
+
export function setProfile(name) {
|
|
11
|
+
if (!PROFILE_RE.test(name)) {
|
|
12
|
+
throw new Error(`Invalid profile name "${name}": must be 1-32 alphanumeric/hyphen chars, no leading/trailing hyphen`);
|
|
13
|
+
}
|
|
14
|
+
_profile = name;
|
|
15
|
+
}
|
|
16
|
+
export function getProfile() {
|
|
17
|
+
return _profile;
|
|
18
|
+
}
|
|
19
|
+
export function getProfileSuffix() {
|
|
20
|
+
return _profile ? `-${_profile}` : "";
|
|
21
|
+
}
|
|
22
|
+
/** Test-only: reset profile state between tests */
|
|
23
|
+
export function _resetProfile() {
|
|
24
|
+
_profile = null;
|
|
25
|
+
}
|
|
26
|
+
export function getBeachViberDir() {
|
|
27
|
+
return join(homedir(), ".beachviber");
|
|
28
|
+
}
|
|
29
|
+
function getConfigFile() {
|
|
30
|
+
return join(getBeachViberDir(), `config${getProfileSuffix()}.json`);
|
|
31
|
+
}
|
|
32
|
+
export function loadConfig() {
|
|
33
|
+
const file = getConfigFile();
|
|
34
|
+
if (!existsSync(file))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function saveConfig(config) {
|
|
44
|
+
const dir = getBeachViberDir();
|
|
45
|
+
if (!existsSync(dir)) {
|
|
46
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(getConfigFile(), JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
export function deleteConfig() {
|
|
51
|
+
const file = getConfigFile();
|
|
52
|
+
if (existsSync(file)) {
|
|
53
|
+
unlinkSync(file);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
export function generateDeviceId() {
|
|
59
|
+
return `dev_${randomBytes(6).toString("hex")}`;
|
|
60
|
+
}
|