@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.
- 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 +182 -39
- 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/lib/graceful-exit.js +17 -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/projects/answer-blockers.js +25 -12
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/renderer/tool-labels.js +8 -2
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- 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 +217 -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/connection-manager.js +15 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/sap/session-end.js +66 -0
- 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/ask-question.js +1 -1
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/tools/tool-loading.js +9 -10
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +4 -3
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- package/dist/skills/source-manifest.js +0 -36
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -251,7 +251,7 @@ registerTool({
|
|
|
251
251
|
},
|
|
252
252
|
multiSelect: {
|
|
253
253
|
type: 'boolean',
|
|
254
|
-
description: 'true = the user may pick several options; false/omitted = pick exactly one.',
|
|
254
|
+
description: 'true = the user may pick several options; false/omitted = pick exactly one. When the user ticks options and also types an answer, it comes back as free text (custom: true): the ticked labels, then the typed text, comma-separated.',
|
|
255
255
|
},
|
|
256
256
|
options: {
|
|
257
257
|
type: 'array',
|
|
@@ -20,6 +20,7 @@ import { promises as fs } from 'node:fs';
|
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { registerTool } from '../index.js';
|
|
22
22
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, isSeatReadOnlyFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
23
|
+
import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
|
|
23
24
|
import { isReadOnlyRel, isWorkRel, teamWorkRoot, teamWorkTarget } from '../_team-work-path.js';
|
|
24
25
|
const MAX_RESULTS = 500;
|
|
25
26
|
const MAX_FILES_TRAVERSED = 10_000;
|
|
@@ -120,6 +121,7 @@ export async function globHandler(args, ctx) {
|
|
|
120
121
|
const wantsCspeach = isCspeachPattern(args.pattern);
|
|
121
122
|
const collected = [];
|
|
122
123
|
let budget = MAX_FILES_TRAVERSED;
|
|
124
|
+
let credentialSkipped = 0;
|
|
123
125
|
async function walk(dir, relPrefix, denyRoot = root) {
|
|
124
126
|
if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
|
|
125
127
|
return;
|
|
@@ -162,6 +164,12 @@ export async function globHandler(args, ctx) {
|
|
|
162
164
|
const denylist = isDenylistedPath(abs, denyRoot);
|
|
163
165
|
if (denylist.blocked && !isReadOnlyProjectFile(abs, denyRoot))
|
|
164
166
|
continue;
|
|
167
|
+
// Security batch item 4, fix round 1 (F2) — credential files are not
|
|
168
|
+
// listed (same rule as the risk gate); one line says how many.
|
|
169
|
+
if (credentialFileRisk(ent.name)) {
|
|
170
|
+
credentialSkipped++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
165
173
|
let mtime = 0;
|
|
166
174
|
try {
|
|
167
175
|
const s = await fs.stat(abs);
|
|
@@ -221,17 +229,18 @@ export async function globHandler(args, ctx) {
|
|
|
221
229
|
// Sort by mtime descending, cap at MAX_RESULTS
|
|
222
230
|
collected.sort((a, b) => b.mtime - a.mtime);
|
|
223
231
|
const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
|
|
232
|
+
const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
|
|
224
233
|
if (sample.length === 0) {
|
|
225
234
|
// A search for saved results that did not name `.cspeach/` gets the hint.
|
|
226
235
|
const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
|
|
227
236
|
? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
|
|
228
237
|
: '';
|
|
229
|
-
return { content: `no files match "${args.pattern}"${hint}` };
|
|
238
|
+
return { content: `no files match "${args.pattern}"${hint}${skipNote}` };
|
|
230
239
|
}
|
|
231
240
|
const truncated = collected.length > MAX_RESULTS;
|
|
232
|
-
const trailer = truncated
|
|
241
|
+
const trailer = (truncated
|
|
233
242
|
? `\n... (${collected.length - MAX_RESULTS} more matches — narrow the pattern)`
|
|
234
|
-
: '';
|
|
243
|
+
: '') + skipNote;
|
|
235
244
|
return { content: sample.join('\n') + trailer };
|
|
236
245
|
}
|
|
237
246
|
registerTool({
|
|
@@ -23,6 +23,7 @@ import { registerTool } from '../index.js';
|
|
|
23
23
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
24
24
|
import { globToRegex } from './glob.js';
|
|
25
25
|
import { teamWorkTarget } from '../_team-work-path.js';
|
|
26
|
+
import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
|
|
26
27
|
const MAX_MATCHES = 250;
|
|
27
28
|
const MAX_FILES_TRAVERSED = 10_000;
|
|
28
29
|
const MAX_FILE_BYTES = 2_000_000;
|
|
@@ -69,6 +70,11 @@ export async function grepHandler(args, ctx) {
|
|
|
69
70
|
const matches = [];
|
|
70
71
|
const fileMatchCounts = new Map();
|
|
71
72
|
let budget = MAX_FILES_TRAVERSED;
|
|
73
|
+
// Security batch item 4, fix round 1 (F2) — credential files (.npmrc,
|
|
74
|
+
// .git-credentials, .env* outside the denylist, *.pem, *.key) are never
|
|
75
|
+
// searched: grep would return their text with no card. Counted, and one
|
|
76
|
+
// line says so. Same rule as the risk gate, so the lists cannot drift.
|
|
77
|
+
let credentialSkipped = 0;
|
|
72
78
|
async function walk(dir, relPrefix) {
|
|
73
79
|
if (budget <= 0 || matches.length >= MAX_MATCHES)
|
|
74
80
|
return;
|
|
@@ -106,6 +112,10 @@ export async function grepHandler(args, ctx) {
|
|
|
106
112
|
const denylist = isDenylistedPath(abs, root);
|
|
107
113
|
if (denylist.blocked)
|
|
108
114
|
continue;
|
|
115
|
+
if (credentialFileRisk(ent.name)) {
|
|
116
|
+
credentialSkipped++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
109
119
|
let stat;
|
|
110
120
|
try {
|
|
111
121
|
stat = await fs.stat(abs);
|
|
@@ -158,13 +168,14 @@ export async function grepHandler(args, ctx) {
|
|
|
158
168
|
}
|
|
159
169
|
}
|
|
160
170
|
await walk(realRoot, '');
|
|
171
|
+
const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
|
|
161
172
|
if (matches.length === 0) {
|
|
162
|
-
return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}` };
|
|
173
|
+
return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}${skipNote}` };
|
|
163
174
|
}
|
|
164
175
|
const truncated = matches.length >= MAX_MATCHES;
|
|
165
|
-
const trailer = truncated
|
|
176
|
+
const trailer = (truncated
|
|
166
177
|
? `\n... (cap of ${MAX_MATCHES} matches reached — narrow the pattern or use a glob filter)`
|
|
167
|
-
: '';
|
|
178
|
+
: '') + skipNote;
|
|
168
179
|
if (outputMode === 'files_with_matches') {
|
|
169
180
|
const files = [...new Set(matches.map((m) => m.file))];
|
|
170
181
|
return { content: files.join('\n') + trailer };
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -644,29 +644,29 @@ registerTool({
|
|
|
644
644
|
handler: async (args, ctx) => {
|
|
645
645
|
// runUnitTest(type, name) — type first
|
|
646
646
|
const result = await ctx.adt.runUnitTest(args.type, args.name);
|
|
647
|
-
// B2 false-green guard
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
// a
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
const summary = result
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
|
|
647
|
+
// B2 false-green guard: a run in which no test actually ran reads to a
|
|
648
|
+
// caller like "nothing failed → green". Wrap the (still-carried) raw result
|
|
649
|
+
// in an explicit UNVERIFIED envelope when:
|
|
650
|
+
// - total is 0, missing or not a number — even when programs are present
|
|
651
|
+
// (a class with no methods, or only tolerable class alerts; review I1);
|
|
652
|
+
// - every test was skipped (total > 0, passed 0, failed 0; review M1).
|
|
653
|
+
// It is no longer blamed on an ADT gap: the 2026-10-12 sap-client fix sends
|
|
654
|
+
// the request shape Eclipse sends. A run with a pass or a failure passes
|
|
655
|
+
// through unchanged.
|
|
656
|
+
const summary = result
|
|
657
|
+
?.summary;
|
|
658
|
+
const total = summary?.total;
|
|
659
|
+
const noTestsRun = typeof total !== 'number' || !Number.isFinite(total) || total === 0;
|
|
660
|
+
const allSkipped = !noTestsRun && summary?.passed === 0 && summary?.failed === 0;
|
|
661
|
+
if (noTestsRun || allSkipped) {
|
|
662
|
+
const n = total;
|
|
663
|
+
const warning = noTestsRun
|
|
664
|
+
? '0 tests ran — this is not a pass. Check that the object has an active test ' +
|
|
665
|
+
'class (FOR TESTING) with active test methods, then run it again.'
|
|
666
|
+
: `${n === 1 ? '1 test was' : `${n} tests were`} skipped and none ran — this is not ` +
|
|
667
|
+
'a pass. Check the risk level and duration of the test classes, then run it again.';
|
|
660
668
|
return {
|
|
661
|
-
content: JSON.stringify({
|
|
662
|
-
verification: 'UNVERIFIED',
|
|
663
|
-
warning: 'ABAP Unit run enumerated 0 tests (empty runResult). This does NOT mean the tests ' +
|
|
664
|
-
'passed — it means none executed. A FOR TESTING class that is active + syntax-clean ' +
|
|
665
|
-
'still returning 0 is a known ADT AUnit-run gap (project_aunit_run_gap): the ' +
|
|
666
|
-
'authoritative check is Eclipse "Run As → ABAP Unit" (Ctrl+Shift+F10). Do NOT treat ' +
|
|
667
|
-
'this as a green test gate.',
|
|
668
|
-
result,
|
|
669
|
-
}, null, 2),
|
|
669
|
+
content: JSON.stringify({ verification: 'UNVERIFIED', warning, result }, null, 2),
|
|
670
670
|
};
|
|
671
671
|
}
|
|
672
672
|
return { content: JSON.stringify(result, null, 2) };
|