voicemode-channel 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -4
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +298 -0
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +109 -0
- package/dist/gateway.d.ts +95 -0
- package/dist/gateway.js +370 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +530 -0
- package/package.json +23 -8
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Bailey
|
|
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
CHANGED
|
@@ -1,6 +1,160 @@
|
|
|
1
|
-
#
|
|
1
|
+
# VoiceMode Channel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Claude Code plugin that enables inbound voice calls via [VoiceMode Connect](https://voicemode.dev).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Users speak on their phone or web app, and their messages arrive in your Claude Code session as channel events. Claude responds using the reply tool, and the response is spoken aloud on the caller's device.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
User speaks on phone/web -> VoiceMode gateway -> Channel plugin -> Claude Code
|
|
11
|
+
|
|
|
12
|
+
User hears TTS response <- Channel reply tool <----------------------+
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
### Claude Code plugin
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude plugin marketplace add mbailey/claude-plugins
|
|
21
|
+
claude plugin install voicemode-channel@mbailey
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Standalone (any MCP host)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx voicemode-channel
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Prerequisites
|
|
31
|
+
|
|
32
|
+
- Node.js 20+
|
|
33
|
+
- VoiceMode Connect credentials (`~/.voicemode/credentials`)
|
|
34
|
+
- Run `voicemode-channel auth login` to authenticate
|
|
35
|
+
|
|
36
|
+
## Auth
|
|
37
|
+
|
|
38
|
+
Manage your VoiceMode Connect credentials:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
voicemode-channel auth login # Authenticate via browser (PKCE flow)
|
|
42
|
+
voicemode-channel auth logout # Remove stored credentials
|
|
43
|
+
voicemode-channel auth status # Show current auth state
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or via npx:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx voicemode-channel auth login
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Enable the channel
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# During research preview, use the development flag
|
|
58
|
+
VOICEMODE_CHANNEL_ENABLED=true claude --dangerously-load-development-channels plugin:voicemode-channel@mbailey
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Make a call
|
|
62
|
+
|
|
63
|
+
Open **[app.voicemode.dev](https://app.voicemode.dev)** on your phone or browser. Sign in with the same account, tap the call button, and speak. Claude will respond and you'll hear TTS audio playback.
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
| Environment Variable | Default | Description |
|
|
68
|
+
| ------------------------------ | ------------------------ | --------------------------------------------------------------------------- |
|
|
69
|
+
| `VOICEMODE_CHANNEL_ENABLED` | `false` | **Required.** Must be `true` to enable. Server exits immediately otherwise. |
|
|
70
|
+
| `VOICEMODE_CHANNEL_DEBUG` | `false` | Enable debug logging |
|
|
71
|
+
| `VOICEMODE_CONNECT_WS_URL` | `wss://voicemode.dev/ws` | WebSocket gateway URL |
|
|
72
|
+
| `VOICEMODE_AGENT_NAME` | `voicemode` | Agent identity for gateway registration |
|
|
73
|
+
| `VOICEMODE_AGENT_DISPLAY_NAME` | `Claude Code` | Display name shown to callers |
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
This plugin provides an MCP server that declares the experimental `claude/channel` capability. It:
|
|
78
|
+
|
|
79
|
+
1. Connects to the VoiceMode Connect WebSocket gateway (authenticated via Auth0)
|
|
80
|
+
2. Registers as a callable agent so callers can reach it
|
|
81
|
+
3. Receives voice transcripts and pushes them as channel notifications
|
|
82
|
+
4. Provides a `reply` tool for Claude to send responses back
|
|
83
|
+
|
|
84
|
+
Channel events appear in Claude's session as:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
<channel source="voicemode-channel" caller="NAME">TRANSCRIPT</channel>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Troubleshooting
|
|
91
|
+
|
|
92
|
+
**Channel not connecting**
|
|
93
|
+
|
|
94
|
+
- Ensure `VOICEMODE_CHANNEL_ENABLED=true` is set
|
|
95
|
+
- Check credentials exist: `voicemode-channel auth status`
|
|
96
|
+
- Re-authenticate: `voicemode-channel auth login`
|
|
97
|
+
- Enable debug logging: `VOICEMODE_CHANNEL_DEBUG=true`
|
|
98
|
+
|
|
99
|
+
**No audio on caller's device**
|
|
100
|
+
|
|
101
|
+
- Confirm you're signed into [app.voicemode.dev](https://app.voicemode.dev) with the same account
|
|
102
|
+
- Check that Claude is using the `reply` tool (not a plain text response)
|
|
103
|
+
|
|
104
|
+
**Plugin not found after install**
|
|
105
|
+
|
|
106
|
+
- Verify Claude Code v2.1.80+ is installed: `claude --version`
|
|
107
|
+
- Reinstall: `claude plugin install voicemode-channel@mbailey`
|
|
108
|
+
|
|
109
|
+
**Hook timeout on startup**
|
|
110
|
+
|
|
111
|
+
- The SessionStart hook installs npm dependencies -- this may take a moment on first run
|
|
112
|
+
- Subsequent starts use the cached install and are fast
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Clone and test locally
|
|
118
|
+
git clone https://github.com/mbailey/voicemode-channel.git
|
|
119
|
+
cd voicemode-channel
|
|
120
|
+
npm install
|
|
121
|
+
|
|
122
|
+
# Build
|
|
123
|
+
make build
|
|
124
|
+
|
|
125
|
+
# Test with --plugin-dir
|
|
126
|
+
VOICEMODE_CHANNEL_ENABLED=true claude --plugin-dir . --dangerously-load-development-channels server:voicemode-channel
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Testing with mcptools
|
|
130
|
+
|
|
131
|
+
[mcptools](https://github.com/f/mcptools) provides an interactive shell for testing MCP servers (`brew install mcptools`):
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
make shell
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Example session:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
mcp > tools # List available tools
|
|
141
|
+
mcp > status # Check connection state
|
|
142
|
+
mcp > reply {"text":"hello from cli"} # Send a voice reply
|
|
143
|
+
mcp > profile # View agent profile
|
|
144
|
+
mcp > profile {"voice":"af_sky"} # Update profile fields
|
|
145
|
+
mcp > /q # Quit
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The MCP Inspector web UI is also available:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
make inspect
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Status
|
|
155
|
+
|
|
156
|
+
Research preview. Requires Claude Code v2.1.80+ with channel support.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth PKCE login flow for VoiceMode Connect.
|
|
3
|
+
*
|
|
4
|
+
* Implements Auth0 PKCE authentication using only Node built-ins:
|
|
5
|
+
* - crypto for PKCE code verifier/challenge
|
|
6
|
+
* - http for localhost callback server
|
|
7
|
+
* - child_process for opening browser
|
|
8
|
+
*
|
|
9
|
+
* Ported from voice_mode/auth.py in the Python CLI.
|
|
10
|
+
*/
|
|
11
|
+
import { type StoredCredentials } from './credentials.js';
|
|
12
|
+
export declare function login(log: (msg: string) => void): Promise<StoredCredentials>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth PKCE login flow for VoiceMode Connect.
|
|
3
|
+
*
|
|
4
|
+
* Implements Auth0 PKCE authentication using only Node built-ins:
|
|
5
|
+
* - crypto for PKCE code verifier/challenge
|
|
6
|
+
* - http for localhost callback server
|
|
7
|
+
* - child_process for opening browser
|
|
8
|
+
*
|
|
9
|
+
* Ported from voice_mode/auth.py in the Python CLI.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { platform } from 'node:os';
|
|
15
|
+
import { AUTH0_DOMAIN, AUTH0_CLIENT_ID, save_credentials, } from './credentials.js';
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Auth0 OAuth parameters (matching Python CLI)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const AUTH0_SCOPES = 'openid profile email offline_access';
|
|
20
|
+
const AUTH0_AUDIENCE = 'https://voicemode.dev/api';
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Callback server configuration
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const CALLBACK_PORT_START = 8765;
|
|
25
|
+
const CALLBACK_PORT_END = 8769;
|
|
26
|
+
const CALLBACK_TIMEOUT_MS = 300_000; // 5 minutes
|
|
27
|
+
function generate_pkce_params() {
|
|
28
|
+
// 32 random bytes → base64url ≈ 43 characters
|
|
29
|
+
const code_verifier = randomBytes(32)
|
|
30
|
+
.toString('base64url');
|
|
31
|
+
// SHA256 hash → base64url (no padding)
|
|
32
|
+
const code_challenge = createHash('sha256')
|
|
33
|
+
.update(code_verifier, 'ascii')
|
|
34
|
+
.digest('base64url');
|
|
35
|
+
return { code_verifier, code_challenge };
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Port selection
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
/**
|
|
41
|
+
* Try to listen on a port, resolving with the server if successful.
|
|
42
|
+
* Eliminates TOCTOU race by using listen() directly instead of checking first.
|
|
43
|
+
*/
|
|
44
|
+
function try_listen(server, port) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
server.once('error', (err) => {
|
|
47
|
+
if (err.code === 'EADDRINUSE')
|
|
48
|
+
resolve(false);
|
|
49
|
+
else
|
|
50
|
+
resolve(false);
|
|
51
|
+
});
|
|
52
|
+
server.listen(port, '127.0.0.1', () => resolve(true));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Callback HTML page
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
function escape_html(s) {
|
|
59
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
60
|
+
}
|
|
61
|
+
function callback_page(success, error_message = '') {
|
|
62
|
+
const icon_bg = success ? '#3fb950' : '#f85149';
|
|
63
|
+
const icon_svg = success
|
|
64
|
+
? '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" fill="#0d1117"/>'
|
|
65
|
+
: '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="#0d1117"/>';
|
|
66
|
+
const heading = success ? 'Authentication Successful' : 'Authentication Failed';
|
|
67
|
+
const message = success
|
|
68
|
+
? 'You can close this window and return to the terminal.'
|
|
69
|
+
: (error_message ? `Error: ${escape_html(error_message)}` : 'Something went wrong.');
|
|
70
|
+
return `<!DOCTYPE html>
|
|
71
|
+
<html lang="en">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="utf-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
75
|
+
<title>VoiceMode - ${heading}</title>
|
|
76
|
+
<style>
|
|
77
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
78
|
+
body {
|
|
79
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
80
|
+
background: #0d1117; color: #e6edf3;
|
|
81
|
+
display: flex; align-items: center; justify-content: center;
|
|
82
|
+
min-height: 100vh; padding: 24px;
|
|
83
|
+
}
|
|
84
|
+
.card {
|
|
85
|
+
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
|
|
86
|
+
padding: 48px 40px; max-width: 420px; width: 100%; text-align: center;
|
|
87
|
+
}
|
|
88
|
+
.icon {
|
|
89
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
90
|
+
width: 48px; height: 48px; background: ${icon_bg}; border-radius: 50%; margin-bottom: 20px;
|
|
91
|
+
}
|
|
92
|
+
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #e6edf3; }
|
|
93
|
+
p { font-size: 14px; color: #8b949e; line-height: 1.5; }
|
|
94
|
+
</style>
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<div class="card">
|
|
98
|
+
<div><div class="icon">
|
|
99
|
+
<svg width="24" height="24" viewBox="0 0 24 24">${icon_svg}</svg>
|
|
100
|
+
</div></div>
|
|
101
|
+
<h1>${heading}</h1>
|
|
102
|
+
<p>${message}</p>
|
|
103
|
+
</div>
|
|
104
|
+
</body>
|
|
105
|
+
</html>`;
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Browser opening
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
function open_browser(url) {
|
|
111
|
+
const plat = platform();
|
|
112
|
+
let cmd;
|
|
113
|
+
let args;
|
|
114
|
+
if (plat === 'darwin') {
|
|
115
|
+
cmd = 'open';
|
|
116
|
+
args = [url];
|
|
117
|
+
}
|
|
118
|
+
else if (plat === 'linux') {
|
|
119
|
+
cmd = 'xdg-open';
|
|
120
|
+
args = [url];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Windows fallback (unlikely for this project)
|
|
124
|
+
cmd = 'cmd';
|
|
125
|
+
args = ['/c', 'start', '', url];
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
execFile(cmd, args, (err) => {
|
|
129
|
+
if (err) {
|
|
130
|
+
// Browser open failed -- headless fallback already handled by caller
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Auth0 API calls
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
async function exchange_code_for_tokens(code, code_verifier, redirect_uri) {
|
|
143
|
+
const token_url = `https://${AUTH0_DOMAIN}/oauth/token`;
|
|
144
|
+
const body = new URLSearchParams({
|
|
145
|
+
grant_type: 'authorization_code',
|
|
146
|
+
client_id: AUTH0_CLIENT_ID,
|
|
147
|
+
code,
|
|
148
|
+
code_verifier,
|
|
149
|
+
redirect_uri,
|
|
150
|
+
});
|
|
151
|
+
const response = await fetch(token_url, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
154
|
+
body: body.toString(),
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
let detail = `HTTP ${response.status}`;
|
|
158
|
+
try {
|
|
159
|
+
const err = (await response.json());
|
|
160
|
+
detail = `${err.error ?? 'unknown'}: ${err.error_description ?? 'Token exchange failed'}`;
|
|
161
|
+
}
|
|
162
|
+
catch { /* use HTTP status */ }
|
|
163
|
+
throw new Error(`Token exchange failed: ${detail}`);
|
|
164
|
+
}
|
|
165
|
+
return (await response.json());
|
|
166
|
+
}
|
|
167
|
+
async function get_user_info(access_token) {
|
|
168
|
+
const url = `https://${AUTH0_DOMAIN}/userinfo`;
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetch(url, {
|
|
171
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok)
|
|
174
|
+
return null;
|
|
175
|
+
return (await response.json());
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Authorization URL builder
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
function build_authorize_url(redirect_uri, pkce, state) {
|
|
185
|
+
const params = new URLSearchParams({
|
|
186
|
+
response_type: 'code',
|
|
187
|
+
client_id: AUTH0_CLIENT_ID,
|
|
188
|
+
redirect_uri,
|
|
189
|
+
scope: AUTH0_SCOPES,
|
|
190
|
+
audience: AUTH0_AUDIENCE,
|
|
191
|
+
code_challenge: pkce.code_challenge,
|
|
192
|
+
code_challenge_method: 'S256',
|
|
193
|
+
state,
|
|
194
|
+
});
|
|
195
|
+
return `https://${AUTH0_DOMAIN}/authorize?${params.toString()}`;
|
|
196
|
+
}
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Main login flow
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
export async function login(log) {
|
|
201
|
+
// Generate PKCE parameters and state upfront (needed for the request handler)
|
|
202
|
+
const pkce = generate_pkce_params();
|
|
203
|
+
const state = randomBytes(16).toString('base64url');
|
|
204
|
+
// Create the server with request handler and find a port by trying to listen directly.
|
|
205
|
+
// The server stays bound to the port the entire time -- no TOCTOU race.
|
|
206
|
+
const result = await new Promise((resolve, reject) => {
|
|
207
|
+
let bound_port = 0;
|
|
208
|
+
const callback_server = createServer((req, res) => {
|
|
209
|
+
const url = new URL(req.url ?? '/', `http://localhost:${bound_port}`);
|
|
210
|
+
if (url.pathname !== '/callback') {
|
|
211
|
+
res.writeHead(404);
|
|
212
|
+
res.end();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const error = url.searchParams.get('error');
|
|
216
|
+
if (error) {
|
|
217
|
+
const desc = url.searchParams.get('error_description') ?? 'Unknown error';
|
|
218
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
219
|
+
res.end(callback_page(false, desc));
|
|
220
|
+
cleanup();
|
|
221
|
+
reject(new Error(`${error}: ${desc}`));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const code = url.searchParams.get('code');
|
|
225
|
+
if (!code) {
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
227
|
+
res.end(callback_page(false, 'Missing authorization code'));
|
|
228
|
+
cleanup();
|
|
229
|
+
reject(new Error('Missing authorization code in callback'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
233
|
+
res.end(callback_page(true));
|
|
234
|
+
cleanup();
|
|
235
|
+
resolve({ code, state: url.searchParams.get('state'), port: bound_port });
|
|
236
|
+
});
|
|
237
|
+
const timeout_timer = setTimeout(() => {
|
|
238
|
+
cleanup();
|
|
239
|
+
resolve(null);
|
|
240
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
241
|
+
function cleanup() {
|
|
242
|
+
clearTimeout(timeout_timer);
|
|
243
|
+
callback_server.close();
|
|
244
|
+
}
|
|
245
|
+
// Try ports sequentially until one binds
|
|
246
|
+
async function bind_port() {
|
|
247
|
+
for (let p = CALLBACK_PORT_START; p <= CALLBACK_PORT_END; p++) {
|
|
248
|
+
if (await try_listen(callback_server, p)) {
|
|
249
|
+
bound_port = p;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
cleanup();
|
|
254
|
+
reject(new Error(`No available ports in range ${CALLBACK_PORT_START}-${CALLBACK_PORT_END}. ` +
|
|
255
|
+
'Please close applications using these ports and try again.'));
|
|
256
|
+
}
|
|
257
|
+
bind_port().then(() => {
|
|
258
|
+
const redirect_uri = `http://localhost:${bound_port}/callback`;
|
|
259
|
+
const auth_url = build_authorize_url(redirect_uri, pkce, state);
|
|
260
|
+
log(`Callback server listening on port ${bound_port}`);
|
|
261
|
+
// Try to open browser
|
|
262
|
+
const opened = open_browser(auth_url);
|
|
263
|
+
if (!opened || !process.env.DISPLAY && !process.env.BROWSER && platform() === 'linux') {
|
|
264
|
+
// Headless fallback
|
|
265
|
+
process.stderr.write(`\nOpen this URL in your browser to log in:\n\n ${auth_url}\n\n`);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
log('Opening browser for authentication...');
|
|
269
|
+
}
|
|
270
|
+
log('Waiting for authentication (up to 5 minutes)...');
|
|
271
|
+
}).catch(reject);
|
|
272
|
+
});
|
|
273
|
+
if (result === null) {
|
|
274
|
+
throw new Error('Authentication timed out. Please try again.');
|
|
275
|
+
}
|
|
276
|
+
// Verify state (CSRF protection)
|
|
277
|
+
if (result.state !== state) {
|
|
278
|
+
throw new Error('State mismatch -- possible CSRF attack. Please try again.');
|
|
279
|
+
}
|
|
280
|
+
log('Authorization code received, exchanging for tokens...');
|
|
281
|
+
// Exchange code for tokens
|
|
282
|
+
const redirect_uri = `http://localhost:${result.port}/callback`;
|
|
283
|
+
const token_response = await exchange_code_for_tokens(result.code, pkce.code_verifier, redirect_uri);
|
|
284
|
+
const expires_in = token_response.expires_in ?? 3600;
|
|
285
|
+
// Fetch user info (optional, non-fatal)
|
|
286
|
+
const user_info = await get_user_info(token_response.access_token);
|
|
287
|
+
// Build and save credentials
|
|
288
|
+
const creds = {
|
|
289
|
+
access_token: token_response.access_token,
|
|
290
|
+
refresh_token: token_response.refresh_token ?? null,
|
|
291
|
+
expires_at: Date.now() / 1000 + expires_in,
|
|
292
|
+
token_type: token_response.token_type ?? 'Bearer',
|
|
293
|
+
user_info: user_info ?? undefined,
|
|
294
|
+
};
|
|
295
|
+
save_credentials(creds);
|
|
296
|
+
log('Login successful -- credentials saved to ~/.voicemode/credentials');
|
|
297
|
+
return creds;
|
|
298
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared credential management for VoiceMode Connect.
|
|
3
|
+
*
|
|
4
|
+
* Auth0 constants, credential file I/O, and token refresh logic
|
|
5
|
+
* used by both the gateway client and the auth login flow.
|
|
6
|
+
*/
|
|
7
|
+
export declare const AUTH0_DOMAIN = "dev-2q681p5hobd1dtmm.us.auth0.com";
|
|
8
|
+
export declare const AUTH0_CLIENT_ID = "1uJR1Q4HMkLkhzOXTg5JFuqBCq0FBsXK";
|
|
9
|
+
export declare const CREDENTIALS_FILE: string;
|
|
10
|
+
export interface StoredCredentials {
|
|
11
|
+
access_token: string;
|
|
12
|
+
refresh_token: string | null;
|
|
13
|
+
expires_at: number;
|
|
14
|
+
token_type: string;
|
|
15
|
+
user_info?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
export declare function load_credentials(): StoredCredentials | null;
|
|
18
|
+
export declare function save_credentials(creds: StoredCredentials): void;
|
|
19
|
+
export declare function is_expired(creds: StoredCredentials): boolean;
|
|
20
|
+
export declare function refresh_access_token(refresh_token: string): Promise<StoredCredentials | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Get a valid (non-expired) access token, refreshing if necessary.
|
|
23
|
+
* Returns the access token string, or null if no valid token is available.
|
|
24
|
+
*/
|
|
25
|
+
export declare function get_valid_token(log: (msg: string) => void): Promise<string | null>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared credential management for VoiceMode Connect.
|
|
3
|
+
*
|
|
4
|
+
* Auth0 constants, credential file I/O, and token refresh logic
|
|
5
|
+
* used by both the gateway client and the auth login flow.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Auth0 configuration
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
export const AUTH0_DOMAIN = 'dev-2q681p5hobd1dtmm.us.auth0.com';
|
|
14
|
+
export const AUTH0_CLIENT_ID = '1uJR1Q4HMkLkhzOXTg5JFuqBCq0FBsXK';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Credential storage
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export const CREDENTIALS_FILE = join(homedir(), '.voicemode', 'credentials');
|
|
19
|
+
const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Credential helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export function load_credentials() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(CREDENTIALS_FILE, 'utf-8');
|
|
26
|
+
const data = JSON.parse(raw);
|
|
27
|
+
if (!data.access_token)
|
|
28
|
+
return null;
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function save_credentials(creds) {
|
|
36
|
+
try {
|
|
37
|
+
const dir = join(homedir(), '.voicemode');
|
|
38
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
39
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Best effort -- if we can't save, we continue with the in-memory token
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function is_expired(creds) {
|
|
46
|
+
return Date.now() / 1000 >= (creds.expires_at - TOKEN_EXPIRY_BUFFER_SECONDS);
|
|
47
|
+
}
|
|
48
|
+
export async function refresh_access_token(refresh_token) {
|
|
49
|
+
const token_url = `https://${AUTH0_DOMAIN}/oauth/token`;
|
|
50
|
+
const body = new URLSearchParams({
|
|
51
|
+
grant_type: 'refresh_token',
|
|
52
|
+
client_id: AUTH0_CLIENT_ID,
|
|
53
|
+
refresh_token,
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(token_url, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
59
|
+
body: body.toString(),
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const data = (await response.json());
|
|
65
|
+
const expires_in = data.expires_in ?? 3600;
|
|
66
|
+
const new_creds = {
|
|
67
|
+
access_token: data.access_token,
|
|
68
|
+
refresh_token: data.refresh_token ?? refresh_token,
|
|
69
|
+
expires_at: Date.now() / 1000 + expires_in,
|
|
70
|
+
token_type: data.token_type ?? 'Bearer',
|
|
71
|
+
};
|
|
72
|
+
return new_creds;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get a valid (non-expired) access token, refreshing if necessary.
|
|
80
|
+
* Returns the access token string, or null if no valid token is available.
|
|
81
|
+
*/
|
|
82
|
+
export async function get_valid_token(log) {
|
|
83
|
+
const creds = load_credentials();
|
|
84
|
+
if (!creds) {
|
|
85
|
+
log('No credentials found at ~/.voicemode/credentials');
|
|
86
|
+
log('Run: voicemode connect auth login');
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!is_expired(creds)) {
|
|
90
|
+
return creds.access_token;
|
|
91
|
+
}
|
|
92
|
+
log('Access token expired, attempting refresh...');
|
|
93
|
+
if (!creds.refresh_token) {
|
|
94
|
+
log('No refresh token available -- please re-login');
|
|
95
|
+
log('Run: voicemode connect auth login');
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const refreshed = await refresh_access_token(creds.refresh_token);
|
|
99
|
+
if (!refreshed) {
|
|
100
|
+
log('Token refresh failed -- please re-login');
|
|
101
|
+
log('Run: voicemode connect auth login');
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
// Preserve user_info from original credentials
|
|
105
|
+
refreshed.user_info = creds.user_info;
|
|
106
|
+
save_credentials(refreshed);
|
|
107
|
+
log('Token refreshed successfully');
|
|
108
|
+
return refreshed.access_token;
|
|
109
|
+
}
|