@cspeach/cli 1.1.33 → 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 +19 -6
- package/dist/commands/help.js +2 -2
- 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 +6 -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/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 +10 -2
- 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/startup-account.js +51 -0
- package/dist/repl.js +211 -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
package/dist/commands/whoami.js
CHANGED
|
@@ -1,25 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `cspeach whoami` — print the current account without booting the REPL.
|
|
3
|
-
* Reads
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Reads the CSPeach key through the one resolver (security batch item 6:
|
|
4
|
+
* this home's OS credential store entry, else auth.json) and calls /v1/me;
|
|
5
|
+
* a "not signed in" note when there is no key.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { resolveApiKey } from '../auth/key-resolver.js';
|
|
8
8
|
import { fetchMe } from '../auth/me.js';
|
|
9
|
+
import { readLicenceCache, keyPrefixOf } from '../licence/licence-check.js';
|
|
9
10
|
import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
|
|
10
11
|
import { panelHeading, panelRow } from '../renderer/panel-rows.js';
|
|
11
12
|
import { paint } from '../renderer/theme.js';
|
|
12
13
|
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
14
|
+
import { queueStats } from '../usage/usage-queue.js';
|
|
13
15
|
/** Server-supplied text (email, plan, trial fields) is one line with no control characters. */
|
|
14
16
|
const serverText = (v) => sanitizeControlChars(String(v ?? '')).replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
17
|
+
async function defaultAzureWhoami() {
|
|
18
|
+
const { readConnectionCache } = await import('../connection/cache.js');
|
|
19
|
+
const c = await readConnectionCache();
|
|
20
|
+
if (!c)
|
|
21
|
+
return null;
|
|
22
|
+
const conn = c.connection;
|
|
23
|
+
if (conn.sign_in.includes('entra')) {
|
|
24
|
+
const { describeEntraSignIn } = await import('../auth/azure/token-provider.js');
|
|
25
|
+
const s = await describeEntraSignIn(conn);
|
|
26
|
+
if (s.state === 'signed-in' || !conn.sign_in.includes('key')) {
|
|
27
|
+
return { endpoint: conn.endpoint, tenant: conn.entra_tenant_id, method: 'Microsoft sign-in', account: s.username, expiresOn: s.expiresOn ?? null, state: s.state === 'signed-in' ? 'ok' : 'needs-sign-in' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const { getAzureKey } = await import('../auth/azure/azure-key.js');
|
|
31
|
+
return { endpoint: conn.endpoint, tenant: conn.entra_tenant_id, method: 'Azure key', state: (await getAzureKey()) ? 'ok' : 'no-key' };
|
|
32
|
+
}
|
|
33
|
+
/** The portal's customer-facing words (viewer team.js INSTALL_MODE_LABEL).
|
|
34
|
+
* Own key is not offered today: byok and ai-hub both read "Your AI service". */
|
|
35
|
+
const MODE_LABEL = {
|
|
36
|
+
managed: 'CSPeach',
|
|
37
|
+
byok: 'Your AI service',
|
|
38
|
+
'ai-hub': 'Your AI service',
|
|
39
|
+
local: 'Local model',
|
|
40
|
+
azure: 'Your Azure',
|
|
41
|
+
};
|
|
42
|
+
/** A local read that must never hang whoami: settles to UNKNOWN after LOCAL_READ_MS. */
|
|
43
|
+
const LOCAL_READ_MS = 2_000;
|
|
44
|
+
const UNKNOWN = Symbol('unknown');
|
|
45
|
+
function boundedRead(read) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const timer = setTimeout(() => resolve(UNKNOWN), LOCAL_READ_MS);
|
|
48
|
+
timer.unref?.();
|
|
49
|
+
let p;
|
|
50
|
+
try {
|
|
51
|
+
p = read();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
p = Promise.reject(new Error('read failed'));
|
|
55
|
+
}
|
|
56
|
+
p.then((v) => { clearTimeout(timer); resolve(v); }, () => { clearTimeout(timer); resolve(UNKNOWN); });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Security batch item 5 — what this machine knows about its own sign-in. Never the key itself. */
|
|
60
|
+
async function printLocalFacts(deps, key) {
|
|
61
|
+
const mode = deps.mode ?? 'managed';
|
|
62
|
+
const prefix = keyPrefixOf(key);
|
|
63
|
+
// Final review (Task 11c): a key no longer than the prefix would print in
|
|
64
|
+
// full — no Key row then.
|
|
65
|
+
if (key.length > prefix.length)
|
|
66
|
+
deps.print(panelRow('Key', `${serverText(prefix)}…`, 8));
|
|
67
|
+
deps.print(panelRow('Mode', MODE_LABEL[mode] ?? 'unknown', 8));
|
|
68
|
+
let licence;
|
|
69
|
+
if (mode === 'managed') {
|
|
70
|
+
licence = 'checked by CSPeach on every request (managed)';
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const lic = await boundedRead(deps.readLicence ?? readLicenceCache);
|
|
74
|
+
if (lic === UNKNOWN)
|
|
75
|
+
licence = 'unknown';
|
|
76
|
+
else if (!lic)
|
|
77
|
+
licence = 'not checked on this machine yet';
|
|
78
|
+
else if (lic.keyPrefix !== prefix)
|
|
79
|
+
licence = 'not checked for this key on this machine yet';
|
|
80
|
+
else
|
|
81
|
+
licence = serverText(`${lic.status} · checked ${lic.checkedAt}`);
|
|
82
|
+
}
|
|
83
|
+
deps.print(panelRow('Licence', licence, 8));
|
|
84
|
+
const q = await boundedRead(deps.queue ?? queueStats);
|
|
85
|
+
const n = q === UNKNOWN ? NaN : Number(q.lines);
|
|
86
|
+
deps.print(panelRow('Queue', Number.isFinite(n) ? `${n} usage records waiting` : 'unknown', 8));
|
|
87
|
+
if (mode === 'azure')
|
|
88
|
+
await printAzureFacts(deps);
|
|
89
|
+
}
|
|
90
|
+
/** Azure tenant mode (Task 7): endpoint, tenant, sign-in method, account and token expiry. */
|
|
91
|
+
async function printAzureFacts(deps) {
|
|
92
|
+
const a = await boundedRead(deps.azure ?? defaultAzureWhoami);
|
|
93
|
+
// A read that failed or ran past LOCAL_READ_MS (e.g. a slow silent token
|
|
94
|
+
// refresh) is not "no settings": say what is true.
|
|
95
|
+
if (a === UNKNOWN) {
|
|
96
|
+
deps.print(panelRow('Azure', 'Could not check your Azure sign-in in time · run cspeach doctor', 8));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!a) {
|
|
100
|
+
deps.print(panelRow('Azure', 'settings not on this machine · cspeach login', 8));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
deps.print(panelRow('Azure', serverText(a.endpoint), 8));
|
|
104
|
+
deps.print(panelRow('Tenant', serverText(a.tenant), 8));
|
|
105
|
+
deps.print(panelRow('Sign-in', a.method, 8));
|
|
106
|
+
if (a.account)
|
|
107
|
+
deps.print(panelRow('Account', serverText(a.account), 8));
|
|
108
|
+
if (a.state === 'ok') {
|
|
109
|
+
if (a.expiresOn)
|
|
110
|
+
deps.print(panelRow('Token', `valid until ${a.expiresOn.toISOString()}`, 8));
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
deps.print(panelRow('Token', a.state === 'no-key' ? 'no Azure key on this machine · cspeach config set llm.azure_key -' : 'sign-in needed · cspeach login azure', 8));
|
|
114
|
+
}
|
|
15
115
|
export async function runWhoamiCommand(deps) {
|
|
16
|
-
const
|
|
17
|
-
if (!
|
|
18
|
-
deps.print(formatInfo('Not signed in
|
|
19
|
-
deps.print(` ${paint('dim', 'Next:')} cspeach login
|
|
116
|
+
const { key } = await (deps.resolveKey ?? (() => resolveApiKey()))();
|
|
117
|
+
if (!key) {
|
|
118
|
+
deps.print(formatInfo('Not signed in (no CSPeach key in this CSPeach home)').trimEnd());
|
|
119
|
+
deps.print(` ${paint('dim', 'Next:')} cspeach login`);
|
|
120
|
+
if (deps.mode === 'azure')
|
|
121
|
+
await printAzureFacts(deps);
|
|
20
122
|
return 0;
|
|
21
123
|
}
|
|
22
|
-
const me = await fetchMe({ proxyUrl: deps.proxyUrl, apiKey:
|
|
124
|
+
const me = await fetchMe({ proxyUrl: deps.proxyUrl, apiKey: key });
|
|
23
125
|
if (!me.ok) {
|
|
24
126
|
// Status 0 = the request never got an answer (network error or the /v1/me
|
|
25
127
|
// timeout): signing in again would not help. Any HTTP answer keeps the
|
|
@@ -30,6 +132,7 @@ export async function runWhoamiCommand(deps) {
|
|
|
30
132
|
detail: [unreachable ? `could not reach the server (${me.error})` : `the server answered ${me.status} (${me.error})`],
|
|
31
133
|
next: unreachable ? 'check this machine can reach cspeach.dev (company network or proxy settings), then run cspeach whoami again' : 'cspeach login',
|
|
32
134
|
}).trimEnd());
|
|
135
|
+
await printLocalFacts(deps, key);
|
|
33
136
|
return 1;
|
|
34
137
|
}
|
|
35
138
|
const p = me.profile;
|
|
@@ -45,5 +148,6 @@ export async function runWhoamiCommand(deps) {
|
|
|
45
148
|
if (p.entitlements.enabled_skills.length > 0) {
|
|
46
149
|
deps.print(panelRow('Skills', serverText(p.entitlements.enabled_skills.join(', ')), 8));
|
|
47
150
|
}
|
|
151
|
+
await printLocalFacts(deps, key);
|
|
48
152
|
return 0;
|
|
49
153
|
}
|
package/dist/config/loader.js
CHANGED
|
@@ -222,6 +222,9 @@ export async function loadConfig() {
|
|
|
222
222
|
plan_audit: parsed.plan_audit === 'on' || parsed.plan_audit === 'off'
|
|
223
223
|
? parsed.plan_audit
|
|
224
224
|
: undefined,
|
|
225
|
+
usage_reporting: parsed.usage_reporting === 'on' || parsed.usage_reporting === 'off'
|
|
226
|
+
? parsed.usage_reporting
|
|
227
|
+
: undefined,
|
|
225
228
|
approvals: parsed.approvals === 'plan' || parsed.approvals === 'per-write'
|
|
226
229
|
? parsed.approvals
|
|
227
230
|
: undefined,
|
|
@@ -318,6 +321,8 @@ export async function loadConfig() {
|
|
|
318
321
|
// callers see a genuine "absent" shape (treated as 'on').
|
|
319
322
|
if (merged.plan_audit === undefined)
|
|
320
323
|
delete merged.plan_audit;
|
|
324
|
+
if (merged.usage_reporting === undefined)
|
|
325
|
+
delete merged.usage_reporting;
|
|
321
326
|
if (merged.approvals === undefined)
|
|
322
327
|
delete merged.approvals;
|
|
323
328
|
// Same for `audit_model`: absent-or-invalid must not serialise as
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — how a request authenticates to the company's Foundry
|
|
3
|
+
* endpoint. Entra: a bearer token from MSAL (Task 6). Key: the admin-issued
|
|
4
|
+
* key from the OS credential store. The CSPeach key never goes here, and the
|
|
5
|
+
* token or key never goes anywhere but the allow-listed host.
|
|
6
|
+
*
|
|
7
|
+
* Every Azure SDK client is built from azureSdkOptions, which also hands the
|
|
8
|
+
* SDK our guarded fetch: the host guard runs on the FINAL URL of every real
|
|
9
|
+
* request (not only on the saved endpoint), and redirects are refused —
|
|
10
|
+
* Node's fetch keeps api-key / x-api-key headers on a cross-origin redirect.
|
|
11
|
+
*/
|
|
12
|
+
import { getAzureKey } from '../auth/azure/azure-key.js';
|
|
13
|
+
import { assertAllowedAzureUrl } from './guard.js';
|
|
14
|
+
export class AzureSignInRequiredError extends Error {
|
|
15
|
+
method;
|
|
16
|
+
constructor(method) {
|
|
17
|
+
super(method === 'entra' ? 'Your Microsoft sign-in has ended.' : 'Your Azure key is not set on this machine.');
|
|
18
|
+
this.name = 'AzureSignInRequiredError';
|
|
19
|
+
this.method = method;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function selectAzureAuth(conn, deps = {}) {
|
|
23
|
+
// Task 6b — Entra through the token provider, loaded on demand (MSAL itself
|
|
24
|
+
// loads only inside auth/azure/msal-client.ts). Never interactive: a sign-in
|
|
25
|
+
// that ended throws AzureSignInRequiredError and the turn ends with
|
|
26
|
+
// `cspeach login azure`.
|
|
27
|
+
const entra = deps.entra ?? (async (c) => (await import('../auth/azure/token-provider.js')).entraForSelect(c, { getKey: deps.getKey }));
|
|
28
|
+
if (conn.sign_in.includes('entra')) {
|
|
29
|
+
const e = await entra(conn);
|
|
30
|
+
if (e)
|
|
31
|
+
return e;
|
|
32
|
+
}
|
|
33
|
+
if (conn.sign_in.includes('key')) {
|
|
34
|
+
return {
|
|
35
|
+
method: 'key',
|
|
36
|
+
async getAuth() {
|
|
37
|
+
const key = await (deps.getKey ?? getAzureKey)();
|
|
38
|
+
if (!key)
|
|
39
|
+
throw new AzureSignInRequiredError('key');
|
|
40
|
+
return { kind: 'key', key };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { method: 'entra', async getAuth() { throw new AzureSignInRequiredError('entra'); } };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The Azure endpoint answered with a redirect (3xx). Unlike
|
|
48
|
+
* AzureConnectionRefusedError this request DID reach Azure; CSPeach refuses to
|
|
49
|
+
* follow the answer. Plain message: the host only — never the key, the full
|
|
50
|
+
* URL or the redirect target.
|
|
51
|
+
*/
|
|
52
|
+
export class AzureRedirectRefusedError extends Error {
|
|
53
|
+
constructor(host) {
|
|
54
|
+
super(`Your Azure endpoint at ${host} answered with a redirect. CSPeach does not follow it.`);
|
|
55
|
+
this.name = 'AzureRedirectRefusedError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function urlOf(input) {
|
|
59
|
+
if (typeof input === 'string')
|
|
60
|
+
return input;
|
|
61
|
+
if (input instanceof URL)
|
|
62
|
+
return input.href;
|
|
63
|
+
return input.url;
|
|
64
|
+
}
|
|
65
|
+
/** The SDK's attempt number for this request (0 = first try); the header it sets on every attempt. */
|
|
66
|
+
function retryCountOf(input, init) {
|
|
67
|
+
try {
|
|
68
|
+
const h = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
69
|
+
const n = Number(h.get('x-stainless-retry-count') ?? 0);
|
|
70
|
+
return Number.isFinite(n) ? n : 0;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Node's fetch with `redirect: 'error'` rejects a 3xx as TypeError("fetch failed", { cause: "unexpected redirect" }). */
|
|
77
|
+
function isRedirectFailure(err) {
|
|
78
|
+
const cause = err?.cause;
|
|
79
|
+
return err instanceof TypeError && cause instanceof Error && /unexpected redirect/i.test(cause.message);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A fetch that checks every request URL against the Azure allow-list BEFORE
|
|
83
|
+
* any network call, always sends `redirect: 'error'`, and refuses a 3xx
|
|
84
|
+
* answer as well (belt and braces for a fetch that ignores the flag). `base`
|
|
85
|
+
* is read at call time, so a test's stubbed global fetch is the one used.
|
|
86
|
+
*
|
|
87
|
+
* Task 7, ruling 4 — a refusal can never succeed, so it is never retried:
|
|
88
|
+
* the SDK retries a failed fetch on its own, and its retries of a refused
|
|
89
|
+
* request (same URL, `x-stainless-retry-count` > 0) get the same refusal
|
|
90
|
+
* back without going to the network. A new request (count 0) always tries.
|
|
91
|
+
*/
|
|
92
|
+
export function createAzureFetch(base = () => globalThis.fetch) {
|
|
93
|
+
const refused = new Map();
|
|
94
|
+
const refuse = (url, err) => { refused.set(url, err); throw err; };
|
|
95
|
+
return async (input, init) => {
|
|
96
|
+
const url = urlOf(input);
|
|
97
|
+
const earlier = refused.get(url);
|
|
98
|
+
if (earlier && retryCountOf(input, init) > 0)
|
|
99
|
+
throw earlier;
|
|
100
|
+
refused.delete(url);
|
|
101
|
+
try {
|
|
102
|
+
assertAllowedAzureUrl(url);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return refuse(url, e);
|
|
106
|
+
}
|
|
107
|
+
let res;
|
|
108
|
+
try {
|
|
109
|
+
res = await base()(input, { ...init, redirect: 'error' });
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
if (isRedirectFailure(e))
|
|
113
|
+
return refuse(url, new AzureRedirectRefusedError(new URL(url).host));
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
if (res.status >= 300 && res.status < 400) {
|
|
117
|
+
return refuse(url, new AzureRedirectRefusedError(new URL(url).host));
|
|
118
|
+
}
|
|
119
|
+
return res;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** The one guarded fetch every Azure SDK client uses. */
|
|
123
|
+
export const azureFetch = createAzureFetch();
|
|
124
|
+
/**
|
|
125
|
+
* SDK options for an Azure client. Both auth fields are always set: the
|
|
126
|
+
* Anthropic SDK otherwise reads ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN from
|
|
127
|
+
* the environment and would send a developer's own key to Azure. Throws for
|
|
128
|
+
* a host outside the allow-list, before any client exists.
|
|
129
|
+
*/
|
|
130
|
+
export function azureSdkOptions(endpoint, auth) {
|
|
131
|
+
assertAllowedAzureUrl(`${endpoint}/v1/messages`);
|
|
132
|
+
return auth.kind === 'entra'
|
|
133
|
+
? { baseURL: endpoint, authToken: auth.token, apiKey: null, fetch: azureFetch }
|
|
134
|
+
: { baseURL: endpoint, apiKey: auth.key, authToken: null, fetch: azureFetch };
|
|
135
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — the spec's plain messages ("Request shaping and
|
|
3
|
+
* errors"), keyed on status and the Azure error body { error: { code,
|
|
4
|
+
* message } }. The 400 text pattern is data (AZURE_FACTS) and is confirmed by
|
|
5
|
+
* the live proof. Returns null for anything else (the generic turn-error block applies).
|
|
6
|
+
*
|
|
7
|
+
* Errors are matched by NAME, never instanceof, so this module (loaded by
|
|
8
|
+
* turn-error-ux.ts in every mode) never pulls in the credential store, MSAL
|
|
9
|
+
* or the Azure SDK client code.
|
|
10
|
+
*/
|
|
11
|
+
import { AZURE_FACTS } from './azure-facts.js';
|
|
12
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
13
|
+
import { getActiveConnection } from './cache.js';
|
|
14
|
+
import { lastAzureDeployment, lastAzureMethod } from './azure-last-call.js';
|
|
15
|
+
import { azureSignInTurnLine } from './azure-turn-error.js';
|
|
16
|
+
const KEY_NEXT = 'ask IT for a current key, then cspeach config set llm.azure_key -';
|
|
17
|
+
export function azureErrorContext() {
|
|
18
|
+
const c = getActiveConnection();
|
|
19
|
+
if (!c)
|
|
20
|
+
return null;
|
|
21
|
+
return { method: lastAzureMethod() ?? (c.connection.sign_in.includes('entra') ? 'entra' : 'key'), deployment: lastAzureDeployment() };
|
|
22
|
+
}
|
|
23
|
+
const REFUSALS = new Set(['AzureHostRefusedError', 'AzureRedirectRefusedError', 'AzureConnectionRefusedError']);
|
|
24
|
+
/**
|
|
25
|
+
* Controller ruling 4 — a refusal from the guarded fetch reaches the caller
|
|
26
|
+
* wrapped by the SDK ("Connection error.", APIConnectionError) with the real
|
|
27
|
+
* reason in `cause`. Walk the cause chain (a few levels) for it.
|
|
28
|
+
*/
|
|
29
|
+
export function azureRefusalIn(err) {
|
|
30
|
+
let e = err;
|
|
31
|
+
for (let depth = 0; depth < 4 && e instanceof Error; depth++) {
|
|
32
|
+
if (REFUSALS.has(e.name))
|
|
33
|
+
return e;
|
|
34
|
+
e = e.cause;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function bodyText(err) {
|
|
39
|
+
const e = err;
|
|
40
|
+
const m = e?.error?.error?.message ?? e?.error?.message ?? e?.message;
|
|
41
|
+
return typeof m === 'string' ? m : '';
|
|
42
|
+
}
|
|
43
|
+
/** Final fix wave A — the line for our service's 403 azure_company (spec message table). */
|
|
44
|
+
export const AZURE_COMPANY_LINE = 'Your company runs CSPeach on its own Azure. Run cspeach login to load its settings.';
|
|
45
|
+
/**
|
|
46
|
+
* True for our service's `403 { error: 'azure_company' }`: the seat's company
|
|
47
|
+
* runs CSPeach on its own Azure, so the managed path refuses it. Read from the
|
|
48
|
+
* SDK's parsed body, else from the "403 {…}" message text.
|
|
49
|
+
*/
|
|
50
|
+
export function isAzureCompanyRefusal(err) {
|
|
51
|
+
const e = err;
|
|
52
|
+
if (!e || typeof e !== 'object')
|
|
53
|
+
return false;
|
|
54
|
+
if (e.status !== undefined && Number(e.status) !== 403)
|
|
55
|
+
return false;
|
|
56
|
+
const parsed = e.error;
|
|
57
|
+
if (parsed && typeof parsed === 'object' && parsed.error === 'azure_company')
|
|
58
|
+
return Number(e.status) === 403;
|
|
59
|
+
const msg = typeof e.message === 'string' ? e.message : '';
|
|
60
|
+
if (!/^403\b/.test(msg))
|
|
61
|
+
return false;
|
|
62
|
+
const brace = msg.indexOf('{');
|
|
63
|
+
if (brace < 0)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(msg.slice(brace, msg.lastIndexOf('}') + 1))?.error === 'azure_company';
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function azureErrorNotice(err, ctx) {
|
|
73
|
+
// Final fix wave A — our service refused this seat on the managed path
|
|
74
|
+
// because its company runs on its own Azure: one plain line, in any mode.
|
|
75
|
+
if (isAzureCompanyRefusal(err))
|
|
76
|
+
return { preformatted: formatFailure({ what: AZURE_COMPANY_LINE }) };
|
|
77
|
+
// Refusals and sign-in errors only ever come from the azure path, so they
|
|
78
|
+
// are shown even when no settings are active (e.g. settings missing).
|
|
79
|
+
const refused = azureRefusalIn(err);
|
|
80
|
+
if (refused) {
|
|
81
|
+
switch (refused.name) {
|
|
82
|
+
case 'AzureConnectionRefusedError': return { preformatted: refused.message };
|
|
83
|
+
case 'AzureHostRefusedError': return { what: refused.message, next: 'cspeach login (settings changed)' };
|
|
84
|
+
default: return { what: refused.message, next: 'ask IT to check the Azure endpoint in CSPeach settings' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const signIn = azureSignInTurnLine(err);
|
|
88
|
+
if (signIn)
|
|
89
|
+
return signIn;
|
|
90
|
+
if (!ctx)
|
|
91
|
+
return null;
|
|
92
|
+
const status = Number(err?.status);
|
|
93
|
+
switch (status) {
|
|
94
|
+
case 401: return ctx.method === 'entra' ? { what: 'Your Microsoft sign-in has ended.', next: 'cspeach login azure' } : { what: 'Your Azure key was rejected.', next: KEY_NEXT };
|
|
95
|
+
case 403: return { what: "Your account is not allowed to use the company's AI models.", next: 'ask IT to add you to the CSPeach developers group' };
|
|
96
|
+
case 404: return { what: `Deployment "${ctx.deployment ?? 'unknown'}" was not found at your company's Azure endpoint.`, next: 'ask IT to check the deployment names in CSPeach settings; run cspeach doctor' };
|
|
97
|
+
case 429: return { what: "Your company's Azure limit was reached.", next: 'wait a minute, or ask IT to raise the quota' };
|
|
98
|
+
case 400: return AZURE_FACTS.unsupportedParamPattern.test(bodyText(err))
|
|
99
|
+
? { what: 'CSPeach sent a setting your Azure model does not accept.', next: 'run cspeach doctor and send us the output' }
|
|
100
|
+
: null;
|
|
101
|
+
default: return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — the values the live test settles, in ONE place (plan
|
|
3
|
+
* D7). Each line names the live-proof item (spec "Live proof checklist") or
|
|
4
|
+
* owner action that sets it. Change them only in their own commit.
|
|
5
|
+
*/
|
|
6
|
+
export const NIL_CLIENT_ID = '00000000-0000-0000-0000-000000000000';
|
|
7
|
+
export const AZURE_FACTS = Object.freeze({
|
|
8
|
+
/** Live proof 5 — 'v0' sends nothing unproven; 'v1' after the probe (azure-profile.ts). */
|
|
9
|
+
profileVersion: 'v0',
|
|
10
|
+
/** Live proof 4 — the Entra scope that returns a Foundry token for our public client. */
|
|
11
|
+
entraScope: 'https://ai.azure.com/.default',
|
|
12
|
+
/** Owner action 3 — our multi-tenant public client id (a public value). Recorded 2026-10-12 (app "CSPeach", multi-tenant, public client). */
|
|
13
|
+
clientId: '1b88e804-7801-43bb-9bf6-26c278472d82',
|
|
14
|
+
/** Live proof 5 — what a Foundry response's `model` holds: the deployment name or the model id. */
|
|
15
|
+
messageStartModel: 'unknown',
|
|
16
|
+
/** Live proof 7 / spec error table — the 400 text that means "a parameter this model does not accept". */
|
|
17
|
+
unsupportedParamPattern: /not supported|unsupported|unrecognized|unknown parameter|extra inputs are not permitted/i,
|
|
18
|
+
});
|
|
19
|
+
export function clientIdConfigured(id = AZURE_FACTS.clientId) {
|
|
20
|
+
return id !== NIL_CLIENT_ID;
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — the per-turn check added to the licence gate (plan D8):
|
|
3
|
+
* refuse a turn while the settings are missing, removed, unconfirmed or point
|
|
4
|
+
* at a host outside the allow-list; then (Task 8b) the seat's monthly limit.
|
|
5
|
+
* Runs between turns only, so a running turn always finishes.
|
|
6
|
+
*/
|
|
7
|
+
import { getActiveConnection } from './cache.js';
|
|
8
|
+
import { azureTurnVerdict } from './state.js';
|
|
9
|
+
import { readLocalUsage } from './local-usage.js';
|
|
10
|
+
import { limitVerdict } from './limit-gate.js';
|
|
11
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
12
|
+
/** The 80% line shows once per session (this process). */
|
|
13
|
+
let warned80 = false;
|
|
14
|
+
/** Test-only. */
|
|
15
|
+
export function __resetLimitWarningForTests() { warned80 = false; }
|
|
16
|
+
export async function azureGateCheck(get = getActiveConnection, deps = {}) {
|
|
17
|
+
const c = get();
|
|
18
|
+
const v = azureTurnVerdict(c);
|
|
19
|
+
if (!v.allow)
|
|
20
|
+
return { allow: false, message: v.message };
|
|
21
|
+
const limit = c.connection.limit;
|
|
22
|
+
if (!limit)
|
|
23
|
+
return { allow: true };
|
|
24
|
+
// A limit check that throws refuses this turn with a plain line (never a
|
|
25
|
+
// crash, never a silent allow); the next turn checks again.
|
|
26
|
+
let lv;
|
|
27
|
+
try {
|
|
28
|
+
lv = limitVerdict(limit, await (deps.readLocal ?? readLocalUsage)(), (deps.now ?? (() => new Date()))(), warned80);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { allow: false, message: formatFailure({ what: "Could not check this month's CSPeach limit.", next: 'try again' }).trimEnd() };
|
|
32
|
+
}
|
|
33
|
+
if (!lv.allow)
|
|
34
|
+
return { allow: false, message: lv.message };
|
|
35
|
+
if (lv.warn) {
|
|
36
|
+
warned80 = true;
|
|
37
|
+
return { allow: true, line: lv.warn };
|
|
38
|
+
}
|
|
39
|
+
return { allow: true };
|
|
40
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode, Task 6b — a turn never opens a browser or asks. When the
|
|
3
|
+
* Microsoft sign-in (or the Azure key) is needed mid-turn, the token provider
|
|
4
|
+
* throws, and the turn ends with ONE plain line and the command that fixes
|
|
5
|
+
* it. "type continue" would only hit the same wall.
|
|
6
|
+
*
|
|
7
|
+
* Matched by error name, with no imports, so the error path (turn-error-ux.ts
|
|
8
|
+
* and one-shot, loaded in every mode) never pulls in MSAL, the credential
|
|
9
|
+
* store or the Azure SDK.
|
|
10
|
+
*/
|
|
11
|
+
const KEY_NEXT = 'ask IT for a current key, then cspeach config set llm.azure_key -';
|
|
12
|
+
export function azureSignInTurnLine(err) {
|
|
13
|
+
if (!(err instanceof Error))
|
|
14
|
+
return null;
|
|
15
|
+
const method = err.method;
|
|
16
|
+
switch (err.name) {
|
|
17
|
+
case 'AzureSignInRequiredError':
|
|
18
|
+
case 'AzureSeveralAccountsError':
|
|
19
|
+
return { what: err.message, next: method === 'key' ? KEY_NEXT : 'cspeach login azure' };
|
|
20
|
+
case 'AzureWrongTenantError':
|
|
21
|
+
return { what: err.message, next: 'sign in with your work account: cspeach login azure' };
|
|
22
|
+
default:
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — <CSPEACH_HOME>/connection.json (one per home, no
|
|
3
|
+
* secret). Read at every start (fast, offline), refreshed with the licence
|
|
4
|
+
* check. A damaged or hand-edited file reads as "no cache".
|
|
5
|
+
*/
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
9
|
+
import { setActiveDeployments } from '../models/model-identity.js';
|
|
10
|
+
import { parseConnectionAnswer } from './guard.js';
|
|
11
|
+
export function connectionCachePath() {
|
|
12
|
+
return path.join(cspeachRoot(), 'connection.json');
|
|
13
|
+
}
|
|
14
|
+
function asAzure(v) {
|
|
15
|
+
const a = parseConnectionAnswer(v);
|
|
16
|
+
return a && a.kind === 'azure' ? a : null;
|
|
17
|
+
}
|
|
18
|
+
export async function readConnectionCache() {
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = JSON.parse(await fs.readFile(connectionCachePath(), 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (!raw || typeof raw !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
const connection = asAzure(raw.connection);
|
|
29
|
+
if (!connection)
|
|
30
|
+
return null;
|
|
31
|
+
const pending = raw.pending == null ? null : asAzure(raw.pending);
|
|
32
|
+
if (raw.pending != null && !pending)
|
|
33
|
+
return null;
|
|
34
|
+
const confirmed = raw.confirmed;
|
|
35
|
+
if (!confirmed || confirmed.endpoint !== connection.endpoint || confirmed.entra_tenant_id !== connection.entra_tenant_id)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
connection, pending, removed: raw.removed === true,
|
|
39
|
+
confirmed: { endpoint: connection.endpoint, entra_tenant_id: connection.entra_tenant_id },
|
|
40
|
+
fetchedAt: typeof raw.fetchedAt === 'string' ? raw.fetchedAt : '',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function writeConnectionCache(c) {
|
|
44
|
+
const file = connectionCachePath();
|
|
45
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
46
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
47
|
+
await fs.writeFile(tmp, JSON.stringify(c, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
48
|
+
await fs.rename(tmp, file);
|
|
49
|
+
}
|
|
50
|
+
/** True when a file was removed, false when there was none; throws on any other error. */
|
|
51
|
+
export async function deleteConnectionCache() {
|
|
52
|
+
try {
|
|
53
|
+
await fs.unlink(connectionCachePath());
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
if (e.code === 'ENOENT')
|
|
58
|
+
return false;
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let active = null;
|
|
63
|
+
/** The settings this process runs on. Also sets the deployment table model identity reads. */
|
|
64
|
+
export function setActiveConnection(c) {
|
|
65
|
+
active = c;
|
|
66
|
+
setActiveDeployments(c ? c.connection.deployments : null);
|
|
67
|
+
}
|
|
68
|
+
export function getActiveConnection() {
|
|
69
|
+
return active;
|
|
70
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Azure tenant mode — GET {proxy}/v1/me/connection. Bearer only; never throws. */
|
|
2
|
+
import { parseConnectionAnswer } from './guard.js';
|
|
3
|
+
export async function fetchConnection(o) {
|
|
4
|
+
const timeoutMs = o.timeoutMs ?? 5000;
|
|
5
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
6
|
+
const timedOut = new Promise((_, reject) => {
|
|
7
|
+
signal.addEventListener('abort', () => reject(new Error(`timed out after ${timeoutMs} ms`)), { once: true });
|
|
8
|
+
});
|
|
9
|
+
timedOut.catch(() => { });
|
|
10
|
+
try {
|
|
11
|
+
return await Promise.race([
|
|
12
|
+
(async () => {
|
|
13
|
+
const r = await (o.fetchImpl ?? fetch)(`${o.proxyUrl.replace(/\/$/, '')}/v1/me/connection`, {
|
|
14
|
+
headers: { Authorization: `Bearer ${o.apiKey}` }, signal,
|
|
15
|
+
});
|
|
16
|
+
// An older service has no such route. Reported on its own: it is NEVER
|
|
17
|
+
// a "none" answer (only a real { kind: 'none' } body marks settings
|
|
18
|
+
// removed — a service rollback must not lock out every azure seat).
|
|
19
|
+
if (r.status === 404)
|
|
20
|
+
return { ok: false, status: 404, error: 'no_route' };
|
|
21
|
+
const body = await r.json().catch(() => null);
|
|
22
|
+
if (!r.ok)
|
|
23
|
+
return { ok: false, status: r.status, error: typeof body?.error === 'string' ? body.error : 'unknown' };
|
|
24
|
+
const answer = parseConnectionAnswer(body);
|
|
25
|
+
return answer ? { ok: true, answer } : { ok: false, status: r.status, error: 'unreadable_settings' };
|
|
26
|
+
})(),
|
|
27
|
+
timedOut,
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
32
|
+
}
|
|
33
|
+
}
|