nothumanallowed 4.1.0 → 6.0.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/package.json +10 -3
- package/src/cli.mjs +181 -5
- package/src/commands/autostart.mjs +342 -0
- package/src/commands/chat.mjs +14 -8
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/ops.mjs +37 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +28 -7
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +61 -0
- package/src/constants.mjs +9 -1
- package/src/services/llm.mjs +22 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/memory.mjs +627 -0
- package/src/services/message-responder.mjs +778 -0
- package/src/services/microsoft-calendar.mjs +319 -0
- package/src/services/microsoft-mail.mjs +308 -0
- package/src/services/microsoft-oauth.mjs +345 -0
- package/src/services/ops-daemon.mjs +620 -11
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/tool-executor.mjs +392 -0
- package/src/services/web-ui.mjs +187 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft OAuth 2.0 with PKCE — browser-based consent flow.
|
|
3
|
+
* Uses Microsoft identity platform (login.microsoftonline.com).
|
|
4
|
+
* Runs ephemeral local HTTP server for callback.
|
|
5
|
+
* Zero dependencies — uses Node.js native http + crypto.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
|
|
13
|
+
import { info, ok, fail, warn } from '../ui.mjs';
|
|
14
|
+
|
|
15
|
+
const SCOPES = [
|
|
16
|
+
'openid',
|
|
17
|
+
'offline_access',
|
|
18
|
+
'User.Read',
|
|
19
|
+
'Mail.Read',
|
|
20
|
+
'Mail.Send',
|
|
21
|
+
'Calendars.ReadWrite',
|
|
22
|
+
'Tasks.ReadWrite',
|
|
23
|
+
].join(' ');
|
|
24
|
+
|
|
25
|
+
const CALLBACK_PORTS = [19852, 19853, 19854, 19855, 19856];
|
|
26
|
+
|
|
27
|
+
const PROVIDER = 'microsoft';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate PKCE code_verifier and code_challenge.
|
|
31
|
+
*/
|
|
32
|
+
function generatePKCE() {
|
|
33
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
34
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
35
|
+
return { verifier, challenge };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Open URL in user's default browser.
|
|
40
|
+
*/
|
|
41
|
+
function openBrowser(url) {
|
|
42
|
+
const platform = os.platform();
|
|
43
|
+
try {
|
|
44
|
+
if (platform === 'darwin') execSync(`open "${url}"`);
|
|
45
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`);
|
|
46
|
+
else execSync(`xdg-open "${url}"`);
|
|
47
|
+
} catch {
|
|
48
|
+
warn('Could not open browser automatically.');
|
|
49
|
+
info(`Open this URL manually:\n\n ${url}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start ephemeral HTTP server and wait for OAuth callback.
|
|
55
|
+
* @returns {Promise<{code: string, port: number}>}
|
|
56
|
+
*/
|
|
57
|
+
function waitForCallback(state, port) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const server = http.createServer((req, res) => {
|
|
60
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
61
|
+
if (url.pathname !== '/callback') {
|
|
62
|
+
res.writeHead(404);
|
|
63
|
+
res.end('Not found');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const code = url.searchParams.get('code');
|
|
68
|
+
const returnedState = url.searchParams.get('state');
|
|
69
|
+
const error = url.searchParams.get('error');
|
|
70
|
+
const errorDesc = url.searchParams.get('error_description');
|
|
71
|
+
|
|
72
|
+
if (error) {
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
74
|
+
res.end(`<html><body><h2>Authorization failed</h2><p>${error}: ${errorDesc || ''}</p><p>You can close this tab.</p></body></html>`);
|
|
75
|
+
server.close();
|
|
76
|
+
reject(new Error(`OAuth error: ${error} — ${errorDesc || ''}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!code || returnedState !== state) {
|
|
81
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
82
|
+
res.end('<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>');
|
|
83
|
+
server.close();
|
|
84
|
+
reject(new Error('Invalid OAuth callback'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
89
|
+
res.end(`
|
|
90
|
+
<html>
|
|
91
|
+
<head><style>body{font-family:monospace;background:#0a0a0a;color:#00ff41;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
92
|
+
.box{text-align:center;border:1px solid #00ff41;padding:40px;border-radius:8px}</style></head>
|
|
93
|
+
<body><div class="box">
|
|
94
|
+
<h2>NHA Connected</h2>
|
|
95
|
+
<p>Microsoft account linked successfully.</p>
|
|
96
|
+
<p style="color:#666">You can close this tab and return to the terminal.</p>
|
|
97
|
+
</div></body></html>
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
server.close();
|
|
101
|
+
resolve({ code, port });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
server.listen(port, '127.0.0.1');
|
|
105
|
+
server.on('error', () => reject(new Error(`Port ${port} in use`)));
|
|
106
|
+
|
|
107
|
+
// 5 minute timeout
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
server.close();
|
|
110
|
+
reject(new Error('OAuth timeout — no callback received within 5 minutes'));
|
|
111
|
+
}, 300_000);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Exchange authorization code for tokens via Microsoft identity platform.
|
|
117
|
+
*/
|
|
118
|
+
async function exchangeCode(code, codeVerifier, clientId, clientSecret, redirectUri, tenantId) {
|
|
119
|
+
const params = new URLSearchParams({
|
|
120
|
+
client_id: clientId,
|
|
121
|
+
scope: SCOPES,
|
|
122
|
+
code,
|
|
123
|
+
redirect_uri: redirectUri,
|
|
124
|
+
grant_type: 'authorization_code',
|
|
125
|
+
code_verifier: codeVerifier,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (clientSecret) {
|
|
129
|
+
params.set('client_secret', clientSecret);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
135
|
+
body: params.toString(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const err = await res.text();
|
|
140
|
+
throw new Error(`Token exchange failed: ${err}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return res.json();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Refresh access token using refresh_token.
|
|
148
|
+
*/
|
|
149
|
+
export async function refreshMicrosoftToken(clientId, clientSecret, refreshToken, tenantId) {
|
|
150
|
+
const params = new URLSearchParams({
|
|
151
|
+
client_id: clientId,
|
|
152
|
+
scope: SCOPES,
|
|
153
|
+
refresh_token: refreshToken,
|
|
154
|
+
grant_type: 'refresh_token',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (clientSecret) {
|
|
158
|
+
params.set('client_secret', clientSecret);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
164
|
+
body: params.toString(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const err = await res.text();
|
|
169
|
+
throw new Error(`Microsoft token refresh failed: ${err}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
return {
|
|
174
|
+
access_token: data.access_token,
|
|
175
|
+
refresh_token: data.refresh_token || refreshToken,
|
|
176
|
+
expires_at: Date.now() + (data.expires_in * 1000),
|
|
177
|
+
scope: data.scope,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fetch authenticated user's profile from Microsoft Graph.
|
|
183
|
+
*/
|
|
184
|
+
async function getUserProfile(accessToken) {
|
|
185
|
+
const res = await fetch('https://graph.microsoft.com/v1.0/me', {
|
|
186
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) return null;
|
|
189
|
+
return res.json();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get a valid Microsoft access token — refreshes automatically if expired.
|
|
194
|
+
* @param {object} config — NHA config with microsoft.clientId etc.
|
|
195
|
+
* @returns {Promise<string>} access_token
|
|
196
|
+
*/
|
|
197
|
+
export async function getMicrosoftAccessToken(config) {
|
|
198
|
+
let tokens = loadTokens(PROVIDER);
|
|
199
|
+
if (!tokens) throw new Error('Not authenticated with Microsoft. Run: nha microsoft auth');
|
|
200
|
+
|
|
201
|
+
const expired = Date.now() >= (tokens.expires_at || 0) - 300_000;
|
|
202
|
+
if (expired) {
|
|
203
|
+
const clientId = config.microsoft?.clientId;
|
|
204
|
+
const clientSecret = config.microsoft?.clientSecret || '';
|
|
205
|
+
const tenantId = config.microsoft?.tenantId || 'common';
|
|
206
|
+
if (!clientId) throw new Error('Microsoft client ID not configured');
|
|
207
|
+
|
|
208
|
+
const refreshed = await refreshMicrosoftToken(clientId, clientSecret, tokens.refresh_token, tenantId);
|
|
209
|
+
tokens = {
|
|
210
|
+
...tokens,
|
|
211
|
+
access_token: refreshed.access_token,
|
|
212
|
+
refresh_token: refreshed.refresh_token,
|
|
213
|
+
expires_at: refreshed.expires_at,
|
|
214
|
+
scope: refreshed.scope,
|
|
215
|
+
};
|
|
216
|
+
saveTokens(tokens, PROVIDER);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return tokens.access_token;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Run the full Microsoft OAuth consent flow.
|
|
224
|
+
* @param {object} config — NHA config
|
|
225
|
+
*/
|
|
226
|
+
export async function runMicrosoftAuthFlow(config) {
|
|
227
|
+
const clientId = config.microsoft?.clientId;
|
|
228
|
+
const clientSecret = config.microsoft?.clientSecret || '';
|
|
229
|
+
const tenantId = config.microsoft?.tenantId || 'common';
|
|
230
|
+
|
|
231
|
+
if (!clientId) {
|
|
232
|
+
fail('Microsoft OAuth client ID not configured.');
|
|
233
|
+
info('Get credentials from Azure Portal:');
|
|
234
|
+
info(' 1. Go to https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps');
|
|
235
|
+
info(' 2. Register an application (Personal accounts or Org + Personal)');
|
|
236
|
+
info(' 3. Add a "Mobile and desktop" platform with redirect URI');
|
|
237
|
+
info(' http://127.0.0.1:19852/callback');
|
|
238
|
+
info(' 4. Under API permissions, add: Mail.Read, Mail.Send, Calendars.ReadWrite, Tasks.ReadWrite, User.Read');
|
|
239
|
+
info(' 5. Run:');
|
|
240
|
+
info(' nha config set microsoft-client-id YOUR_CLIENT_ID');
|
|
241
|
+
info(' nha config set microsoft-client-secret YOUR_CLIENT_SECRET (optional for public clients)');
|
|
242
|
+
info(' nha config set microsoft-tenant common (or your tenant ID)');
|
|
243
|
+
info(' 6. Run: nha microsoft auth');
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Find available port
|
|
248
|
+
let port = 0;
|
|
249
|
+
for (const p of CALLBACK_PORTS) {
|
|
250
|
+
try {
|
|
251
|
+
const srv = http.createServer();
|
|
252
|
+
await new Promise((resolve, reject) => {
|
|
253
|
+
srv.listen(p, '127.0.0.1', () => { srv.close(); resolve(true); });
|
|
254
|
+
srv.on('error', () => reject());
|
|
255
|
+
});
|
|
256
|
+
port = p;
|
|
257
|
+
break;
|
|
258
|
+
} catch { continue; }
|
|
259
|
+
}
|
|
260
|
+
if (!port) {
|
|
261
|
+
fail('No available port for OAuth callback (tried 19852-19856)');
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
266
|
+
const { verifier, challenge } = generatePKCE();
|
|
267
|
+
const state = crypto.randomBytes(32).toString('hex');
|
|
268
|
+
|
|
269
|
+
const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
|
|
270
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
271
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
272
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
273
|
+
authUrl.searchParams.set('response_mode', 'query');
|
|
274
|
+
authUrl.searchParams.set('scope', SCOPES);
|
|
275
|
+
authUrl.searchParams.set('state', state);
|
|
276
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
277
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
278
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
279
|
+
|
|
280
|
+
info('Opening browser for Microsoft authorization...');
|
|
281
|
+
openBrowser(authUrl.toString());
|
|
282
|
+
info('Waiting for authorization (5 min timeout)...\n');
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const { code } = await waitForCallback(state, port);
|
|
286
|
+
info('Authorization code received. Exchanging for tokens...');
|
|
287
|
+
|
|
288
|
+
const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri, tenantId);
|
|
289
|
+
const profile = await getUserProfile(tokenData.access_token);
|
|
290
|
+
|
|
291
|
+
const tokens = {
|
|
292
|
+
access_token: tokenData.access_token,
|
|
293
|
+
refresh_token: tokenData.refresh_token,
|
|
294
|
+
expires_at: Date.now() + (tokenData.expires_in * 1000),
|
|
295
|
+
scope: tokenData.scope,
|
|
296
|
+
email: profile?.mail || profile?.userPrincipalName || 'unknown',
|
|
297
|
+
displayName: profile?.displayName || '',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
saveTokens(tokens, PROVIDER);
|
|
301
|
+
ok(`Microsoft account connected: ${tokens.email}`);
|
|
302
|
+
ok('Outlook Mail + Calendar + Tasks access granted.');
|
|
303
|
+
info('Run "nha plan" to generate your first daily plan.');
|
|
304
|
+
return true;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
fail(err.message);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Show Microsoft connection status.
|
|
313
|
+
*/
|
|
314
|
+
export function showMicrosoftStatus() {
|
|
315
|
+
const tokens = loadTokens(PROVIDER);
|
|
316
|
+
if (!tokens) {
|
|
317
|
+
info('Not connected to Microsoft. Run: nha microsoft auth');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const expired = Date.now() >= tokens.expires_at;
|
|
322
|
+
console.log(`\n Microsoft Account: ${tokens.email || 'unknown'}`);
|
|
323
|
+
if (tokens.displayName) console.log(` Display Name: ${tokens.displayName}`);
|
|
324
|
+
console.log(` Token Status: ${expired ? '\x1b[0;31mexpired\x1b[0m' : '\x1b[0;32mactive\x1b[0m'}`);
|
|
325
|
+
console.log(` Expires: ${new Date(tokens.expires_at).toLocaleString()}`);
|
|
326
|
+
console.log(` Scopes: ${tokens.scope || 'unknown'}\n`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Revoke tokens and delete local storage.
|
|
331
|
+
*/
|
|
332
|
+
export async function revokeMicrosoftAuth() {
|
|
333
|
+
const tokens = loadTokens(PROVIDER);
|
|
334
|
+
if (!tokens) {
|
|
335
|
+
info('No Microsoft tokens found.');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Microsoft doesn't have a simple revoke endpoint like Google.
|
|
340
|
+
// Best effort: sign out via logout URL (this invalidates the session in browser).
|
|
341
|
+
// The refresh token will expire naturally. Local deletion is the primary action.
|
|
342
|
+
deleteTokens(PROVIDER);
|
|
343
|
+
ok('Microsoft account disconnected. Local tokens deleted.');
|
|
344
|
+
info('To fully revoke access, visit: https://account.live.com/consent/Manage');
|
|
345
|
+
}
|