@cspeach/cli 1.1.33 → 1.1.35

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.
Files changed (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Azure tenant mode, Task 6b — the Microsoft sign-in that starts with the
3
+ * REPL. The question comes first (inquirer owns the keyboard then); only
4
+ * while the sign-in itself runs does Esc or Ctrl+C stop it, through the
5
+ * SAME signal ensureAzureSignIn hands to signInAzure, so the loopback
6
+ * listener closes at once (owner decision: the listener is always closed).
7
+ * The keyboard is handed back exactly as it was found.
8
+ */
9
+ import { ensureAzureSignIn, signInAzure } from './sign-in.js';
10
+ import { askOrNo } from '../../repl/ask-or-no.js';
11
+ const CTRL_C = 0x03;
12
+ const ESC = 0x1b;
13
+ /** Ctrl+C anywhere in the chunk, or a lone Esc (an arrow key is Esc + more bytes). */
14
+ export function isStopKey(chunk) {
15
+ const b = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
16
+ return b.includes(CTRL_C) || (b.length === 1 && b[0] === ESC);
17
+ }
18
+ /** Aborts `ac` on Esc / Ctrl+C (raw TTY) or SIGINT until released. */
19
+ export function armStopKeys(ac, input, signals) {
20
+ const stop = () => { if (!ac.signal.aborted)
21
+ ac.abort(); };
22
+ const onData = (chunk) => { if (isStopKey(chunk))
23
+ stop(); };
24
+ const raw = input.isTTY === true && typeof input.setRawMode === 'function';
25
+ const wasRaw = input.isRaw === true;
26
+ const wasFlowing = input.readableFlowing === true;
27
+ signals.on('SIGINT', stop);
28
+ if (raw) {
29
+ input.setRawMode(true);
30
+ input.on('data', onData);
31
+ input.resume();
32
+ }
33
+ return () => {
34
+ signals.removeListener('SIGINT', stop);
35
+ if (raw) {
36
+ input.removeListener('data', onData);
37
+ input.setRawMode(wasRaw);
38
+ // Hand the keyboard back as found: still flowing if something else was
39
+ // reading it, paused otherwise (readline / Ink resume it themselves).
40
+ if (!wasFlowing)
41
+ input.pause();
42
+ }
43
+ };
44
+ }
45
+ export async function azureSignInAtStart(conn, o) {
46
+ const ac = new AbortController();
47
+ const input = o.input ?? process.stdin;
48
+ const signals = o.signals ?? process;
49
+ const run = o.signIn ?? signInAzure;
50
+ return (o.ensure ?? ensureAzureSignIn)(conn, {
51
+ // Ctrl+C at the question (ExitPromptError) is a no.
52
+ interactive: true, ask: askOrNo(o.ask), print: o.print, open: o.open, signal: ac.signal,
53
+ // The keys are armed only while the sign-in runs, never during the question.
54
+ signIn: async (c, opts, deps) => {
55
+ const release = armStopKeys(ac, input, signals);
56
+ try {
57
+ return await run(c, opts, deps);
58
+ }
59
+ finally {
60
+ release();
61
+ }
62
+ },
63
+ });
64
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Azure tenant mode — Entra tokens for the Foundry endpoint. Silent before
3
+ * EVERY request (MSAL serves the cached token, refreshing with the refresh
4
+ * token near expiry); a token with < 120 s left by this laptop's clock is
5
+ * force-refreshed (plan D12). Never interactive here: interaction required ⇒
6
+ * AzureSignInRequiredError ("Your Microsoft sign-in has ended."). A token
7
+ * from another tenant is refused. No token ever goes into an error or a log.
8
+ */
9
+ import { AZURE_FACTS } from '../../connection/azure-facts.js';
10
+ import { AzureSignInRequiredError } from '../../connection/azure-auth.js';
11
+ import { cspeachRoot } from '../../config/paths.js';
12
+ import { getAzureKey } from './azure-key.js';
13
+ import { openCacheStore } from './cache-store.js';
14
+ import { azureLog } from './azure-log.js';
15
+ import { createMsal, isInteractionRequired, tenantOf, AzureWrongTenantError } from './msal-client.js';
16
+ export const NEAR_EXPIRY_MS = 120_000;
17
+ const apps = new Map();
18
+ /** One MSAL client per CSPeach home and tenant per process, with that home's encrypted cache. */
19
+ export function msalFor(tenantId, o = {}) {
20
+ const slot = `${cspeachRoot()}|${tenantId.toLowerCase()}`;
21
+ let p = apps.get(slot);
22
+ if (!p) {
23
+ p = (async () => {
24
+ const { plugin } = await (o.store ?? (() => openCacheStore({ notice: o.notice })))();
25
+ return (o.factory ?? createMsal)({ clientId: AZURE_FACTS.clientId, tenantId, cachePlugin: plugin, log: azureLog });
26
+ })();
27
+ apps.set(slot, p);
28
+ p.catch(() => apps.delete(slot));
29
+ }
30
+ return p;
31
+ }
32
+ const appFor = (conn, deps) => (deps.msal ?? (() => msalFor(conn.entra_tenant_id)))();
33
+ /** Every cached account for this tenant (MSAL's raw objects). */
34
+ export async function accountsForTenant(app, tenantId) {
35
+ const t = tenantId.toLowerCase();
36
+ return (await app.getAllAccounts()).filter((a) => (a.tenantId ?? '').toLowerCase() === t);
37
+ }
38
+ /** More than one account for the company's tenant: never guess which one. A fresh `cspeach login azure` keeps only the one signed in. */
39
+ export class AzureSeveralAccountsError extends AzureSignInRequiredError {
40
+ constructor() {
41
+ super('entra');
42
+ this.message = 'More than one Microsoft account is signed in for your company on this computer.';
43
+ this.name = 'AzureSeveralAccountsError';
44
+ }
45
+ }
46
+ async function accountFor(app, tenantId) {
47
+ const found = await accountsForTenant(app, tenantId);
48
+ if (found.length > 1)
49
+ throw new AzureSeveralAccountsError();
50
+ return found[0] ?? null;
51
+ }
52
+ const scopes = () => [AZURE_FACTS.entraScope];
53
+ async function silent(app, account, forceRefresh) {
54
+ try {
55
+ return await app.acquireTokenSilent({ scopes: scopes(), account, ...(forceRefresh ? { forceRefresh: true } : {}) });
56
+ }
57
+ catch (e) {
58
+ if (isInteractionRequired(e))
59
+ throw new AzureSignInRequiredError('entra');
60
+ throw e;
61
+ }
62
+ }
63
+ export function createEntraAuthProvider(conn, deps = {}) {
64
+ const now = deps.now ?? Date.now;
65
+ return {
66
+ method: 'entra',
67
+ async getAuth(opts = {}) {
68
+ const a = await appFor(conn, deps);
69
+ const account = await accountFor(a, conn.entra_tenant_id);
70
+ if (!account)
71
+ throw new AzureSignInRequiredError('entra');
72
+ let r = await silent(a, account, opts.forceRefresh === true);
73
+ if (!opts.forceRefresh && r.expiresOn && r.expiresOn.getTime() - now() < NEAR_EXPIRY_MS)
74
+ r = await silent(a, account, true);
75
+ if (tenantOf(r) !== conn.entra_tenant_id.toLowerCase())
76
+ throw new AzureWrongTenantError();
77
+ return { kind: 'entra', token: r.accessToken };
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * SelectAuthDeps.entra: Entra when this machine has an Entra account (or
83
+ * several) for the tenant; when it has none and the company allows keys and a key is stored,
84
+ * null (use the key); otherwise Entra (which asks for sign-in).
85
+ */
86
+ export async function entraForSelect(conn, deps = {}) {
87
+ const provider = createEntraAuthProvider(conn, deps);
88
+ let hasAccount = false;
89
+ try {
90
+ hasAccount = (await accountsForTenant(await appFor(conn, deps), conn.entra_tenant_id)).length > 0;
91
+ }
92
+ catch {
93
+ hasAccount = false;
94
+ }
95
+ if (hasAccount)
96
+ return provider;
97
+ if (conn.sign_in.includes('key') && (await (deps.getKey ?? getAzureKey)().catch(() => null)))
98
+ return null;
99
+ return provider;
100
+ }
101
+ /** whoami / doctor: the account and token expiry, silently. */
102
+ export async function describeEntraSignIn(conn, deps = {}) {
103
+ try {
104
+ const a = await appFor(conn, deps);
105
+ const account = await accountFor(a, conn.entra_tenant_id);
106
+ if (!account)
107
+ return { state: 'needs-sign-in' };
108
+ const r = await silent(a, account, false);
109
+ if (tenantOf(r) !== conn.entra_tenant_id.toLowerCase())
110
+ return { state: 'needs-sign-in' };
111
+ return { state: 'signed-in', username: account.username, expiresOn: r.expiresOn };
112
+ }
113
+ catch {
114
+ return { state: 'needs-sign-in' };
115
+ }
116
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Security batch item 6 — the ONE CSPeach key resolver. Every reader (REPL
3
+ * requests, banner, whoami, logout, licence check, usage reporter) reaches it
4
+ * through getStore().get() (api-key.ts).
5
+ *
6
+ * Where the key lives:
7
+ * - the OS credential store, service `cspeach`, one account per CSPeach
8
+ * home: `api-key` for the default ~/.cspeach, `api-key:<12 hex of
9
+ * sha256(home)>` for any other CSPEACH_HOME — two homes never share one;
10
+ * - auth.json `apiKey` only where no credential store works, or where an
11
+ * older CLI left it (moved at start: copy → read back → only then remove).
12
+ *
13
+ * When both hold DIFFERENT keys, the newer login wins:
14
+ * - auth.json carries `savedAt` (this CLI wrote it while the store was
15
+ * unavailable) newer than the last store write → auth.json;
16
+ * - auth.json has no `savedAt` (an older CLI wrote it) → auth.json only when
17
+ * key-store.json proves our store write came BEFORE the file changed;
18
+ * - otherwise the credential store — the key REPL requests already used
19
+ * before this batch (managed stays byte-identical) — plus one notice.
20
+ *
21
+ * key-store.json (beside auth.json) holds { account, storedAt, legacyChecked }:
22
+ * no secret.
23
+ */
24
+ import crypto from 'node:crypto';
25
+ import path from 'node:path';
26
+ import { promises as fs } from 'node:fs';
27
+ import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
28
+ import { readAuthFile, writeAuthFile, deleteAuthFile, authFileMtimeMs, authFileDir } from './auth-file.js';
29
+ import { licenceCachePath } from '../licence/paths.js';
30
+ export const KEYCHAIN_SERVICE = 'cspeach';
31
+ export const DEFAULT_ACCOUNT = 'api-key';
32
+ async function defaultIo() {
33
+ const { default: keychain } = await import('./keychain.js');
34
+ return {
35
+ get: (a) => keychain.getPassword(KEYCHAIN_SERVICE, a),
36
+ set: (a, k) => keychain.setPassword(KEYCHAIN_SERVICE, a, k),
37
+ del: (a) => keychain.deletePassword(KEYCHAIN_SERVICE, a),
38
+ };
39
+ }
40
+ function normHome(p) {
41
+ const r = path.resolve(p).replace(/[\\/]+$/, '');
42
+ return process.platform === 'win32' ? r.toLowerCase() : r;
43
+ }
44
+ export function keychainAccountForHome(home = cspeachRoot(), defaultHome = defaultCspeachRoot()) {
45
+ const h = normHome(home);
46
+ if (h === normHome(defaultHome))
47
+ return DEFAULT_ACCOUNT;
48
+ return `${DEFAULT_ACCOUNT}:${crypto.createHash('sha256').update(h).digest('hex').slice(0, 12)}`;
49
+ }
50
+ const metaPath = () => path.join(authFileDir(), 'key-store.json');
51
+ async function readMeta() {
52
+ try {
53
+ const m = JSON.parse(await fs.readFile(metaPath(), 'utf-8'));
54
+ if (typeof m.account !== 'string')
55
+ return null;
56
+ return { account: m.account, storedAt: typeof m.storedAt === 'string' ? m.storedAt : null, legacyChecked: m.legacyChecked === true };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function writeMeta(m) {
63
+ const p = metaPath();
64
+ await fs.mkdir(path.dirname(p), { recursive: true });
65
+ // Unique per write: two writes in one process must never share a temp file.
66
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
67
+ await fs.writeFile(tmp, JSON.stringify(m, null, 2), { encoding: 'utf-8', mode: 0o600 });
68
+ try {
69
+ await fs.rename(tmp, p);
70
+ }
71
+ finally {
72
+ await fs.rm(tmp, { force: true }).catch(() => { });
73
+ }
74
+ }
75
+ /** Note that this CLI just wrote the credential store (drives "newer login wins"). */
76
+ export async function recordStoreWrite(account = keychainAccountForHome(), at = new Date().toISOString()) {
77
+ const prev = await readMeta();
78
+ await writeMeta({ account, storedAt: at, legacyChecked: prev?.legacyChecked ?? false });
79
+ }
80
+ export function pickKey(i) {
81
+ const store = i.store?.trim() || null;
82
+ const fileKey = i.auth?.apiKey?.trim() || null;
83
+ if (!store && !fileKey)
84
+ return { key: null, source: 'none', conflict: false };
85
+ if (store && !fileKey)
86
+ return { key: store, source: 'store', conflict: false };
87
+ if (!store && fileKey)
88
+ return { key: fileKey, source: 'auth-file', conflict: false };
89
+ if (store === fileKey)
90
+ return { key: store, source: 'store', conflict: false };
91
+ const storedAtMs = i.storedAt ? Date.parse(i.storedAt) : NaN;
92
+ const fileNewer = i.auth?.savedAt
93
+ ? !Number.isFinite(storedAtMs) || Date.parse(i.auth.savedAt) > storedAtMs
94
+ : Number.isFinite(storedAtMs) && i.authMtimeMs !== null && i.authMtimeMs > storedAtMs;
95
+ return fileNewer
96
+ ? { key: fileKey, source: 'auth-file', conflict: false }
97
+ : { key: store, source: 'store', conflict: true };
98
+ }
99
+ export async function resolveApiKey(deps = {}) {
100
+ let store = null;
101
+ try {
102
+ store = await (deps.io ?? (await defaultIo())).get(keychainAccountForHome());
103
+ }
104
+ catch {
105
+ store = null; // no credential store on this machine: auth.json is the fallback
106
+ }
107
+ const [auth, authMtimeMs, meta] = await Promise.all([readAuthFile(), authFileMtimeMs(), readMeta()]);
108
+ return pickKey({ store, auth, authMtimeMs, storedAt: meta?.storedAt ?? null });
109
+ }
110
+ const exists = async (p) => fs.access(p).then(() => true, () => false);
111
+ /**
112
+ * Run once at start (cli.ts). Idempotent. The common start is one file read
113
+ * and no keychain call: there is nothing to move.
114
+ */
115
+ export async function migrateSignIn(deps = {}) {
116
+ const now = deps.now ?? (() => new Date());
117
+ const account = keychainAccountForHome();
118
+ const auth = await readAuthFile();
119
+ const meta = await readMeta();
120
+ // (a) A non-default home that predates per-home accounts kept its key in the
121
+ // shared `api-key` entry. Copy it once — only for a home already in use, so a
122
+ // brand-new home never inherits another seat's key. Fix round 1 (F1): a home
123
+ // whose auth.json holds its own key has no claim on the shared key — that
124
+ // key is this home's sign-in, moved in (b).
125
+ const needsLegacyCheck = account !== DEFAULT_ACCOUNT && !meta?.legacyChecked && !auth?.apiKey
126
+ && (auth !== null || await exists(path.join(cspeachRoot(), 'config.toml')));
127
+ if (!auth?.apiKey && !needsLegacyCheck)
128
+ return 'none';
129
+ let io;
130
+ try {
131
+ io = deps.io ?? (await defaultIo());
132
+ }
133
+ catch {
134
+ return auth?.apiKey ? 'store-unavailable' : 'none';
135
+ }
136
+ let outcome = 'none';
137
+ try {
138
+ if (needsLegacyCheck) {
139
+ if (!(await io.get(account))) {
140
+ const shared = await io.get(DEFAULT_ACCOUNT);
141
+ if (shared) {
142
+ await io.set(account, shared);
143
+ if ((await io.get(account)) !== shared)
144
+ return 'copy-verify-failed';
145
+ // A copy is not a login: keep the previous storedAt, never "now" (F1).
146
+ await writeMeta({ account, storedAt: meta?.storedAt ?? null, legacyChecked: true });
147
+ outcome = 'copied-shared';
148
+ }
149
+ }
150
+ if (outcome !== 'copied-shared')
151
+ await writeMeta({ account, storedAt: meta?.storedAt ?? null, legacyChecked: true });
152
+ }
153
+ // (b) A key in auth.json: copy → read back → only then remove.
154
+ if (!auth?.apiKey)
155
+ return outcome;
156
+ const store = await io.get(account);
157
+ const pick = pickKey({ store, auth, authMtimeMs: await authFileMtimeMs(), storedAt: (await readMeta())?.storedAt ?? null });
158
+ if (pick.conflict)
159
+ return 'conflict';
160
+ const fileKey = auth.apiKey.trim();
161
+ if (store !== fileKey) {
162
+ await io.set(account, fileKey);
163
+ if ((await io.get(account)) !== fileKey)
164
+ return 'verify-failed'; // never remove the only copy
165
+ outcome = 'migrated';
166
+ }
167
+ else {
168
+ outcome = 'removed';
169
+ }
170
+ // key-store.json is bookkeeping (no secret): its failure never turns a good
171
+ // store write into a "no credential store" notice (F7).
172
+ // Final review (Task 4 N1): a non-default home now holds its own key, so
173
+ // the shared-account check has nothing left to do — mark it done.
174
+ await (account !== DEFAULT_ACCOUNT
175
+ ? writeMeta({ account, storedAt: now().toISOString(), legacyChecked: true })
176
+ : recordStoreWrite(account, now().toISOString())).catch(() => { });
177
+ }
178
+ catch {
179
+ // No key in auth.json (only the shared-account check ran): nothing stays
180
+ // behind, so no notice — the check simply runs again on the next start.
181
+ return auth?.apiKey ? 'store-unavailable' : 'none';
182
+ }
183
+ try {
184
+ await writeAuthFile({ customerId: auth.customerId, ...(auth.savedAt ? { savedAt: auth.savedAt } : {}) });
185
+ }
186
+ catch {
187
+ return 'failed'; // the store holds the key; the next start removes the copy ('removed')
188
+ }
189
+ return outcome;
190
+ }
191
+ /** The one start-up notice for an outcome the user must know about, else null. */
192
+ export function signInMigrationNotice(o) {
193
+ switch (o) {
194
+ case 'conflict':
195
+ return 'cspeach: auth.json in your CSPeach home holds a different CSPeach key than your OS credential store. CSPeach uses the credential store\'s key. Run cspeach login to sign in again and clear this up.';
196
+ case 'store-unavailable':
197
+ return 'cspeach: your OS credential store is not available, so your CSPeach key stays in auth.json in your CSPeach home (on Linux: install and unlock a Secret Service keyring such as gnome-keyring).';
198
+ case 'verify-failed':
199
+ return 'cspeach: your CSPeach key could not be confirmed in your OS credential store, so it stays in auth.json for now. CSPeach tries again on the next start.';
200
+ case 'copy-verify-failed':
201
+ return 'cspeach: the CSPeach key for this CSPeach home could not be confirmed in your OS credential store. CSPeach tries again on the next start, or run cspeach login to sign in this home.';
202
+ default:
203
+ return null;
204
+ }
205
+ }
206
+ /** `cspeach login`: the store first; auth.json keeps customerId + savedAt. */
207
+ export async function saveSignIn(a, deps = {}) {
208
+ const account = keychainAccountForHome();
209
+ const savedAt = (deps.now ?? (() => new Date()))().toISOString();
210
+ // Fix round 1 (F7): only the store calls decide store vs file.
211
+ let stored = false;
212
+ try {
213
+ const io = deps.io ?? (await defaultIo());
214
+ await io.set(account, a.apiKey);
215
+ stored = (await io.get(account)) === a.apiKey;
216
+ }
217
+ catch {
218
+ stored = false;
219
+ }
220
+ if (!stored) {
221
+ await writeAuthFile({ apiKey: a.apiKey, customerId: a.customerId, savedAt });
222
+ return 'file';
223
+ }
224
+ await recordStoreWrite(account, savedAt).catch(() => { }); // bookkeeping only, no secret
225
+ await writeAuthFile({ customerId: a.customerId, savedAt });
226
+ return 'store';
227
+ }
228
+ /**
229
+ * `cspeach logout`: the store entry, auth.json and licence.json. Fix round 1
230
+ * (F2): verified — `cleared` is false when a delete failed or a key still
231
+ * resolves afterwards, so logout never says "removed" while a key is left.
232
+ * (A start running its migration at the same moment can re-store the key it
233
+ * read before this delete; the verify below catches that too.)
234
+ */
235
+ export async function clearSignIn(deps = {}) {
236
+ const account = keychainAccountForHome();
237
+ const auth = await readAuthFile();
238
+ let hadStore = false;
239
+ let storeOk = true;
240
+ let io = null;
241
+ try {
242
+ io = deps.io ?? (await defaultIo());
243
+ hadStore = (await io.get(account)) !== null;
244
+ if (hadStore && (await io.del(account)) === false)
245
+ storeOk = false;
246
+ }
247
+ catch {
248
+ // No credential store: nothing to delete there. A store that held a key
249
+ // but threw on delete is caught by the verify below.
250
+ }
251
+ const fileOk = await deleteAuthFile();
252
+ await fs.rm(licenceCachePath(), { force: true }).catch(() => { });
253
+ // Keep legacyChecked so a signed-out non-default home is never re-filled
254
+ // from the shared account on the next start.
255
+ await writeMeta({ account, storedAt: null, legacyChecked: true }).catch(() => { });
256
+ const left = (await resolveApiKey(io ? { io } : {})).key;
257
+ return { hadSignIn: hadStore || auth !== null, cleared: storeOk && fileOk && left === null };
258
+ }