@cspeach/cli 1.1.32 → 1.1.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/dist/agent/anthropic-provider.js +2 -0
- package/dist/agent/azure-profile.js +7 -0
- package/dist/agent/connection-profile.js +49 -0
- package/dist/agent/loop.js +61 -8
- package/dist/agent/provider-shape.js +36 -0
- package/dist/agent/providers/ai-hub-provider.js +6 -5
- package/dist/agent/providers/azure-provider.js +125 -0
- package/dist/agent/providers/byok-provider.js +8 -7
- package/dist/agent/providers/factory.js +8 -3
- package/dist/agent/providers/local-provider.js +18 -22
- package/dist/agent/risk-gate.js +202 -0
- package/dist/agent/summarise-via-provider.js +55 -8
- package/dist/agent/tool-dispatch.js +19 -0
- package/dist/agent/turn-error-ux.js +22 -0
- package/dist/auth/api-key.js +10 -5
- package/dist/auth/auth-file.js +35 -6
- package/dist/auth/azure/azure-key.js +21 -0
- package/dist/auth/azure/azure-log.js +45 -0
- package/dist/auth/azure/cache-store.js +171 -0
- package/dist/auth/azure/clear.js +28 -0
- package/dist/auth/azure/loopback.js +192 -0
- package/dist/auth/azure/msal-client.js +41 -0
- package/dist/auth/azure/sign-in.js +219 -0
- package/dist/auth/azure/startup-sign-in.js +64 -0
- package/dist/auth/azure/token-provider.js +116 -0
- package/dist/auth/key-resolver.js +258 -0
- package/dist/auth/me.js +11 -5
- package/dist/cli.js +21 -2
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +74 -3
- package/dist/commands/config-show.js +25 -0
- package/dist/commands/cost.js +25 -8
- package/dist/commands/help.js +2 -1
- package/dist/commands/login-azure.js +33 -0
- package/dist/commands/login.js +56 -8
- package/dist/commands/logout.js +34 -10
- package/dist/commands/plan-boundary.js +4 -4
- package/dist/commands/plan-model-tier.js +8 -3
- package/dist/commands/usage.js +112 -0
- package/dist/commands/whoami.js +113 -9
- package/dist/config/loader.js +5 -0
- package/dist/connection/azure-auth.js +135 -0
- package/dist/connection/azure-errors.js +103 -0
- package/dist/connection/azure-facts.js +21 -0
- package/dist/connection/azure-gate.js +40 -0
- package/dist/connection/azure-last-call.js +5 -0
- package/dist/connection/azure-turn-error.js +25 -0
- package/dist/connection/cache.js +70 -0
- package/dist/connection/fetch.js +33 -0
- package/dist/connection/guard.js +94 -0
- package/dist/connection/limit-gate.js +26 -0
- package/dist/connection/local-usage.js +71 -0
- package/dist/connection/login-step.js +62 -0
- package/dist/connection/startup.js +67 -0
- package/dist/connection/state.js +68 -0
- package/dist/connection/types.js +1 -0
- package/dist/cost/cost-log.js +3 -1
- package/dist/cost/credits-wire.js +1 -1
- package/dist/cost/credits.js +4 -0
- package/dist/cost/display-mode.js +7 -0
- package/dist/cost/month-usage.js +45 -0
- package/dist/cost/router-spend.js +3 -0
- package/dist/cost/session-cost.js +4 -0
- package/dist/doctor/checks/azure.js +100 -0
- package/dist/doctor/checks/credits.js +4 -3
- package/dist/doctor/checks/usage-reporting.js +22 -0
- package/dist/doctor/run.js +7 -0
- package/dist/licence/licence-check.js +205 -0
- package/dist/licence/paths.js +6 -0
- package/dist/licence/turn-gate.js +66 -0
- package/dist/models/model-identity.js +65 -0
- package/dist/models/resolve.js +8 -0
- package/dist/models/server-config.js +3 -2
- package/dist/one-shot.js +86 -2
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/repl/ask-or-no.js +18 -0
- package/dist/repl/builtin-commands.js +12 -1
- package/dist/repl/post-turn-status.js +3 -2
- package/dist/repl/safety-confirm.js +47 -2
- package/dist/repl/session-spend-line.js +4 -1
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +223 -48
- package/dist/router/byok-classifier.js +41 -14
- package/dist/router/classifier.js +5 -16
- package/dist/router/router-target.js +41 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/session/audit-export.js +11 -4
- package/dist/skills/manifest-client.js +37 -4
- package/dist/skills/skills-cache.js +115 -0
- package/dist/skills/source-authed.js +276 -0
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/tool-loading.js +9 -10
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +3 -2
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- package/dist/skills/source-manifest.js +0 -36
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — MSAL's token cache (3–6 KB) is too big for one Windows
|
|
3
|
+
* credential (2,560-byte blob cap), so it lives in
|
|
4
|
+
* <CSPEACH_HOME>/azure/msal-cache.enc, AES-256-GCM. The 32-byte key lives in
|
|
5
|
+
* the OS credential store: service cspeach.azure, account cache-key[:<home hash>].
|
|
6
|
+
* Two homes never share a file or a key. No credential store ⇒ no persisted
|
|
7
|
+
* cache (sign in each session), never plaintext.
|
|
8
|
+
*
|
|
9
|
+
* File format: [1 byte version=1][12 bytes IV][16 bytes tag][ciphertext].
|
|
10
|
+
* Every write draws a fresh IV, goes to a temp file in the same folder and is
|
|
11
|
+
* renamed over the old file (atomic). POSIX: folder 0700, file 0600. Windows
|
|
12
|
+
* ignores those modes; the file sits in the person's own profile folder.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
17
|
+
import { cspeachRoot } from '../../config/paths.js';
|
|
18
|
+
import { AZURE_SERVICE, azureAccountForHome } from './azure-key.js';
|
|
19
|
+
import { azureLog } from './azure-log.js';
|
|
20
|
+
const VERSION = 1;
|
|
21
|
+
const IV_BYTES = 12;
|
|
22
|
+
const TAG_BYTES = 16;
|
|
23
|
+
const HEADER = 1 + IV_BYTES + TAG_BYTES;
|
|
24
|
+
const KEY_BYTES = 32;
|
|
25
|
+
export const NO_STORE_NOTICE = 'Your Microsoft sign-in is kept for this session only: no OS credential store is available on this machine.';
|
|
26
|
+
export function azureCachePath() {
|
|
27
|
+
return path.join(cspeachRoot(), 'azure', 'msal-cache.enc');
|
|
28
|
+
}
|
|
29
|
+
export function encryptCache(plain, key, iv = randomBytes(IV_BYTES)) {
|
|
30
|
+
const c = createCipheriv('aes-256-gcm', key, iv);
|
|
31
|
+
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
|
|
32
|
+
return Buffer.concat([Buffer.from([VERSION]), iv, c.getAuthTag(), ct]);
|
|
33
|
+
}
|
|
34
|
+
export function decryptCache(blob, key) {
|
|
35
|
+
if (blob.length < HEADER || blob[0] !== VERSION)
|
|
36
|
+
throw new Error('unreadable cache');
|
|
37
|
+
const d = createDecipheriv('aes-256-gcm', key, blob.subarray(1, 1 + IV_BYTES));
|
|
38
|
+
d.setAuthTag(blob.subarray(1 + IV_BYTES, HEADER));
|
|
39
|
+
return Buffer.concat([d.update(blob.subarray(HEADER)), d.final()]).toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
async function defaultKeychain() {
|
|
42
|
+
return (await import('../keychain.js')).default;
|
|
43
|
+
}
|
|
44
|
+
async function writeAtomic(file, data) {
|
|
45
|
+
const dir = path.dirname(file);
|
|
46
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
// mkdir's mode only applies to a NEW folder; tighten one that already existed.
|
|
48
|
+
// A failure (a folder owned by someone else, a file system without modes)
|
|
49
|
+
// is logged and never fails the cache write: the file itself is still 0600.
|
|
50
|
+
if (process.platform !== 'win32') {
|
|
51
|
+
try {
|
|
52
|
+
await fs.chmod(dir, 0o700);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
azureLog('cache: could not tighten the sign-in cache folder permissions');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const tmp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
await fs.writeFile(tmp, data, { mode: 0o600, flag: 'wx' });
|
|
61
|
+
await fs.rename(tmp, file);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function asKey(stored) {
|
|
69
|
+
const decoded = stored ? Buffer.from(stored, 'base64') : null;
|
|
70
|
+
return decoded && decoded.length === KEY_BYTES ? decoded : null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Two processes can start on the same home at once. Rules that keep them on
|
|
74
|
+
* ONE key without deleting each other's file:
|
|
75
|
+
* - first use: store a new key, then read it back and adopt what is stored
|
|
76
|
+
* (another process may have written last);
|
|
77
|
+
* - before each write: re-read the stored key and adopt it if it changed;
|
|
78
|
+
* - a read that fails to decrypt: re-read the stored key once and retry
|
|
79
|
+
* before treating the file as damaged.
|
|
80
|
+
*/
|
|
81
|
+
export async function openCacheStore(deps = {}) {
|
|
82
|
+
const account = azureAccountForHome('cache-key');
|
|
83
|
+
let kc;
|
|
84
|
+
let key;
|
|
85
|
+
try {
|
|
86
|
+
kc = deps.keychain ?? (await defaultKeychain());
|
|
87
|
+
const existing = asKey(await kc.getPassword(AZURE_SERVICE, account));
|
|
88
|
+
if (existing)
|
|
89
|
+
key = existing;
|
|
90
|
+
else {
|
|
91
|
+
const fresh = (deps.random ?? randomBytes)(KEY_BYTES);
|
|
92
|
+
await kc.setPassword(AZURE_SERVICE, account, fresh.toString('base64'));
|
|
93
|
+
key = asKey(await kc.getPassword(AZURE_SERVICE, account)) ?? fresh;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
deps.notice?.(NO_STORE_NOTICE);
|
|
98
|
+
return { plugin: null, persisted: false };
|
|
99
|
+
}
|
|
100
|
+
/** The stored key when it is readable and different from ours; otherwise null. */
|
|
101
|
+
const storedIfChanged = async () => {
|
|
102
|
+
try {
|
|
103
|
+
const stored = asKey(await kc.getPassword(AZURE_SERVICE, account));
|
|
104
|
+
return stored && !stored.equals(key) ? stored : null;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const file = azureCachePath();
|
|
111
|
+
const plugin = {
|
|
112
|
+
async beforeCacheAccess(ctx) {
|
|
113
|
+
let blob;
|
|
114
|
+
try {
|
|
115
|
+
blob = await fs.readFile(file);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let plain = null;
|
|
121
|
+
try {
|
|
122
|
+
plain = decryptCache(blob, key);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
const stored = await storedIfChanged();
|
|
126
|
+
if (stored) {
|
|
127
|
+
try {
|
|
128
|
+
plain = decryptCache(blob, stored);
|
|
129
|
+
key = stored;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
plain = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (plain === null)
|
|
138
|
+
throw new Error('unreadable');
|
|
139
|
+
ctx.tokenCache.deserialize(plain);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Damaged, written under a key no longer stored, or unreadable to MSAL: start empty and drop it.
|
|
143
|
+
await fs.rm(file, { force: true }).catch(() => { });
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async afterCacheAccess(ctx) {
|
|
147
|
+
if (!ctx.cacheHasChanged)
|
|
148
|
+
return;
|
|
149
|
+
const stored = await storedIfChanged();
|
|
150
|
+
if (stored)
|
|
151
|
+
key = stored;
|
|
152
|
+
await writeAtomic(file, encryptCache(ctx.tokenCache.serialize(), key));
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
return { plugin, persisted: true };
|
|
156
|
+
}
|
|
157
|
+
/** Logout: the cache file and its key for THIS home. True when either existed. */
|
|
158
|
+
export async function clearAzureCache(deps = {}) {
|
|
159
|
+
const kc = deps.keychain ?? (await defaultKeychain());
|
|
160
|
+
let file = false;
|
|
161
|
+
try {
|
|
162
|
+
await fs.unlink(azureCachePath());
|
|
163
|
+
file = true;
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
if (e.code !== 'ENOENT')
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
const key = await kc.deletePassword(AZURE_SERVICE, azureAccountForHome('cache-key'));
|
|
170
|
+
return file || key;
|
|
171
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — what `cspeach logout` removes on top of today's
|
|
3
|
+
* entries: the company settings (Task 4), the Azure key (Task 5), the
|
|
4
|
+
* Microsoft sign-in cache file and its key (Task 6), and this home's local
|
|
5
|
+
* limit count, azure-usage-local.json (final fix wave E). Reports in plain words
|
|
6
|
+
* what it removed and what it could not.
|
|
7
|
+
*/
|
|
8
|
+
import { deleteConnectionCache } from '../../connection/cache.js';
|
|
9
|
+
import { deleteAzureKey } from './azure-key.js';
|
|
10
|
+
import { clearAzureCache } from './cache-store.js';
|
|
11
|
+
import { deleteLocalUsage } from '../../connection/local-usage.js';
|
|
12
|
+
export async function clearAzureSignIn(deps = {}) {
|
|
13
|
+
const out = { removed: [], failed: [] };
|
|
14
|
+
const step = async (label, run) => {
|
|
15
|
+
try {
|
|
16
|
+
if (await run())
|
|
17
|
+
out.removed.push(label);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
out.failed.push(label);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
await step("your company's Azure settings", deps.deleteConnection ?? deleteConnectionCache);
|
|
24
|
+
await step('your Azure key', deps.deleteKey ?? deleteAzureKey);
|
|
25
|
+
await step('your Microsoft sign-in', deps.clearCache ?? (() => clearAzureCache()));
|
|
26
|
+
await step('your Azure usage count on this machine', deps.deleteLocalCount ?? deleteLocalUsage);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — our own loopback listener for the browser sign-in
|
|
3
|
+
* (owner decision P5: it is ALWAYS closed when sign-in ends — success,
|
|
4
|
+
* error, timeout or abort). MSAL Node 6.0.1 gives no way to supply or close
|
|
5
|
+
* its own listener, so sign-in.ts uses this one with MSAL's public
|
|
6
|
+
* getAuthCodeUrl + acquireTokenByCode.
|
|
7
|
+
*
|
|
8
|
+
* - Loopback only: 127.0.0.1, plus ::1 on the SAME port when IPv6 is there
|
|
9
|
+
* (a browser may resolve localhost either way). Never 0.0.0.0 or '::'.
|
|
10
|
+
* IPv4 alone only when this machine has no IPv6 loopback; when the port is
|
|
11
|
+
* busy on ::1, up to 20 new ports, then LoopbackPortError (a plain line).
|
|
12
|
+
* - Redirect URI http://localhost:<port> (Entra matches any port for localhost).
|
|
13
|
+
* - Exactly one completing GET on "/": the state must match (single use) and
|
|
14
|
+
* the Host must be localhost / 127.0.0.1 / [::1] on our port. Anything else
|
|
15
|
+
* gets a 4xx and does not end the sign-in.
|
|
16
|
+
* - The page is short fixed HTML: no code, state, token or Microsoft text.
|
|
17
|
+
* - Nothing here logs. The code, state and redirect query never leave this
|
|
18
|
+
* module except as the resolved value.
|
|
19
|
+
*/
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
22
|
+
/** The signal fired (timeout or abort) before the browser came back. */
|
|
23
|
+
export class LoopbackCancelledError extends Error {
|
|
24
|
+
constructor() { super('The sign-in was stopped before the browser came back.'); this.name = 'LoopbackCancelledError'; }
|
|
25
|
+
}
|
|
26
|
+
/** Microsoft sent the browser back with `error`. `detail` is for classifying only — never log or print it. */
|
|
27
|
+
export class LoopbackAuthError extends Error {
|
|
28
|
+
errorCode;
|
|
29
|
+
detail;
|
|
30
|
+
constructor(errorCode, detail) {
|
|
31
|
+
super('Microsoft sent the sign-in back with an error.');
|
|
32
|
+
this.name = 'LoopbackAuthError';
|
|
33
|
+
this.errorCode = errorCode;
|
|
34
|
+
this.detail = detail;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** No port was free on both 127.0.0.1 and ::1 after every try. */
|
|
38
|
+
export class LoopbackPortError extends Error {
|
|
39
|
+
constructor() { super('Could not open a sign-in port on this computer. Try again, or use cspeach login azure --device-code.'); this.name = 'LoopbackPortError'; }
|
|
40
|
+
}
|
|
41
|
+
const IPV4 = '127.0.0.1';
|
|
42
|
+
const IPV6 = '::1';
|
|
43
|
+
/** ::1 cannot be used on this machine: no IPv6 loopback. */
|
|
44
|
+
const IPV6_UNAVAILABLE = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT', 'EINVAL', 'EPROTONOSUPPORT']);
|
|
45
|
+
const page = (text) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>CSPeach sign-in</title></head><body><p>${text}</p></body></html>`;
|
|
46
|
+
const PAGE_OK = page('Signed in. You can close this tab and go back to CSPeach.');
|
|
47
|
+
const PAGE_FAILED = page('Sign-in did not finish. You can close this tab and go back to CSPeach for the next step.');
|
|
48
|
+
const PAGE_NOT_OURS = page('This page is not part of the CSPeach sign-in. You can close this tab.');
|
|
49
|
+
function listen(server, port, host) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const onError = (e) => { server.off('listening', onListening); reject(e); };
|
|
52
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
53
|
+
server.once('error', onError);
|
|
54
|
+
server.once('listening', onListening);
|
|
55
|
+
server.listen({ port, host, exclusive: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function closeServer(server) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
if (!server.listening) {
|
|
61
|
+
server.closeAllConnections?.();
|
|
62
|
+
resolve();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
server.close(() => resolve());
|
|
66
|
+
server.closeAllConnections?.();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function sameSecret(a, b) {
|
|
70
|
+
const x = Buffer.from(a, 'utf8');
|
|
71
|
+
const y = Buffer.from(b, 'utf8');
|
|
72
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
73
|
+
}
|
|
74
|
+
/** Microsoft error codes are short snake_case words; anything else is reduced to a fixed word. */
|
|
75
|
+
function errorCodeOf(raw) {
|
|
76
|
+
return /^[a-z_]{1,64}$/.test(raw) ? raw : 'unknown_error';
|
|
77
|
+
}
|
|
78
|
+
async function bind(make, attempts) {
|
|
79
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
80
|
+
const v4 = make(IPV4);
|
|
81
|
+
await listen(v4, 0, IPV4);
|
|
82
|
+
const port = v4.address().port;
|
|
83
|
+
const v6 = make(IPV6);
|
|
84
|
+
try {
|
|
85
|
+
await listen(v6, port, IPV6);
|
|
86
|
+
return [v4, v6];
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
const code = e.code ?? '';
|
|
90
|
+
// IPv4 alone ONLY when this machine has no IPv6 loopback at all.
|
|
91
|
+
if (IPV6_UNAVAILABLE.has(code))
|
|
92
|
+
return [v4];
|
|
93
|
+
await closeServer(v4);
|
|
94
|
+
if (code !== 'EADDRINUSE')
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ::1 exists but no port was free on both: a browser that resolves
|
|
99
|
+
// localhost to ::1 would never reach an IPv4-only listener. Stop plainly.
|
|
100
|
+
throw new LoopbackPortError();
|
|
101
|
+
}
|
|
102
|
+
export async function startLoopback(o) {
|
|
103
|
+
if (o.signal?.aborted)
|
|
104
|
+
throw new LoopbackCancelledError();
|
|
105
|
+
const make = o.makeServer ?? (() => http.createServer());
|
|
106
|
+
const servers = await bind(make, o.maxAttempts ?? 20);
|
|
107
|
+
const port = servers[0].address().port;
|
|
108
|
+
const hosts = servers.map((s) => s.address().address);
|
|
109
|
+
const allowedHosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
|
|
110
|
+
let settle;
|
|
111
|
+
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
112
|
+
result.catch(() => { });
|
|
113
|
+
let done = false;
|
|
114
|
+
let closing = null;
|
|
115
|
+
const onAbort = () => {
|
|
116
|
+
if (done)
|
|
117
|
+
return;
|
|
118
|
+
done = true;
|
|
119
|
+
void close();
|
|
120
|
+
settle.reject(new LoopbackCancelledError());
|
|
121
|
+
};
|
|
122
|
+
const close = () => {
|
|
123
|
+
if (!closing) {
|
|
124
|
+
o.signal?.removeEventListener('abort', onAbort);
|
|
125
|
+
closing = Promise.all(servers.map(closeServer)).then(() => undefined);
|
|
126
|
+
if (!done) {
|
|
127
|
+
done = true;
|
|
128
|
+
settle.reject(new LoopbackCancelledError());
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return closing;
|
|
132
|
+
};
|
|
133
|
+
const handler = (req, res) => {
|
|
134
|
+
const send = (status, body) => {
|
|
135
|
+
res.writeHead(status, {
|
|
136
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
137
|
+
'Cache-Control': 'no-store',
|
|
138
|
+
'Referrer-Policy': 'no-referrer',
|
|
139
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
140
|
+
'X-Content-Type-Options': 'nosniff',
|
|
141
|
+
Connection: 'close',
|
|
142
|
+
});
|
|
143
|
+
res.end(body);
|
|
144
|
+
};
|
|
145
|
+
if (done)
|
|
146
|
+
return send(400, PAGE_NOT_OURS);
|
|
147
|
+
if (req.method !== 'GET')
|
|
148
|
+
return send(405, PAGE_NOT_OURS);
|
|
149
|
+
if (!allowedHosts.has((req.headers.host ?? '').toLowerCase()))
|
|
150
|
+
return send(400, PAGE_NOT_OURS);
|
|
151
|
+
let url;
|
|
152
|
+
try {
|
|
153
|
+
url = new URL(req.url ?? '', 'http://localhost');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return send(400, PAGE_NOT_OURS);
|
|
157
|
+
}
|
|
158
|
+
if (url.pathname !== '/')
|
|
159
|
+
return send(404, PAGE_NOT_OURS);
|
|
160
|
+
const states = url.searchParams.getAll('state');
|
|
161
|
+
if (states.length !== 1 || !sameSecret(states[0], o.state))
|
|
162
|
+
return send(400, PAGE_NOT_OURS);
|
|
163
|
+
const error = url.searchParams.get('error');
|
|
164
|
+
if (error) {
|
|
165
|
+
done = true;
|
|
166
|
+
send(200, PAGE_FAILED);
|
|
167
|
+
settle.reject(new LoopbackAuthError(errorCodeOf(error), (url.searchParams.get('error_description') ?? '').slice(0, 500)));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const codes = url.searchParams.getAll('code');
|
|
171
|
+
if (codes.length !== 1 || !codes[0])
|
|
172
|
+
return send(400, PAGE_NOT_OURS);
|
|
173
|
+
done = true;
|
|
174
|
+
send(200, PAGE_OK);
|
|
175
|
+
const clientInfo = url.searchParams.get('client_info');
|
|
176
|
+
settle.resolve({ code: codes[0], ...(clientInfo ? { clientInfo } : {}) });
|
|
177
|
+
};
|
|
178
|
+
for (const s of servers)
|
|
179
|
+
s.on('request', handler);
|
|
180
|
+
o.signal?.addEventListener('abort', onAbort, { once: true });
|
|
181
|
+
if (o.signal?.aborted)
|
|
182
|
+
onAbort();
|
|
183
|
+
return {
|
|
184
|
+
redirectUri: `http://localhost:${port}`,
|
|
185
|
+
port,
|
|
186
|
+
hosts,
|
|
187
|
+
boundAddresses: () => servers.filter((s) => s.listening).map((s) => s.address()),
|
|
188
|
+
isListening: () => servers.some((s) => s.listening),
|
|
189
|
+
waitForCode: () => result,
|
|
190
|
+
close,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const createMsal = async (o) => {
|
|
2
|
+
const msal = await import('@azure/msal-node');
|
|
3
|
+
const pca = new msal.PublicClientApplication({
|
|
4
|
+
auth: { clientId: o.clientId, authority: `https://login.microsoftonline.com/${o.tenantId}` },
|
|
5
|
+
...(o.cachePlugin ? { cache: { cachePlugin: o.cachePlugin } } : {}),
|
|
6
|
+
system: {
|
|
7
|
+
loggerOptions: {
|
|
8
|
+
logLevel: msal.LogLevel.Error,
|
|
9
|
+
piiLoggingEnabled: false,
|
|
10
|
+
loggerCallback: (_level, message, containsPii) => { if (!containsPii)
|
|
11
|
+
o.log(message); },
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
const crypto = new msal.CryptoProvider();
|
|
16
|
+
const app = {
|
|
17
|
+
generatePkceCodes: () => crypto.generatePkceCodes(),
|
|
18
|
+
getAuthCodeUrl: (r) => pca.getAuthCodeUrl(r),
|
|
19
|
+
acquireTokenByCode: async (r) => (await pca.acquireTokenByCode(r)),
|
|
20
|
+
acquireTokenByDeviceCode: async (r) => (await pca.acquireTokenByDeviceCode(r)),
|
|
21
|
+
acquireTokenSilent: async (r) => (await pca.acquireTokenSilent(r)),
|
|
22
|
+
getAllAccounts: async () => (await pca.getAllAccounts()),
|
|
23
|
+
removeAccount: (a) => pca.getTokenCache().removeAccount(a),
|
|
24
|
+
};
|
|
25
|
+
return app;
|
|
26
|
+
};
|
|
27
|
+
const INTERACTION_CODES = new Set([
|
|
28
|
+
'interaction_required', 'invalid_grant', 'login_required', 'consent_required',
|
|
29
|
+
'no_tokens_found', 'no_account_in_silent_request', 'token_refresh_required',
|
|
30
|
+
]);
|
|
31
|
+
export function isInteractionRequired(err) {
|
|
32
|
+
const e = err;
|
|
33
|
+
return e?.name === 'InteractionRequiredAuthError' || (typeof e?.errorCode === 'string' && INTERACTION_CODES.has(e.errorCode));
|
|
34
|
+
}
|
|
35
|
+
export function tenantOf(r) {
|
|
36
|
+
const t = r.tenantId ?? (typeof r.idTokenClaims?.tid === 'string' ? r.idTokenClaims.tid : undefined) ?? r.account?.tenantId;
|
|
37
|
+
return typeof t === 'string' && t ? t.toLowerCase() : null;
|
|
38
|
+
}
|
|
39
|
+
export class AzureWrongTenantError extends Error {
|
|
40
|
+
constructor() { super('This Microsoft account belongs to a different company directory.'); this.name = 'AzureWrongTenantError'; }
|
|
41
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — the Microsoft sign-in. Browser by default: our own
|
|
3
|
+
* loopback listener (loopback.ts) + MSAL's getAuthCodeUrl with PKCE +
|
|
4
|
+
* acquireTokenByCode. Device code behind --device-code (Conditional Access
|
|
5
|
+
* often blocks it, so it is the fallback). Five minutes, then a plain line.
|
|
6
|
+
* The listener is closed in a finally on success, error, timeout and abort
|
|
7
|
+
* (owner decision P5). Never called mid-turn.
|
|
8
|
+
*
|
|
9
|
+
* Never logged or printed: the authorization code, the PKCE verifier, the
|
|
10
|
+
* redirect query, tokens, Microsoft's error description. The authorize URL
|
|
11
|
+
* is printed only when the browser did not open (the person has to copy it).
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { formatAttention, formatFailure, formatInfo } from '../../renderer/notice-shape.js';
|
|
15
|
+
import { sanitizeControlChars } from '../../renderer/sanitize.js';
|
|
16
|
+
import { AZURE_FACTS, clientIdConfigured } from '../../connection/azure-facts.js';
|
|
17
|
+
import { AzureSignInRequiredError, selectAzureAuth } from '../../connection/azure-auth.js';
|
|
18
|
+
import { azureLog } from './azure-log.js';
|
|
19
|
+
import { msalFor, entraForSelect, accountsForTenant } from './token-provider.js';
|
|
20
|
+
import { startLoopback, LoopbackAuthError, LoopbackCancelledError, LoopbackPortError } from './loopback.js';
|
|
21
|
+
import { tenantOf, AzureWrongTenantError } from './msal-client.js';
|
|
22
|
+
export const SIGN_IN_TIMEOUT_MS = 300_000;
|
|
23
|
+
const WRONG_DIRECTORY = { what: 'This Microsoft account belongs to a different company directory.', next: 'sign in with your work account: cspeach login azure' };
|
|
24
|
+
function explain(text, deviceCode) {
|
|
25
|
+
if (/AADSTS65001|consent_required/i.test(text))
|
|
26
|
+
return { what: "Your company's admin has not approved CSPeach in Microsoft Entra yet.", next: 'ask IT to approve CSPeach (the IT guide has the link)' };
|
|
27
|
+
if (/AADSTS53003|AADSTS530003|AADSTS50097|AADSTS50158|access_denied/i.test(text)) {
|
|
28
|
+
return { what: "Your company's sign-in rules blocked this sign-in.", next: deviceCode ? 'cspeach login azure (opens your browser)' : 'ask IT which sign-in your company allows' };
|
|
29
|
+
}
|
|
30
|
+
if (/AADSTS50020|AADSTS90072/i.test(text))
|
|
31
|
+
return WRONG_DIRECTORY;
|
|
32
|
+
return { what: 'Microsoft sign-in failed.', next: 'cspeach login azure' };
|
|
33
|
+
}
|
|
34
|
+
/** Rejects with LoopbackCancelledError as soon as `signal` fires; the work itself is left to the caller to stop. */
|
|
35
|
+
function untilStopped(work, signal) {
|
|
36
|
+
if (signal.aborted) {
|
|
37
|
+
work.catch(() => { });
|
|
38
|
+
return Promise.reject(new LoopbackCancelledError());
|
|
39
|
+
}
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const onAbort = () => reject(new LoopbackCancelledError());
|
|
42
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
43
|
+
work.then((v) => { signal.removeEventListener('abort', onAbort); resolve(v); }, (e) => { signal.removeEventListener('abort', onAbort); reject(e); });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A token request still running when the sign-in stopped may finish later, and
|
|
48
|
+
* MSAL then caches it. The person cancelled, so an account that was NOT here
|
|
49
|
+
* before this sign-in is removed again. An account that was already signed in
|
|
50
|
+
* stays (its tokens were only renewed).
|
|
51
|
+
*/
|
|
52
|
+
function discardLate(app, work, before) {
|
|
53
|
+
work.then(async (r) => {
|
|
54
|
+
const a = r?.account;
|
|
55
|
+
if (a && !before.has(a.homeAccountId)) {
|
|
56
|
+
// Residual (ledger): MSAL has already written this token to the
|
|
57
|
+
// encrypted cache before removeAccount runs. A failed removal leaves
|
|
58
|
+
// that account cached on this machine, with one log line; the next
|
|
59
|
+
// successful sign-in for the company keeps only its own account
|
|
60
|
+
// (keepOnly), and `cspeach logout` deletes the cache.
|
|
61
|
+
// Its own catch: a failed removal is not "the late request failed".
|
|
62
|
+
try {
|
|
63
|
+
await app.removeAccount(a);
|
|
64
|
+
azureLog('sign-in: removed an account that finished after the sign-in was stopped');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
azureLog('sign-in: could not remove an account that finished after the sign-in was stopped');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}).catch(() => { });
|
|
71
|
+
}
|
|
72
|
+
/** Runs the token request; a stop (timeout or abort) while it runs, or right after, never keeps a new account. */
|
|
73
|
+
async function finishOrDiscard(app, work, before, signal) {
|
|
74
|
+
let r;
|
|
75
|
+
try {
|
|
76
|
+
r = await untilStopped(work, signal);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e instanceof LoopbackCancelledError)
|
|
80
|
+
discardLate(app, work, before);
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
if (signal.aborted) {
|
|
84
|
+
discardLate(app, work, before);
|
|
85
|
+
throw new LoopbackCancelledError();
|
|
86
|
+
}
|
|
87
|
+
return r;
|
|
88
|
+
}
|
|
89
|
+
async function accountIds(app, signal) {
|
|
90
|
+
return new Set((await untilStopped(app.getAllAccounts(), signal)).map((a) => a.homeAccountId));
|
|
91
|
+
}
|
|
92
|
+
async function browserSignIn(app, signal, deps) {
|
|
93
|
+
const scopes = [AZURE_FACTS.entraScope];
|
|
94
|
+
const before = await accountIds(app, signal);
|
|
95
|
+
const pkce = await untilStopped(app.generatePkceCodes(), signal);
|
|
96
|
+
const state = randomBytes(32).toString('base64url');
|
|
97
|
+
const listener = await (deps.loopback ?? startLoopback)({ state, signal });
|
|
98
|
+
try {
|
|
99
|
+
const url = await untilStopped(app.getAuthCodeUrl({ scopes, redirectUri: listener.redirectUri, codeChallenge: pkce.challenge, codeChallengeMethod: 'S256', state, responseMode: 'query' }), signal);
|
|
100
|
+
// Said here, just before the browser is asked to open: never when the
|
|
101
|
+
// sign-in refuses first (client id not recorded, no port) or uses a device code.
|
|
102
|
+
deps.print(formatInfo('Opening your browser to sign in to Microsoft').trimEnd());
|
|
103
|
+
await untilStopped(deps.open(url).catch(() => {
|
|
104
|
+
// The address goes on its OWN line, whole: never wrapped into a
|
|
105
|
+
// Next: row, never shortened (it is often 500+ characters).
|
|
106
|
+
deps.print(formatAttention({ text: "The browser didn't open", next: 'open the address below in your browser yourself' }).trimEnd());
|
|
107
|
+
deps.print(url);
|
|
108
|
+
}), signal);
|
|
109
|
+
const { code, clientInfo } = await listener.waitForCode();
|
|
110
|
+
const exchange = app.acquireTokenByCode({ scopes, redirectUri: listener.redirectUri, code, codeVerifier: pkce.verifier, ...(clientInfo ? { clientInfo } : {}) });
|
|
111
|
+
return await finishOrDiscard(app, exchange, before, signal);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await listener.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function deviceCodeSignIn(app, signal, deps) {
|
|
118
|
+
const before = await accountIds(app, signal);
|
|
119
|
+
const request = {
|
|
120
|
+
scopes: [AZURE_FACTS.entraScope],
|
|
121
|
+
deviceCodeCallback: (i) => deps.print(formatInfo(sanitizeControlChars(i.message)).trimEnd()),
|
|
122
|
+
};
|
|
123
|
+
// MSAL stops polling when `cancel` turns true on this same object.
|
|
124
|
+
const stop = () => { request.cancel = true; };
|
|
125
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
126
|
+
try {
|
|
127
|
+
return await finishOrDiscard(app, app.acquireTokenByDeviceCode(request), before, signal);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
signal.removeEventListener('abort', stop);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** After a good sign-in: other accounts for the same tenant would make every request ambiguous. */
|
|
134
|
+
async function keepOnly(app, tenantId, keep) {
|
|
135
|
+
try {
|
|
136
|
+
for (const a of await accountsForTenant(app, tenantId))
|
|
137
|
+
if (a.homeAccountId !== keep)
|
|
138
|
+
await app.removeAccount(a);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
azureLog('sign-in: could not remove an older account for this company');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export async function signInAzure(conn, opts, deps) {
|
|
145
|
+
if (!(deps.clientIdReady ?? clientIdConfigured)()) {
|
|
146
|
+
return { ok: false, what: 'Microsoft sign-in is not set up in this CSPeach build yet.', next: conn.sign_in.includes('key') ? 'ask IT for an Azure key, then cspeach config set llm.azure_key -' : 'contact us at info@cremencing.com' };
|
|
147
|
+
}
|
|
148
|
+
// One signal for both ways a sign-in stops: the five minutes, or the person.
|
|
149
|
+
const stopper = new AbortController();
|
|
150
|
+
const timer = setTimeout(() => stopper.abort(), deps.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
const onUserAbort = () => stopper.abort();
|
|
153
|
+
if (deps.signal?.aborted)
|
|
154
|
+
stopper.abort();
|
|
155
|
+
else
|
|
156
|
+
deps.signal?.addEventListener('abort', onUserAbort, { once: true });
|
|
157
|
+
const stopped = () => (deps.signal?.aborted
|
|
158
|
+
? { ok: false, what: 'Microsoft sign-in was cancelled.', next: 'cspeach login azure' }
|
|
159
|
+
: { ok: false, what: 'Microsoft sign-in did not finish.', next: opts.deviceCode ? 'cspeach login azure' : 'cspeach login azure --device-code' });
|
|
160
|
+
try {
|
|
161
|
+
const app = await untilStopped((deps.msal ?? (() => msalFor(conn.entra_tenant_id)))(), stopper.signal);
|
|
162
|
+
const r = opts.deviceCode ? await deviceCodeSignIn(app, stopper.signal, deps) : await browserSignIn(app, stopper.signal, deps);
|
|
163
|
+
if (!r)
|
|
164
|
+
return { ok: false, what: 'Microsoft sign-in did not finish.', next: 'cspeach login azure' };
|
|
165
|
+
if (tenantOf(r) !== conn.entra_tenant_id.toLowerCase()) {
|
|
166
|
+
if (r.account)
|
|
167
|
+
await app.removeAccount(r.account).catch(() => { });
|
|
168
|
+
return { ok: false, ...WRONG_DIRECTORY };
|
|
169
|
+
}
|
|
170
|
+
if (r.account)
|
|
171
|
+
await keepOnly(app, conn.entra_tenant_id, r.account.homeAccountId);
|
|
172
|
+
return { ok: true, username: r.account?.username ?? 'your account', expiresOn: r.expiresOn };
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err instanceof LoopbackCancelledError) {
|
|
176
|
+
azureLog(deps.signal?.aborted ? 'sign-in cancelled by the person' : 'sign-in timed out');
|
|
177
|
+
return stopped();
|
|
178
|
+
}
|
|
179
|
+
if (err instanceof LoopbackPortError) {
|
|
180
|
+
azureLog('sign-in: no loopback port free on both addresses');
|
|
181
|
+
return { ok: false, what: 'Could not open a sign-in port on this computer.', next: 'try again, or use cspeach login azure --device-code' };
|
|
182
|
+
}
|
|
183
|
+
if (err instanceof LoopbackAuthError) {
|
|
184
|
+
// Microsoft's description is used to pick the plain line, never logged.
|
|
185
|
+
azureLog(`sign-in: Microsoft returned error ${err.errorCode}`);
|
|
186
|
+
return { ok: false, ...explain(`${err.errorCode} ${err.detail}`, opts.deviceCode) };
|
|
187
|
+
}
|
|
188
|
+
const e = err;
|
|
189
|
+
azureLog(`sign-in failed: ${e?.name ?? 'Error'}${e?.errorCode ? ` (${e.errorCode})` : ''}: ${e?.message ?? String(err)}`);
|
|
190
|
+
return { ok: false, ...explain(`${e?.errorCode ?? ''} ${e?.message ?? ''}`, opts.deviceCode) };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
deps.signal?.removeEventListener('abort', onUserAbort);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** REPL start (asks first) and one-shot (never asks). */
|
|
198
|
+
export async function ensureAzureSignIn(conn, o) {
|
|
199
|
+
const provider = await (o.select ?? ((c) => selectAzureAuth(c, { entra: entraForSelect })))(conn);
|
|
200
|
+
try {
|
|
201
|
+
await provider.getAuth();
|
|
202
|
+
return { ok: true };
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
// A token from another directory: the same path as an ended sign-in (describeEntraSignIn says needs-sign-in too).
|
|
206
|
+
const wrongDirectory = e instanceof AzureWrongTenantError;
|
|
207
|
+
if (!wrongDirectory && !(e instanceof AzureSignInRequiredError)) {
|
|
208
|
+
o.print(formatInfo('Could not check your Microsoft sign-in just now · CSPeach will try again on the first request').trimEnd());
|
|
209
|
+
return { ok: true };
|
|
210
|
+
}
|
|
211
|
+
if (!wrongDirectory && e.method === 'key')
|
|
212
|
+
return { ok: false, message: formatFailure({ what: 'Your Azure key is not set on this machine.', next: 'ask IT for a current key, then cspeach config set llm.azure_key -' }) };
|
|
213
|
+
if (!o.interactive || !(await o.ask('Sign in to Microsoft now?'))) {
|
|
214
|
+
return { ok: false, message: formatFailure(wrongDirectory ? WRONG_DIRECTORY : { what: e.message, next: 'cspeach login azure' }) };
|
|
215
|
+
}
|
|
216
|
+
const r = await (o.signIn ?? signInAzure)(conn, { deviceCode: false }, { open: o.open, print: o.print, ...(o.signal ? { signal: o.signal } : {}) });
|
|
217
|
+
return r.ok ? { ok: true } : { ok: false, message: formatFailure({ what: r.what, next: r.next }) };
|
|
218
|
+
}
|
|
219
|
+
}
|