@cspeach/cli 1.1.32 → 1.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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 +61 -8
  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 +25 -8
  33. package/dist/commands/help.js +2 -1
  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 +112 -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/month-usage.js +45 -0
  62. package/dist/cost/router-spend.js +3 -0
  63. package/dist/cost/session-cost.js +4 -0
  64. package/dist/doctor/checks/azure.js +100 -0
  65. package/dist/doctor/checks/credits.js +4 -3
  66. package/dist/doctor/checks/usage-reporting.js +22 -0
  67. package/dist/doctor/run.js +7 -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/renderer/footer-line.js +7 -1
  76. package/dist/renderer/status-footer.js +13 -6
  77. package/dist/repl/ask-or-no.js +18 -0
  78. package/dist/repl/builtin-commands.js +12 -1
  79. package/dist/repl/post-turn-status.js +3 -2
  80. package/dist/repl/safety-confirm.js +47 -2
  81. package/dist/repl/session-spend-line.js +4 -1
  82. package/dist/repl/slash-completer.js +1 -0
  83. package/dist/repl/startup-account.js +51 -0
  84. package/dist/repl.js +223 -48
  85. package/dist/router/byok-classifier.js +41 -14
  86. package/dist/router/classifier.js +5 -16
  87. package/dist/router/router-target.js +41 -0
  88. package/dist/sap/onboarding.js +70 -64
  89. package/dist/session/audit-export.js +11 -4
  90. package/dist/skills/manifest-client.js +37 -4
  91. package/dist/skills/skills-cache.js +115 -0
  92. package/dist/skills/source-authed.js +276 -0
  93. package/dist/tools/filesystem/glob.js +12 -3
  94. package/dist/tools/filesystem/grep.js +14 -3
  95. package/dist/tools/tool-loading.js +9 -10
  96. package/dist/usage/usage-queue.js +323 -0
  97. package/dist/usage/usage-reporter.js +270 -0
  98. package/package.json +3 -2
  99. package/dist/skills/bundled-skills.js +0 -286
  100. package/dist/skills/source-bundled.js +0 -20
  101. package/dist/skills/source-manifest.js +0 -36
@@ -126,73 +126,79 @@ export async function runAddSapWizard() {
126
126
  default: 'approval-gated',
127
127
  });
128
128
  cfg.write_mode = write_mode;
129
- const llm_mode = await select({
130
- message: 'How should CSPeach reach the AI model?',
131
- choices: [
132
- {
133
- name: 'managed (recommended) — we handle the Anthropic account',
134
- value: 'managed',
135
- description: 'Your prompts route through api.cspeach.dev. We bill, we ratelimit, we sign skills. Simplest setup.',
136
- },
137
- {
138
- name: 'byok — your own Anthropic account',
139
- value: 'byok',
140
- description: 'Your prompts go direct to Anthropic with your API key. You own the contract. Skills signed + fetched from our public manifest.',
141
- },
142
- {
143
- name: 'ai-hub — your SAP AI Core tenant (endorsed architecture)',
144
- value: 'ai-hub',
145
- description: 'Your prompts route through your SAP BTP AI Core proxy. Data stays in your SAP landscape. Strongest compliance posture (short of SAP Partner Edge).',
146
- },
147
- {
148
- name: 'local — your own Ollama / vLLM endpoint (air-gap)',
149
- value: 'local',
150
- description: 'No external AI. Skills bundled with the CLI binary. Tool-calling quality depends on the local model.',
151
- },
152
- ],
153
- default: 'managed',
154
- });
155
- cfg.llm = { mode: llm_mode };
156
- if (llm_mode === 'ai-hub') {
157
- cfg.llm.ai_hub_base_url = await input({
158
- message: 'AI Core proxy URL (e.g. https://proxy.yourcorp.com/v1):',
159
- validate: v => v.startsWith('https://') || v.startsWith('http://') ? true : 'Must be a URL',
160
- });
161
- cfg.llm.ai_hub_model_alias = await input({
162
- message: 'AI Core model alias (default: anthropic--claude-opus-4-6):',
163
- default: 'anthropic--claude-opus-4-6',
164
- });
165
- const aiHubToken = await password({
166
- message: 'AI Core API token (input masked):',
167
- mask: '*',
168
- });
169
- try {
170
- await keychain.setPassword('cspeach.ai-hub', 'token', aiHubToken);
171
- }
172
- catch {
173
- console.log(chalk.yellow('Warning: keychain unavailable; AI Hub token will be prompted per session.'));
174
- }
129
+ if (cfg.llm?.mode === 'azure') {
130
+ // Azure tenant mode — the company chose the models; the wizard never changes it.
131
+ console.log(chalk.dim("AI models: your company's Azure (set by your company)."));
175
132
  }
176
- if (llm_mode === 'local') {
177
- cfg.llm.local_base_url = await input({
178
- message: 'Local endpoint base URL (default: http://localhost:11434/v1 for Ollama):',
179
- default: 'http://localhost:11434/v1',
180
- });
181
- cfg.llm.local_model = await input({
182
- message: 'Model name (e.g. llama3.1:70b):',
183
- default: 'llama3.1:70b',
133
+ else {
134
+ const llm_mode = await select({
135
+ message: 'How should CSPeach reach the AI model?',
136
+ choices: [
137
+ {
138
+ name: 'managed (recommended) — we handle the Anthropic account',
139
+ value: 'managed',
140
+ description: 'Your prompts route through api.cspeach.dev. We bill, we ratelimit, we sign skills. Simplest setup.',
141
+ },
142
+ {
143
+ name: 'byok — your own Anthropic account',
144
+ value: 'byok',
145
+ description: 'Your prompts go direct to Anthropic with your API key. You own the contract. Skills signed + fetched from our public manifest.',
146
+ },
147
+ {
148
+ name: 'ai-hub — your SAP AI Core tenant (endorsed architecture)',
149
+ value: 'ai-hub',
150
+ description: 'Your prompts route through your SAP BTP AI Core proxy. Data stays in your SAP landscape. Strongest compliance posture (short of SAP Partner Edge).',
151
+ },
152
+ {
153
+ name: 'local — your own Ollama / vLLM endpoint (air-gap)',
154
+ value: 'local',
155
+ description: 'No external AI. Skills bundled with the CLI binary. Tool-calling quality depends on the local model.',
156
+ },
157
+ ],
158
+ default: 'managed',
184
159
  });
185
- }
186
- if (llm_mode === 'byok') {
187
- const byokKey = await password({
188
- message: 'Anthropic API key sk-ant-... (input masked):',
189
- mask: '*',
190
- });
191
- try {
192
- await keychain.setPassword('cspeach.anthropic', 'api-key', byokKey);
160
+ cfg.llm = { mode: llm_mode };
161
+ if (llm_mode === 'ai-hub') {
162
+ cfg.llm.ai_hub_base_url = await input({
163
+ message: 'AI Core proxy URL (e.g. https://proxy.yourcorp.com/v1):',
164
+ validate: v => v.startsWith('https://') || v.startsWith('http://') ? true : 'Must be a URL',
165
+ });
166
+ cfg.llm.ai_hub_model_alias = await input({
167
+ message: 'AI Core model alias (default: anthropic--claude-opus-4-6):',
168
+ default: 'anthropic--claude-opus-4-6',
169
+ });
170
+ const aiHubToken = await password({
171
+ message: 'AI Core API token (input masked):',
172
+ mask: '*',
173
+ });
174
+ try {
175
+ await keychain.setPassword('cspeach.ai-hub', 'token', aiHubToken);
176
+ }
177
+ catch {
178
+ console.log(chalk.yellow('Warning: keychain unavailable; AI Hub token will be prompted per session.'));
179
+ }
180
+ }
181
+ if (llm_mode === 'local') {
182
+ cfg.llm.local_base_url = await input({
183
+ message: 'Local endpoint base URL (default: http://localhost:11434/v1 for Ollama):',
184
+ default: 'http://localhost:11434/v1',
185
+ });
186
+ cfg.llm.local_model = await input({
187
+ message: 'Model name (e.g. llama3.1:70b):',
188
+ default: 'llama3.1:70b',
189
+ });
193
190
  }
194
- catch {
195
- console.log(chalk.yellow('Warning: keychain unavailable; Anthropic key will be prompted per session.'));
191
+ if (llm_mode === 'byok') {
192
+ const byokKey = await password({
193
+ message: 'Anthropic API key sk-ant-... (input masked):',
194
+ mask: '*',
195
+ });
196
+ try {
197
+ await keychain.setPassword('cspeach.anthropic', 'api-key', byokKey);
198
+ }
199
+ catch {
200
+ console.log(chalk.yellow('Warning: keychain unavailable; Anthropic key will be prompted per session.'));
201
+ }
196
202
  }
197
203
  }
198
204
  }
@@ -31,7 +31,8 @@
31
31
  // never an invented approval.
32
32
  import { summarise } from '../cost/cost-log.js';
33
33
  import { CLI_VERSION } from '../lib/version.js';
34
- import { creditsActive, formatCredits } from '../cost/credits.js';
34
+ import { creditsActive, tokensOnlyActive, formatCredits } from '../cost/credits.js';
35
+ import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
35
36
  /**
36
37
  * Object-mutating tools — one row per completed call in the Writes table.
37
38
  * Kept as a literal name set (not the tool registry) so this pure builder
@@ -433,7 +434,13 @@ export function buildAuditReport(input) {
433
434
  const auditEntries = costEntries.filter((e) => e.label);
434
435
  const auditCost = Math.round(auditEntries.reduce((s, e) => s + e.cost, 0) * 10000) / 10000;
435
436
  const inCredits = creditsActive();
436
- if (inCredits) {
437
+ // Azure (Task 5b): tokens only — no price of ours for the customer's deployments.
438
+ const tokensOnly = tokensOnlyActive();
439
+ const noPrice = inCredits || tokensOnly;
440
+ if (tokensOnly) {
441
+ L(`- **Total:** ${AZURE_INVOICE_LINE} · ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
442
+ }
443
+ else if (inCredits) {
437
444
  const unit = credits?.unitLabel ?? 'credits';
438
445
  const logged = costEntries.reduce((s, e) => s + (typeof e.credits_charged === 'number' ? e.credits_charged : 0), 0);
439
446
  const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : logged;
@@ -449,14 +456,14 @@ export function buildAuditReport(input) {
449
456
  if (auditEntries.length > 0) {
450
457
  // Credits mode: the labelled-audit turn COUNT is still useful provenance;
451
458
  // its USD cost is not ours to publish to a customer.
452
- L(inCredits
459
+ L(noPrice
453
460
  ? `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}`
454
461
  : `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
455
462
  }
456
463
  if (cost.byModel.length > 1) {
457
464
  L('- **By model:**');
458
465
  for (const m of cost.byModel) {
459
- L(inCredits
466
+ L(noPrice
460
467
  ? ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'}`
461
468
  : ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
462
469
  }
@@ -6,6 +6,31 @@ import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
6
6
  import { canonicalise } from './canonical.js';
7
7
  // @noble/ed25519 v2 requires an ed25519 sha512 hook.
8
8
  ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
9
+ /**
10
+ * A body that does not match its signed manifest entry. Fail closed: never
11
+ * loaded, never cached, and deliberately NOT a "skills unavailable" error, so
12
+ * it can never open the public fallback or the disk cache. Hashes stay out of
13
+ * the user message (they go to `debug`, written only to the debug log).
14
+ */
15
+ export class SkillIntegrityError extends Error {
16
+ debug;
17
+ constructor(debug) {
18
+ super('This skill failed its integrity check and was not loaded. Update CSPeach or contact support.');
19
+ this.name = 'SkillIntegrityError';
20
+ this.debug = debug;
21
+ }
22
+ }
23
+ /**
24
+ * The manifest could not be fetched: a network failure or a 5xx (incl. 503
25
+ * "not yet published"). Task 8 — the ONLY manifest failure the on-disk skills
26
+ * cache may cover. A bad signature, a 4xx or bad JSON never is.
27
+ */
28
+ export class ManifestUnavailableError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = 'ManifestUnavailableError';
32
+ }
33
+ }
9
34
  export class ManifestClient {
10
35
  manifestUrl;
11
36
  constructor(manifestUrl) {
@@ -17,21 +42,29 @@ export class ManifestClient {
17
42
  r = await fetch(this.manifestUrl, { signal: AbortSignal.timeout(10_000) });
18
43
  }
19
44
  catch (e) {
20
- throw new Error(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
45
+ throw new ManifestUnavailableError(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
21
46
  }
22
47
  if (r.status === 503) {
23
48
  const body = await r.json().catch(() => ({}));
24
49
  const raw = String(body.error ?? 'server says not ready');
25
50
  // Cap length — an untrusted server could flood the error message.
26
51
  const safe = raw.length > 200 ? raw.slice(0, 200) + '…' : raw;
27
- throw new Error(`manifest not yet published (HTTP 503): ${safe}`);
52
+ throw new ManifestUnavailableError(`manifest not yet published (HTTP 503): ${safe}`);
28
53
  }
54
+ if (r.status >= 500)
55
+ throw new ManifestUnavailableError(`manifest fetch: HTTP ${r.status} from ${this.manifestUrl}`);
29
56
  if (!r.ok)
30
57
  throw new Error(`manifest fetch: HTTP ${r.status} from ${this.manifestUrl}`);
31
58
  return await r.json();
32
59
  }
33
60
  async fetchAndVerifyManifest() {
34
- const m = await this.fetchManifest();
61
+ return this.verifyManifest(await this.fetchManifest());
62
+ }
63
+ /**
64
+ * Verify a manifest's ed25519 signature — fetched just now or read from the
65
+ * on-disk skills cache (Task 8). Works offline.
66
+ */
67
+ async verifyManifest(m) {
35
68
  const { signature, ...payload } = m;
36
69
  const canonical = canonicalise(payload);
37
70
  const canonicalBytes = new TextEncoder().encode(canonical);
@@ -82,7 +115,7 @@ export class ManifestClient {
82
115
  // the manifest-anchored expected value, constitutes an integrity check.
83
116
  const actualSha = bytesToHex(sha256(new TextEncoder().encode(j.body)));
84
117
  if (opts.expectedSha256 !== actualSha) {
85
- throw new Error(`skill ${name}: sha256 mismatch with manifest (manifest=${opts.expectedSha256}, computed=${actualSha}) — body may have been tampered in transit`);
118
+ throw new SkillIntegrityError(`skill ${name}: sha256 mismatch with manifest (manifest=${opts.expectedSha256}, computed=${actualSha}) — body may have been tampered in transit`);
86
119
  }
87
120
  return { name, body: j.body, sha256: actualSha, signature: '', signedAt: j.updated_at };
88
121
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Security batch item 2 — <CSPeach home>/skills-cache/ for byok, ai-hub and
3
+ * local. manifest.json (re-verified with its ed25519 signature on every load)
4
+ * plus one <skill>.json per body. A body loads only when its sha256 AND
5
+ * updated_at match the signed manifest entry; anything else is refetched.
6
+ * Offline use works from here within the licence check's 72-hour grace.
7
+ *
8
+ * The cache is never a trust anchor: a stored hash is never read back (the
9
+ * body is re-hashed every load), a corrupt or edited file loads nothing, and
10
+ * a name that is not a plain skill name never becomes a path.
11
+ *
12
+ * No TTL by design: the licence turn-gate (72-hour grace) bounds offline use.
13
+ */
14
+ import { promises as fs } from 'node:fs';
15
+ import crypto from 'node:crypto';
16
+ import path from 'node:path';
17
+ import { sha256 } from '@noble/hashes/sha256';
18
+ import { bytesToHex } from '@noble/hashes/utils';
19
+ import { cspeachRoot } from '../config/paths.js';
20
+ const SAFE_NAME = /^[a-z0-9_-]{1,64}$/;
21
+ const MANIFEST_FILE = 'manifest.json';
22
+ /** A *.tmp older than this is a crash leftover (a live write renames within ms). */
23
+ const STALE_TMP_MS = 60_000;
24
+ /** A plain skill name, and never the manifest's own file name. */
25
+ function safeName(name) {
26
+ return typeof name === 'string' && SAFE_NAME.test(name) && `${name}.json` !== MANIFEST_FILE;
27
+ }
28
+ export function skillsCacheDir() {
29
+ return path.join(cspeachRoot(), 'skills-cache');
30
+ }
31
+ const manifestFile = () => path.join(skillsCacheDir(), MANIFEST_FILE);
32
+ const bodyFile = (name) => path.join(skillsCacheDir(), `${name}.json`);
33
+ async function writeAtomic(p, text) {
34
+ await fs.mkdir(path.dirname(p), { recursive: true });
35
+ // Unique per write (as key-resolver writeMeta): two writes in one process
36
+ // must never share a temp file.
37
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
38
+ try {
39
+ await fs.writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
40
+ await fs.rename(tmp, p);
41
+ }
42
+ finally {
43
+ await fs.rm(tmp, { force: true }).catch(() => { });
44
+ }
45
+ }
46
+ export const fileSkillsCache = {
47
+ async saveManifest(m) {
48
+ await writeAtomic(manifestFile(), JSON.stringify(m)).catch(() => { });
49
+ },
50
+ async loadManifest(verify) {
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(await fs.readFile(manifestFile(), 'utf-8'));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.skills))
59
+ return null;
60
+ try {
61
+ return await verify(parsed);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ },
67
+ async loadBody(entry) {
68
+ if (!safeName(entry.name))
69
+ return null;
70
+ try {
71
+ const c = JSON.parse(await fs.readFile(bodyFile(entry.name), 'utf-8'));
72
+ if (c.name !== entry.name || typeof c.body !== 'string' || c.updated_at !== entry.updated_at)
73
+ return null;
74
+ const actual = bytesToHex(sha256(new TextEncoder().encode(c.body)));
75
+ if (actual !== entry.sha256)
76
+ return null;
77
+ return { name: entry.name, body: c.body, sha256: actual, signature: '', signedAt: typeof c.signedAt === 'string' ? c.signedAt : '' };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ },
83
+ async saveBody(entry, body) {
84
+ if (!safeName(entry.name))
85
+ return;
86
+ await writeAtomic(bodyFile(entry.name), JSON.stringify({ name: entry.name, body: body.body, updated_at: entry.updated_at, signedAt: body.signedAt })).catch(() => { });
87
+ },
88
+ async removeBody(name) {
89
+ if (!safeName(name))
90
+ return;
91
+ await fs.rm(bodyFile(name), { force: true }).catch(() => { });
92
+ },
93
+ async prune(m) {
94
+ const keep = new Set([MANIFEST_FILE, ...m.skills.filter((s) => safeName(s.name)).map((s) => `${s.name}.json`)]);
95
+ let removed = 0;
96
+ try {
97
+ for (const f of await fs.readdir(skillsCacheDir())) {
98
+ const p = path.join(skillsCacheDir(), f);
99
+ if (f.endsWith('.json') && !keep.has(f)) {
100
+ await fs.rm(p, { force: true });
101
+ removed++;
102
+ }
103
+ else if (f.endsWith('.tmp')) {
104
+ const st = await fs.stat(p).catch(() => null);
105
+ if (st && Date.now() - st.mtimeMs > STALE_TMP_MS) {
106
+ await fs.rm(p, { force: true });
107
+ removed++;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ catch { /* no cache yet */ }
113
+ return removed;
114
+ },
115
+ };
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Security batch item 2 — skill text only for signed-in seats (byok, ai-hub,
3
+ * local). The manifest stays public and signed (older CLIs verify it, and the
4
+ * routing list comes from it). Bodies come from GET /v1/skills/:name with the
5
+ * CSPeach bearer, and each body's sha256 is checked against the SIGNED
6
+ * manifest entry — the server-claimed hash is never trusted.
7
+ *
8
+ * Public bodies (manifest.cspeach.dev/v1/skills-public/…) are read only with
9
+ * CSPEACH_SKILLS_PUBLIC_FALLBACK=1, for one release; the route returns 410 in
10
+ * the next. Honest limit: any licensed seat can read the skill text — this is
11
+ * licence compliance, not secrecy.
12
+ */
13
+ import { sha256 } from '@noble/hashes/sha256';
14
+ import { bytesToHex } from '@noble/hashes/utils';
15
+ import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
16
+ import { fileSkillsCache } from './skills-cache.js';
17
+ // Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
18
+ export { SkillIntegrityError };
19
+ export class SkillNotInPlanError extends Error {
20
+ constructor(name) {
21
+ super(`The skill /${name} is not in your CSPeach plan. Ask your admin to add it.`);
22
+ this.name = 'SkillNotInPlanError';
23
+ }
24
+ }
25
+ export class SkillsUnavailableError extends Error {
26
+ constructor(detail) {
27
+ super(`Skills can't be loaded right now (${detail}). Try again in a few minutes.`);
28
+ this.name = 'SkillsUnavailableError';
29
+ }
30
+ }
31
+ /**
32
+ * The server answered, but not with a usable skill: a 4xx other than 401/403
33
+ * (404, 429, …) or a 200 without skill text. Deliberately NOT a
34
+ * SkillsUnavailableError: only network failures and 5xx may open the disk
35
+ * cache or the public fallback.
36
+ */
37
+ export class SkillFetchError extends Error {
38
+ constructor(detail) {
39
+ super(`Skills can't be loaded right now (${detail}). Try again in a few minutes.`);
40
+ this.name = 'SkillFetchError';
41
+ }
42
+ }
43
+ export class SkillSignInError extends Error {
44
+ constructor() {
45
+ super('CSPeach did not accept your sign-in for skills. Run cspeach login.');
46
+ this.name = 'SkillSignInError';
47
+ }
48
+ }
49
+ export function publicFallbackEnabled(env = process.env) {
50
+ return env.CSPEACH_SKILLS_PUBLIC_FALLBACK === '1';
51
+ }
52
+ /** The one resolver's key (Task 4), for providers built without a bearer. */
53
+ export async function defaultBearer() {
54
+ const { getStore } = await import('../auth/api-key.js');
55
+ return (await (await getStore()).get()) ?? '';
56
+ }
57
+ const FETCH_TIMEOUT_MS = 10_000;
58
+ /** GET /v1/skills/:name with the CSPeach bearer; the body is hashed against the SIGNED manifest entry. */
59
+ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch) {
60
+ // A missing key is a sign-in problem, settled before any network call.
61
+ let bearer;
62
+ try {
63
+ bearer = await getBearer();
64
+ }
65
+ catch {
66
+ throw new SkillSignInError();
67
+ }
68
+ if (typeof bearer !== 'string' || bearer.trim() === '')
69
+ throw new SkillSignInError();
70
+ let r;
71
+ try {
72
+ r = await doFetch(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, {
73
+ headers: { Authorization: `Bearer ${bearer}` },
74
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
75
+ });
76
+ }
77
+ catch {
78
+ throw new SkillsUnavailableError('could not reach CSPeach');
79
+ }
80
+ if (r.status === 403)
81
+ throw new SkillNotInPlanError(name);
82
+ if (r.status === 401)
83
+ throw new SkillSignInError();
84
+ if (r.status >= 500)
85
+ throw new SkillsUnavailableError(`the server answered ${r.status}`);
86
+ if (!r.ok)
87
+ throw new SkillFetchError(`the server answered ${r.status}`);
88
+ const j = await r.json().catch(() => null);
89
+ if (!j || typeof j.body !== 'string')
90
+ throw new SkillFetchError('the answer had no skill text');
91
+ const actual = bytesToHex(sha256(new TextEncoder().encode(j.body)));
92
+ if (actual !== expectedSha) {
93
+ throw new SkillIntegrityError(`skill ${name}: sha256 mismatch with the signed manifest (manifest=${expectedSha}, computed=${actual})`);
94
+ }
95
+ return { name, body: j.body, sha256: actual, signature: '', signedAt: typeof j.signedAt === 'string' ? j.signedAt : '' };
96
+ }
97
+ export function createAuthedSkillSource(cfg, getBearer, deps = {}) {
98
+ const manifestUrl = deps.manifestUrl ?? DEFAULT_MANIFEST_URL;
99
+ const client = deps.client ?? new ManifestClient(manifestUrl);
100
+ const doFetch = deps.fetchImpl ?? fetch;
101
+ const env = deps.env ?? process.env;
102
+ const cache = deps.cache === undefined ? fileSkillsCache : deps.cache;
103
+ const baseUrl = cfg.proxy_url.replace(/\/$/, '');
104
+ let manifest = null;
105
+ const memory = new Map();
106
+ // The existing debug channel (CSPEACH_DEBUG=1 → stderr). Never user output.
107
+ function debugIntegrity(err) {
108
+ if (env.CSPEACH_DEBUG !== '1')
109
+ return;
110
+ process.stderr.write(`[skills] ${err.debug}\n`);
111
+ }
112
+ async function getManifest() {
113
+ if (manifest)
114
+ return manifest;
115
+ try {
116
+ manifest = await client.fetchAndVerifyManifest();
117
+ if (cache) {
118
+ await cache.saveManifest(manifest);
119
+ await cache.prune(manifest);
120
+ }
121
+ }
122
+ catch (err) {
123
+ // Only "could not reach it" (network / 5xx) may use the disk copy. A bad
124
+ // signature, a 4xx or bad JSON never does.
125
+ if (!(err instanceof ManifestUnavailableError))
126
+ throw err;
127
+ const fromDisk = cache ? await cache.loadManifest((m) => client.verifyManifest(m)) : null;
128
+ if (!fromDisk)
129
+ throw err;
130
+ manifest = fromDisk; // offline: the signed copy on disk, re-verified just now
131
+ }
132
+ return manifest;
133
+ }
134
+ const fetchSignedIn = (name, expectedSha) => fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch);
135
+ async function fetchPublic(name, entry) {
136
+ try {
137
+ return await client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
138
+ }
139
+ catch (err) {
140
+ if (err instanceof SkillIntegrityError)
141
+ throw err;
142
+ if (/HTTP 410\b/.test(String(err?.message))) {
143
+ throw new SkillsUnavailableError('the public skill route is retired; run cspeach login and unset CSPEACH_SKILLS_PUBLIC_FALLBACK');
144
+ }
145
+ throw new SkillsUnavailableError('the public skill source did not answer');
146
+ }
147
+ }
148
+ return {
149
+ async getSkillBody(name) {
150
+ const m = await getManifest();
151
+ const entry = m.skills.find((s) => s.name === name);
152
+ if (!entry)
153
+ throw new Error(`skill '${name}' not found in manifest (available: ${m.skills.map((s) => s.name).join(', ')})`);
154
+ const inMemory = memory.get(name);
155
+ if (inMemory && inMemory.sha256 === entry.sha256)
156
+ return inMemory;
157
+ let body;
158
+ try {
159
+ try {
160
+ body = await fetchSignedIn(name, entry.sha256);
161
+ if (cache)
162
+ await cache.saveBody(entry, body);
163
+ }
164
+ catch (err) {
165
+ // No longer in the plan: drop the cached copy so a later outage
166
+ // cannot serve it.
167
+ if (err instanceof SkillNotInPlanError && cache)
168
+ await cache.removeBody(name);
169
+ // Network / 5xx: a valid cached copy first (hash-checked against the
170
+ // signed manifest), then — only with the switch — the public body.
171
+ // 401 / 403 / a hash mismatch never fall back.
172
+ if (!(err instanceof SkillsUnavailableError))
173
+ throw err;
174
+ const fromDisk = cache ? await cache.loadBody(entry) : null;
175
+ if (fromDisk)
176
+ body = fromDisk;
177
+ else if (publicFallbackEnabled(env))
178
+ body = await fetchPublic(name, entry);
179
+ else
180
+ throw err;
181
+ }
182
+ }
183
+ catch (err) {
184
+ if (err instanceof SkillIntegrityError)
185
+ debugIntegrity(err);
186
+ throw err;
187
+ }
188
+ memory.set(name, body);
189
+ return body;
190
+ },
191
+ async listAvailableSkills() {
192
+ return (await getManifest()).skills.map((s) => s.name);
193
+ },
194
+ describe() {
195
+ return `signed manifest at ${manifestUrl}; bodies from ${baseUrl} for signed-in seats`;
196
+ },
197
+ };
198
+ }
199
+ // ── Skills cache warm-up (final review finding 1) ─────────────────────────
200
+ // "Sign in once, then work offline": after an ok licence verdict in byok /
201
+ // ai-hub / local, fill the disk cache with every skill body that is missing or
202
+ // out of date — through the same signed-in, hash-checked fetch and the same
203
+ // cache as getSkillBody. Fire-and-forget: never awaited on the start-up or
204
+ // turn path, every error swallowed, at most once per process. Managed does no
205
+ // work at all (its skills come from the proxy on every request).
206
+ const WARM_CONCURRENCY = 4;
207
+ export async function warmSkillsCache(cfg, getBearer, deps = {}, opts = {}) {
208
+ const result = { fetched: 0, skipped: 0, stopped: null };
209
+ const cache = deps.cache === undefined ? fileSkillsCache : deps.cache;
210
+ if (!cache)
211
+ return result;
212
+ const client = deps.client ?? new ManifestClient(deps.manifestUrl ?? DEFAULT_MANIFEST_URL);
213
+ const doFetch = deps.fetchImpl ?? fetch;
214
+ const baseUrl = cfg.proxy_url.replace(/\/$/, '');
215
+ let m;
216
+ try {
217
+ m = await client.fetchAndVerifyManifest();
218
+ await cache.saveManifest(m);
219
+ await cache.prune(m);
220
+ }
221
+ catch {
222
+ return result; // offline or a bad manifest: nothing to warm
223
+ }
224
+ // One key read for the whole warm-up, not one per skill.
225
+ let bearerP = null;
226
+ const bearerOnce = () => (bearerP ??= getBearer());
227
+ const queue = [...m.skills];
228
+ const worker = async () => {
229
+ for (let entry = queue.shift(); entry && !result.stopped; entry = queue.shift()) {
230
+ try {
231
+ if (await cache.loadBody(entry)) {
232
+ result.skipped++;
233
+ continue;
234
+ }
235
+ const body = await fetchSignedInBody(baseUrl, entry.name, entry.sha256, bearerOnce, doFetch);
236
+ await cache.saveBody(entry, body);
237
+ result.fetched++;
238
+ }
239
+ catch (err) {
240
+ // Coordinator ruling: only a 401 / sign-in failure stops the warm-up.
241
+ if (err instanceof SkillSignInError)
242
+ result.stopped = 'sign-in';
243
+ // 403: this one skill is not in the plan — drop any cached copy (as
244
+ // getSkillBody does) and carry on with the rest.
245
+ else if (err instanceof SkillNotInPlanError)
246
+ await cache.removeBody(entry.name).catch(() => { });
247
+ // 5xx / network, a hash mismatch or another 4xx: skip this body, carry
248
+ // on, never retry; nothing is cached for it.
249
+ }
250
+ }
251
+ };
252
+ const n = Math.max(1, Math.min(opts.concurrency ?? WARM_CONCURRENCY, queue.length));
253
+ await Promise.all(Array.from({ length: n }, worker));
254
+ return result;
255
+ }
256
+ let warmStarted = false;
257
+ /** Test hook: forget that this process already warmed. */
258
+ export function _resetSkillsWarmupForTests() {
259
+ warmStarted = false;
260
+ }
261
+ /**
262
+ * Start the warm-up when the mode is not managed and the licence verdict is
263
+ * ok. Returns at once: null when nothing was started, else a promise that
264
+ * never rejects (callers drop it; tests await it).
265
+ */
266
+ export function startSkillsCacheWarmup(cfg, verdict, opts = {}) {
267
+ if (cfg.llm.mode === 'managed' || verdict !== 'ok' || warmStarted)
268
+ return null;
269
+ warmStarted = true;
270
+ try {
271
+ return warmSkillsCache(cfg, opts.getBearer ?? defaultBearer, opts.deps).then(() => undefined, () => undefined);
272
+ }
273
+ catch {
274
+ return Promise.resolve();
275
+ }
276
+ }