@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
@@ -13,6 +13,8 @@ import { checkCredits } from './checks/credits.js';
13
13
  import { checkForgeRules } from './checks/forge-rules.js';
14
14
  import { checkSystemRoles } from './checks/system-roles.js';
15
15
  import { checkStandards } from './checks/standards.js';
16
+ import { checkUsageReporting } from './checks/usage-reporting.js';
17
+ import { checkAzure, activateAzureForDoctor } from './checks/azure.js';
16
18
  import { paint } from '../renderer/theme.js';
17
19
  import { getGlyphs } from '../renderer/glyphs.js';
18
20
  import { isNoColor } from '../renderer/color-mode.js';
@@ -104,7 +106,12 @@ export async function collectChecks() {
104
106
  const checks = [];
105
107
  checks.push(await checkProxy());
106
108
  checks.push(await checkAuth());
109
+ // Azure tenant mode (Task 7): the saved settings, then the azure rows right
110
+ // after the llm-mode row. Every other mode: no read, no rows.
111
+ await activateAzureForDoctor(cfg);
107
112
  checks.push(await checkLlmMode(cfg));
113
+ checks.push(...(await checkAzure(cfg)));
114
+ checks.push(await checkUsageReporting(cfg));
108
115
  checks.push(await checkCredits(cfg));
109
116
  checks.push(await checkWriteMode(cfg));
110
117
  checks.push(await checkSystemRoles(cfg));
@@ -31,6 +31,20 @@ function call(fn, self) {
31
31
  return Promise.reject(e);
32
32
  }
33
33
  }
34
+ /**
35
+ * 2026-10-13 — async work that must run on the way out, before the sockets
36
+ * close (ending the stateful SAP session, so its locks leave SM12). Capped as
37
+ * a whole; a task that throws or hangs never blocks the exit.
38
+ */
39
+ const exitTasks = new Set();
40
+ /** Register an async exit task. Returns an unregister. */
41
+ export function registerExitTask(task) {
42
+ exitTasks.add(task);
43
+ return () => { exitTasks.delete(task); };
44
+ }
45
+ export function __resetExitTasksForTests() {
46
+ exitTasks.clear();
47
+ }
34
48
  export async function gracefulExit(code, opts = {}) {
35
49
  const exit = opts.exit ?? ((c) => process.exit(c));
36
50
  const graceMs = opts.graceMs ?? 250;
@@ -38,6 +52,9 @@ export async function gracefulExit(code, opts = {}) {
38
52
  const guard = () => { };
39
53
  process.on('unhandledRejection', guard);
40
54
  try {
55
+ if (exitTasks.size > 0) {
56
+ await capped(Promise.allSettled([...exitTasks].map((t) => call(t, {}))), opts.exitTaskMs ?? 3_500);
57
+ }
41
58
  const dispatcher = globalThis[UNDICI_DISPATCHER];
42
59
  if (dispatcher) {
43
60
  let done = false;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Security batch item 1 — the shared licence check. It IS the start-up
3
+ * GET /v1/me (no second call), plus a cache in licence.json
4
+ * ({ checkedAt, serverDate, status, keyPrefix }, atomic, no secret).
5
+ *
6
+ * 401 invalid_api_key / malformed_api_key / reason 'revoked' → revoked: refuse.
7
+ * 401 missing_authorization → missing: "run cspeach login".
8
+ * network error, timeout, 5xx, 429, 404 customer_not_found… → unknown: the grace clock.
9
+ *
10
+ * Grace: 72 hours from the last `ok`. With no usable cache (fresh or wiped
11
+ * profile, unreadable file, rotated key) the clock starts at this failed
12
+ * check — only a server verdict refuses straight away. A clock earlier than
13
+ * the cached server Date gives "unknown" and never rewrites the cache: a
14
+ * deterrent, not tamper-proof.
15
+ */
16
+ import crypto from 'node:crypto';
17
+ import { promises as fs } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { getDeviceFingerprint } from '../auth/device.js';
20
+ import { fetchMe } from '../auth/me.js';
21
+ import { formatFailure } from '../renderer/notice-shape.js';
22
+ import { licenceCachePath } from './paths.js';
23
+ export const LICENCE_GRACE_MS = 72 * 60 * 60 * 1000;
24
+ export const LICENCE_RECHECK_MS = 4 * 60 * 60 * 1000;
25
+ export const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000;
26
+ export function keyPrefixOf(key) { return key.slice(0, 12); }
27
+ export function verdictFromMe(r) {
28
+ if (r.ok)
29
+ return 'ok';
30
+ if (r.status === 401) {
31
+ if (r.error === 'missing_authorization')
32
+ return 'missing';
33
+ if (r.reason === 'revoked' || r.error === 'invalid_api_key' || r.error === 'malformed_api_key')
34
+ return 'revoked';
35
+ }
36
+ return 'unknown';
37
+ }
38
+ const when = (d) => d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
39
+ const offlineLine = (graceUntil) => `Could not reach CSPeach to check your licence; working offline until ${when(graceUntil)}`;
40
+ export function revokedMessage() {
41
+ return formatFailure({
42
+ what: 'Your CSPeach licence for this key has ended',
43
+ detail: [
44
+ 'Your SAP work, sessions and snapshots are untouched.',
45
+ 'If a plan was running, it stopped before its next step; resume it later with /abap-plan --resume.',
46
+ ],
47
+ next: 'run cspeach login, or contact your admin',
48
+ }).trimEnd();
49
+ }
50
+ /** A 401 invalid key without the server's 'revoked' reason (e.g. mistyped or
51
+ * unknown key): same refusal, but nothing "ended". */
52
+ export function invalidKeyMessage() {
53
+ return formatFailure({
54
+ what: 'This CSPeach key is not valid',
55
+ detail: ['Your SAP work, sessions and snapshots are untouched.'],
56
+ next: 'run cspeach login, or contact your admin',
57
+ }).trimEnd();
58
+ }
59
+ const refusalMessage = (reason) => (reason === 'revoked' ? revokedMessage() : invalidKeyMessage());
60
+ function expiredMessage(lastOk) {
61
+ return formatFailure({
62
+ what: 'CSPeach could not check your licence for 72 hours',
63
+ detail: [
64
+ lastOk ? `last successful check: ${when(lastOk)}` : 'no successful check on this machine yet',
65
+ 'Your SAP work, sessions and snapshots are untouched.',
66
+ ],
67
+ next: 'connect to the network and run cspeach again, or contact your admin',
68
+ }).trimEnd();
69
+ }
70
+ export function missingMessage() {
71
+ return formatFailure({ what: 'You are not signed in to CSPeach', next: 'cspeach login' }).trimEnd();
72
+ }
73
+ export function decide(a) {
74
+ const nowIso = a.now.toISOString();
75
+ if (a.verdict === 'ok') {
76
+ return { decision: { allow: true, verdict: 'ok', profile: a.profile }, write: { checkedAt: nowIso, serverDate: a.serverDate, status: 'ok', keyPrefix: a.keyPrefix } };
77
+ }
78
+ if (a.verdict === 'revoked') {
79
+ return {
80
+ decision: { allow: false, verdict: 'revoked', message: refusalMessage(a.reason) },
81
+ write: { checkedAt: nowIso, serverDate: a.serverDate, status: 'revoked', keyPrefix: a.keyPrefix, ...(a.reason === 'revoked' ? { reason: 'revoked' } : {}) },
82
+ };
83
+ }
84
+ if (a.verdict === 'missing')
85
+ return { decision: { allow: false, verdict: 'missing', message: missingMessage() }, write: null };
86
+ // A rotated key's cache, or one whose checkedAt is not a real time, is no
87
+ // cache: the clock starts now. It can never produce 'expired'.
88
+ const cache = a.cache && a.cache.keyPrefix === a.keyPrefix && Number.isFinite(Date.parse(a.cache.checkedAt)) ? a.cache : null;
89
+ if (!cache) {
90
+ const graceUntil = new Date(a.now.getTime() + LICENCE_GRACE_MS);
91
+ return {
92
+ decision: { allow: true, verdict: 'unknown', graceUntil, line: offlineLine(graceUntil), clockBehind: false },
93
+ write: { checkedAt: nowIso, serverDate: null, status: 'unknown', keyPrefix: a.keyPrefix },
94
+ };
95
+ }
96
+ if (cache.status === 'revoked')
97
+ return { decision: { allow: false, verdict: 'revoked', message: refusalMessage(cache.reason) }, write: null };
98
+ const start = Date.parse(cache.checkedAt);
99
+ const anchor = Math.max(start, cache.serverDate ? Date.parse(cache.serverDate) || -Infinity : -Infinity);
100
+ const graceUntil = new Date(start + LICENCE_GRACE_MS);
101
+ const clockBehind = a.now.getTime() < anchor - CLOCK_SKEW_TOLERANCE_MS;
102
+ if (clockBehind || a.now.getTime() <= graceUntil.getTime()) {
103
+ return { decision: { allow: true, verdict: 'unknown', graceUntil, line: offlineLine(graceUntil), clockBehind }, write: null };
104
+ }
105
+ return { decision: { allow: false, verdict: 'expired', message: expiredMessage(cache.status === 'ok' ? new Date(start) : null) }, write: null };
106
+ }
107
+ export async function readLicenceCache() {
108
+ try {
109
+ const c = JSON.parse(await fs.readFile(licenceCachePath(), 'utf-8'));
110
+ if (typeof c.checkedAt !== 'string' || !Number.isFinite(Date.parse(c.checkedAt)))
111
+ return null;
112
+ if (c.status !== 'ok' && c.status !== 'revoked' && c.status !== 'unknown')
113
+ return null;
114
+ if (typeof c.keyPrefix !== 'string')
115
+ return null;
116
+ return {
117
+ checkedAt: c.checkedAt, serverDate: typeof c.serverDate === 'string' ? c.serverDate : null, status: c.status, keyPrefix: c.keyPrefix,
118
+ ...(c.reason === 'revoked' ? { reason: 'revoked' } : {}),
119
+ };
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ export async function writeLicenceCache(c) {
126
+ const p = licenceCachePath();
127
+ await fs.mkdir(path.dirname(p), { recursive: true });
128
+ // Random suffix, as key-resolver writeMeta: two writes in one process
129
+ // (start-up check + Task 6 recheck) can never share a temp file.
130
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
131
+ try {
132
+ await fs.writeFile(tmp, JSON.stringify(c, null, 2), 'utf-8');
133
+ await fs.rename(tmp, p);
134
+ }
135
+ finally {
136
+ await fs.rm(tmp, { force: true }).catch(() => { });
137
+ }
138
+ }
139
+ /** One check. Never throws: anything unexpected is "unknown". */
140
+ export async function runLicenceCheck(a, deps = {}) {
141
+ let now;
142
+ try {
143
+ now = (deps.now ?? (() => new Date()))();
144
+ }
145
+ catch {
146
+ now = new Date();
147
+ }
148
+ let me;
149
+ try {
150
+ me = await (deps.fetchMe ?? fetchMe)({ proxyUrl: a.proxyUrl, apiKey: a.apiKey, headers: deps.headers });
151
+ }
152
+ catch (err) {
153
+ me = { ok: false, status: 0, error: err instanceof Error ? err.message : String(err), serverDate: null };
154
+ }
155
+ let cache = null;
156
+ try {
157
+ cache = await (deps.readCache ?? readLicenceCache)();
158
+ }
159
+ catch {
160
+ cache = null; // unreadable counts as missing — never a refusal
161
+ }
162
+ let verdict = 'unknown';
163
+ try {
164
+ verdict = verdictFromMe(me);
165
+ }
166
+ catch {
167
+ verdict = 'unknown'; // a malformed answer is never a refusal
168
+ }
169
+ const reason = me && !me.ok ? me.reason : undefined;
170
+ let result;
171
+ try {
172
+ result = decide({
173
+ verdict, now, cache, keyPrefix: keyPrefixOf(a.apiKey),
174
+ serverDate: me.serverDate ?? null, profile: me.ok ? me.profile : undefined, reason,
175
+ });
176
+ }
177
+ catch {
178
+ // Defensive only. A clear server verdict already in hand is honoured;
179
+ // anything else must never lock a working user out.
180
+ if (verdict === 'revoked')
181
+ return { allow: false, verdict: 'revoked', message: refusalMessage(reason) };
182
+ if (verdict === 'missing')
183
+ return { allow: false, verdict: 'missing', message: missingMessage() };
184
+ const graceUntil = new Date(Date.now() + LICENCE_GRACE_MS);
185
+ return { allow: true, verdict: 'unknown', graceUntil, line: offlineLine(graceUntil), clockBehind: false };
186
+ }
187
+ if (result.write) {
188
+ try {
189
+ await (deps.writeCache ?? writeLicenceCache)(result.write);
190
+ }
191
+ catch { /* a cache we cannot write only means the next check starts fresh */ }
192
+ }
193
+ return result.decision;
194
+ }
195
+ /**
196
+ * Device headers on the licence check (spec item 1), byok / ai-hub / local
197
+ * only — the managed /v1/me request stays byte-identical (plan deviation D2).
198
+ * `x-cspeach-mode` lets the proxy record the install's mode (Task 11a).
199
+ */
200
+ export async function licenceCheckHeaders(mode, device = getDeviceFingerprint) {
201
+ if (mode === 'managed')
202
+ return {};
203
+ const d = await device();
204
+ return { 'x-device-machine-id': d.machineId, 'x-device-hostname': d.hostname, 'x-cspeach-mode': mode };
205
+ }
@@ -0,0 +1,6 @@
1
+ import path from 'node:path';
2
+ import { cspeachRoot } from '../config/paths.js';
3
+ /** <CSPeach home>/licence.json — the licence check's cache. Holds no secret. */
4
+ export function licenceCachePath() {
5
+ return path.join(cspeachRoot(), 'licence.json');
6
+ }
@@ -0,0 +1,66 @@
1
+ import { runLicenceCheck, licenceCheckHeaders, missingMessage, LICENCE_RECHECK_MS, } from './licence-check.js';
2
+ import { formatFailure } from '../renderer/notice-shape.js';
3
+ export class LicenceTurnGate {
4
+ o;
5
+ lastCheckAtMs;
6
+ revokedMessage = null;
7
+ constructor(o) {
8
+ this.o = o;
9
+ this.lastCheckAtMs = o.lastCheckAtMs ?? (o.now ?? Date.now)();
10
+ }
11
+ async beforeTurn() {
12
+ const lic = await this.licenceBeforeTurn();
13
+ if (this.o.mode === 'managed' || !lic.allow || !this.o.extra)
14
+ return lic;
15
+ let ex;
16
+ // Fix round 1 (I1): a throwing extra gate fails CLOSED. With removed
17
+ // settings the cache still holds the old connection, so allowing would
18
+ // send the prompt and token where the gate refuses. Not sticky: the next
19
+ // turn runs the gate again.
20
+ try {
21
+ ex = await this.o.extra();
22
+ }
23
+ catch {
24
+ return { allow: false, message: formatFailure({ what: "Could not check your company's Azure settings.", next: 'start cspeach again' }).trimEnd() };
25
+ }
26
+ if (!ex.allow)
27
+ return ex;
28
+ const line = [lic.line, ex.line].filter(Boolean).join(' · ');
29
+ return line ? { allow: true, line } : { allow: true };
30
+ }
31
+ async licenceBeforeTurn() {
32
+ if (this.o.mode === 'managed')
33
+ return { allow: true };
34
+ if (this.revokedMessage)
35
+ return { allow: false, message: this.revokedMessage };
36
+ const now = (this.o.now ?? Date.now)();
37
+ if (now - this.lastCheckAtMs < (this.o.intervalMs ?? LICENCE_RECHECK_MS))
38
+ return { allow: true };
39
+ this.lastCheckAtMs = now;
40
+ await this.o.onRecheck?.().catch(() => { });
41
+ const key = await this.o.getKey().catch(() => null);
42
+ if (!key) {
43
+ this.lastCheckAtMs = -Infinity;
44
+ return { allow: false, message: missingMessage() };
45
+ }
46
+ const headers = await (this.o.headers ?? licenceCheckHeaders)(this.o.mode).catch(() => ({}));
47
+ let d;
48
+ try {
49
+ d = await (this.o.check ?? runLicenceCheck)({ proxyUrl: this.o.proxyUrl, apiKey: key }, { headers });
50
+ }
51
+ catch {
52
+ // runLicenceCheck never throws; any other check that does must never
53
+ // block or crash the REPL. Allow, and check again on the next turn.
54
+ this.lastCheckAtMs = -Infinity;
55
+ return { allow: true };
56
+ }
57
+ if (!d.allow) {
58
+ if (d.verdict === 'revoked')
59
+ this.revokedMessage = d.message;
60
+ else
61
+ this.lastCheckAtMs = -Infinity;
62
+ return { allow: false, message: d.message };
63
+ }
64
+ return d.verdict === 'unknown' ? { allow: true, line: d.line } : { allow: true };
65
+ }
66
+ }
@@ -0,0 +1,65 @@
1
+ let active = null;
2
+ export function setActiveDeployments(t) { active = t; }
3
+ export function getActiveDeployments() { return active; }
4
+ export function familyFromTable(wire, table) {
5
+ if (!table)
6
+ return wire;
7
+ for (const e of [table.main, table.small, table.review]) {
8
+ if (e && e.name === wire)
9
+ return e.model;
10
+ }
11
+ return wire;
12
+ }
13
+ export function familyOf(wire, provider) {
14
+ if (!provider || provider.profile?.modelIdentity !== 'deployment')
15
+ return wire;
16
+ return provider.modelFamily ? provider.modelFamily(wire) : familyFromTable(wire, active);
17
+ }
18
+ /**
19
+ * Azure tenant mode, Task 5a — families known to accept output_config.effort
20
+ * (an ALLOW-list, exact match after lowercasing and stripping a trailing
21
+ * -YYYYMMDD). Deployment connections send effort only to these; an unknown
22
+ * deployment name or an unprobed family (claude-haiku-*, claude-sonnet-5-5
23
+ * until Task 5c's probe) gets none. Model-id connections keep the
24
+ * modelAcceptsEffort deny-list unchanged.
25
+ */
26
+ export const EFFORT_FAMILIES = new Set(['claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6']);
27
+ export function familyAcceptsEffort(family) {
28
+ return EFFORT_FAMILIES.has(family.trim().toLowerCase().replace(/-\d{8}$/, ''));
29
+ }
30
+ /** The deployment each CLI role runs on (spec "Model roles"). */
31
+ export function connectionRoleModel(role, table) {
32
+ switch (role) {
33
+ case 'session_default': return table.main.name;
34
+ case 'audit': return (table.review ?? table.main).name;
35
+ case 'compact': return table.small.name;
36
+ }
37
+ }
38
+ /** Skill routing runs on the small deployment. */
39
+ export function routerDeployment(table) { return table.small; }
40
+ /**
41
+ * Plan tiering target: undefined = no connection (today's Sonnet constant);
42
+ * the review deployment's name; null = azure without review (tiering off).
43
+ */
44
+ export function planTierModelFor(table) {
45
+ if (!table)
46
+ return undefined;
47
+ return table.review?.name ?? null;
48
+ }
49
+ /** The deployments `cspeach doctor` sends a 1-token call to (Task 7). */
50
+ export function probeTargets(table) {
51
+ const out = [
52
+ { role: 'main', name: table.main.name, family: table.main.model, required: true },
53
+ { role: 'small', name: table.small.name, family: table.small.model, required: true },
54
+ ];
55
+ if (table.review)
56
+ out.push({ role: 'review', name: table.review.name, family: table.review.model, required: false });
57
+ return out;
58
+ }
59
+ export const ROLE_ENV_VARS = ['CSPEACH_DEFAULT_MODEL', 'CSPEACH_AUDIT_MODEL', 'CSPEACH_COMPACT_MODEL'];
60
+ /** Env role overrides that are set but ignored because the company's connection chooses the models. */
61
+ export function ignoredEnvOverrides(env) {
62
+ if (!active)
63
+ return [];
64
+ return ROLE_ENV_VARS.filter((v) => (env[v] ?? '').trim().length > 0);
65
+ }
@@ -19,6 +19,8 @@
19
19
  // snapshot) every role resolves to today's constant — byte-identical to before
20
20
  // this feature.
21
21
  //
22
+ // Azure tenant mode: when a connection table is active it replaces every layer.
23
+ //
22
24
  // Pure in the resolveAuditModel sense: no async I/O, no fetch. The only external
23
25
  // read is getServerModelConfig(), a synchronous in-memory snapshot the caller
24
26
  // (or the startup fetch) has already populated. `router` is proxy-side only and
@@ -28,6 +30,7 @@ import { DEFAULT_SESSION_MODEL } from '../config/model-defaults.js';
28
30
  import { PLAN_TIER_SONNET_MODEL } from '../commands/plan-model-tier.js';
29
31
  import { COMPACTION_MODEL } from '../commands/compact.js';
30
32
  import { getCustomerModelConfig, getServerModelConfig, isEffort } from './server-config.js';
33
+ import { getActiveDeployments, connectionRoleModel } from './model-identity.js';
31
34
  /** First non-empty (trimmed) candidate in precedence order, else the built-in. */
32
35
  function firstSet(candidates, builtin) {
33
36
  for (const c of candidates) {
@@ -49,6 +52,11 @@ function firstSet(candidates, builtin) {
49
52
  * explicit user choice (even one equal to the built-in) still wins.
50
53
  */
51
54
  export function resolveModelRole(role, cfg, env = process.env) {
55
+ // Azure tenant mode (Task 2) — the company's deployments are the ONLY role
56
+ // source in azure mode (org setting wins); null for every other mode.
57
+ const table = getActiveDeployments();
58
+ if (table)
59
+ return connectionRoleModel(role, table);
52
60
  const server = getServerModelConfig();
53
61
  switch (role) {
54
62
  case 'session_default':
@@ -27,6 +27,7 @@
27
27
  import fs from 'node:fs/promises';
28
28
  import path from 'node:path';
29
29
  import { cspeachRoot } from '../config/paths.js';
30
+ import { profileForMode } from '../agent/connection-profile.js';
30
31
  export const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
31
32
  const EFFORT_ROLE_KEYS = ['session_default', 'audit', 'compact'];
32
33
  export function isEffort(v) {
@@ -152,7 +153,7 @@ async function readFreshDiskCache() {
152
153
  */
153
154
  export async function loadServerModelConfig(cfg, opts) {
154
155
  // Non-managed modes bypass the proxy for inference and must never fetch.
155
- if (cfg.llm?.mode !== 'managed')
156
+ if (profileForMode(cfg.llm?.mode).servedConfig !== 'full')
156
157
  return;
157
158
  try {
158
159
  // URL construction lives INSIDE the try: a degenerate config (e.g. a
@@ -297,7 +298,7 @@ export function byokRouterModel() {
297
298
  export async function fetchToolLoadingMode(cfg, getBearer, opts) {
298
299
  byokToolLoading = undefined;
299
300
  byokRouter = undefined;
300
- if (cfg.llm?.mode !== 'byok')
301
+ if (profileForMode(cfg.llm?.mode).servedConfig !== 'router-only')
301
302
  return undefined;
302
303
  const controller = new AbortController();
303
304
  const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
package/dist/one-shot.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
3
3
  import { applyColorMode } from './renderer/color-mode.js';
4
- import { formatAttention, formatInfo } from './renderer/notice-shape.js';
4
+ import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
5
+ import { azureErrorNotice, azureErrorContext } from './connection/azure-errors.js';
5
6
  import { formatRoutingLine } from './renderer/routing-line.js';
6
7
  import { createOfflineAdtClient } from './sap/offline-adt-client.js';
7
8
  import { STANDALONE_ALIAS } from './tools/index.js';
@@ -14,9 +15,11 @@ import { newSession } from './session/schema.js';
14
15
  import { resolveModelRole } from './models/resolve.js';
15
16
  import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
16
17
  import { prefetchRouterPrompt } from './router/router-prompt.js';
18
+ import { profileForMode } from './agent/connection-profile.js';
17
19
  import { createProviderForMode } from './agent/providers/factory.js';
18
20
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
19
21
  import { runTurn } from './agent/loop.js';
22
+ import { runLicenceCheck, licenceCheckHeaders } from './licence/licence-check.js';
20
23
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
21
24
  import { routingFailureFromResult, routingFailureOneShotLine, routingFallbackFromError } from './router/routing-failure.js';
22
25
  import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
@@ -32,6 +35,8 @@ import { todoEmitter } from './ui/todo-emitter.js';
32
35
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
33
36
  import { getEffectiveWriteMode } from './repl/mode-cycle.js';
34
37
  import { initCredits } from './cost/credits-wire.js';
38
+ import { startUsageFlushAtStartup } from './usage/usage-reporter.js';
39
+ import { startSkillsCacheWarmup } from './skills/source-authed.js';
35
40
  import { formatTurnReceipt, turnSpendText } from './renderer/footer-line.js';
36
41
  import { snapshotTurnStart, computeTurnFacts } from './repl/post-turn-status.js';
37
42
  import { globalCredits } from './cost/credits.js';
@@ -105,7 +110,7 @@ export async function runOneShot(prompt, opts) {
105
110
  fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
106
111
  // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
107
112
  // router prompt (public manifest, no prompt data) under the same cap.
108
- cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
113
+ profileForMode(cfg.llm?.mode).router === 'direct' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
109
114
  ]);
110
115
  // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
111
116
  // REPL, often piped (CI / battery). Any stdin prompt would hang forever
@@ -150,6 +155,74 @@ export async function runOneShot(prompt, opts) {
150
155
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
151
156
  return 1;
152
157
  }
158
+ // Security batch item 1 — licence check at one-shot start, byok / ai-hub /
159
+ // local only. Managed makes no new call: the proxy checks every request.
160
+ if (cfg.llm.mode !== 'managed') {
161
+ const licence = await runLicenceCheck({ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? '' }, { headers: await licenceCheckHeaders(cfg.llm.mode).catch(() => ({})) });
162
+ if (!licence.allow) {
163
+ console.error(licence.message);
164
+ return 1;
165
+ }
166
+ if (licence.verdict === 'unknown')
167
+ console.error(formatInfo(licence.line).trimEnd());
168
+ // Azure tenant mode — one-shot never asks: a changed endpoint, removed
169
+ // settings or no settings refuse until a person runs cspeach interactively.
170
+ if (cfg.llm.mode === 'azure') {
171
+ const { refreshAzureConnection } = await import('./connection/startup.js');
172
+ const az = await refreshAzureConnection({
173
+ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? null, interactive: false,
174
+ print: (l) => console.error(l.trimEnd()), confirm: async () => false,
175
+ });
176
+ if (!az.verdict.allow) {
177
+ console.error(az.verdict.message.trimEnd());
178
+ return 1;
179
+ }
180
+ // Task 6b — the Microsoft sign-in, silently: one-shot never asks and
181
+ // never opens a browser; a sign-in that ended refuses with the line.
182
+ if (az.cached) {
183
+ const { ensureAzureSignIn } = await import('./auth/azure/sign-in.js');
184
+ const s = await ensureAzureSignIn(az.cached.connection, {
185
+ interactive: false, ask: async () => false, print: (l) => console.error(l.trimEnd()),
186
+ open: async () => { throw new Error('no browser in one-shot'); },
187
+ });
188
+ if (!s.ok) {
189
+ console.error(s.message.trimEnd());
190
+ return 1;
191
+ }
192
+ // Task 8b — the monthly limit, the same check as the REPL's per-turn
193
+ // gate, on the settings just loaded. A check that throws refuses with
194
+ // a plain line, never a crash.
195
+ const cached = az.cached;
196
+ let g;
197
+ try {
198
+ g = await (await import('./connection/azure-gate.js')).azureGateCheck(() => cached);
199
+ }
200
+ catch {
201
+ g = { allow: false, message: formatFailure({ what: "Could not check this month's CSPeach limit.", next: 'try again' }) };
202
+ }
203
+ if (!g.allow) {
204
+ console.error(g.message.trimEnd());
205
+ return 1;
206
+ }
207
+ if (g.line)
208
+ console.error(formatInfo(g.line).trimEnd());
209
+ }
210
+ }
211
+ // Final review finding 1 — fill the skills cache for offline use after an
212
+ // ok verdict. Fire-and-forget: never awaited, never throws into start-up.
213
+ if (licence.verdict === 'ok') {
214
+ try {
215
+ void startSkillsCacheWarmup(cfg, licence.verdict);
216
+ }
217
+ catch { /* best effort */ }
218
+ }
219
+ // Security batch item 3 — send the usage counts still waiting from earlier
220
+ // runs. Fire-and-forget: never awaited, never throws into start-up.
221
+ try {
222
+ startUsageFlushAtStartup(cfg);
223
+ }
224
+ catch { /* best effort */ }
225
+ }
153
226
  // Credits (2026-09-16) — resolve the display mode before the turn runs, so
154
227
  // the per-turn cost JSONL records the ledger charge and nothing downstream
155
228
  // can print a dollar to a managed customer — including the one-line stderr
@@ -347,6 +420,17 @@ export async function runOneShot(prompt, opts) {
347
420
  ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: noticeEmitter, todoEmitter },
348
421
  });
349
422
  }
423
+ catch (err) {
424
+ // Azure tenant mode (Task 6b) — the sign-in ended mid-turn: never a
425
+ // browser or a question; the plain line and the fix, exit 1. Task 7: the
426
+ // rest of the azure message table too, and a refusal the SDK wrapped as
427
+ // "Connection error." is unwrapped (ruling 4). Null in every other mode.
428
+ const azure = azureErrorNotice(err, azureErrorContext());
429
+ if (!azure)
430
+ throw err;
431
+ console.error(('preformatted' in azure ? azure.preformatted : formatFailure(azure)).trimEnd());
432
+ return 1;
433
+ }
350
434
  finally {
351
435
  // Review M2 — even when the turn throws, the answer so far ends with its
352
436
  // newline (and any held colour reset) instead of being cut mid-line.