@salesforce/graphiti 11.15.0 → 11.16.0
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/CHANGELOG.md +10 -0
- package/dist/intent/build-aggregate.js +6 -0
- package/dist/intent/build-aggregate.js.map +1 -1
- package/dist/intent/build-output.d.ts +15 -1
- package/dist/intent/build-output.js +15 -1
- package/dist/intent/build-output.js.map +1 -1
- package/dist/lib/errors.d.ts +54 -1
- package/dist/lib/errors.js +104 -8
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/introspect.js +10 -3
- package/dist/lib/introspect.js.map +1 -1
- package/dist/lib/prime-schema.d.ts +10 -0
- package/dist/lib/prime-schema.js +31 -4
- package/dist/lib/prime-schema.js.map +1 -1
- package/dist/lib/query-builder.d.ts +11 -0
- package/dist/lib/query-builder.js +174 -12
- package/dist/lib/query-builder.js.map +1 -1
- package/dist/mcp/tools/sf-gql-connect.js +1 -1
- package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
- package/dist/schemas/tool-adapter.d.ts +17 -1
- package/dist/schemas/tool-adapter.js +29 -6
- package/dist/schemas/tool-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/intent/__tests__/build-aggregate.spec.ts +24 -0
- package/src/intent/__tests__/build-output.spec.ts +24 -1
- package/src/intent/build-aggregate.ts +6 -0
- package/src/intent/build-output.ts +15 -1
- package/src/lib/__tests__/query-builder.spec.ts +388 -0
- package/src/lib/errors.ts +125 -2
- package/src/lib/introspect.ts +10 -2
- package/src/lib/prime-schema.ts +32 -4
- package/src/lib/query-builder.ts +176 -11
- package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +50 -1
- package/src/mcp/tools/sf-gql-connect.ts +1 -1
- package/src/schemas/__tests__/tool-adapter.spec.ts +162 -1
- package/src/schemas/tool-adapter.ts +51 -7
|
@@ -8,10 +8,14 @@ import { parse } from "graphql";
|
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
import { makeSession, TEST_SCHEMA } from "../../__tests__/helpers/schema.js";
|
|
10
10
|
import { selectLeafInSession } from "../../commands/query.js";
|
|
11
|
+
import { UserInputError } from "../errors.js";
|
|
11
12
|
import { renderQuery } from "../query-builder.js";
|
|
12
13
|
import {
|
|
13
14
|
addVariable,
|
|
14
15
|
createSiblingFieldInstance,
|
|
16
|
+
deepSetArg,
|
|
17
|
+
type FieldProjectionNode,
|
|
18
|
+
type FragmentProjectionNode,
|
|
15
19
|
selectLeaf,
|
|
16
20
|
setAliasOnPath,
|
|
17
21
|
setArg,
|
|
@@ -160,4 +164,388 @@ describe("query-builder", () => {
|
|
|
160
164
|
);
|
|
161
165
|
});
|
|
162
166
|
});
|
|
167
|
+
|
|
168
|
+
// W-23204027: the render layer asserts every emitted GraphQL Name is valid,
|
|
169
|
+
// making "the renderer never emits an injectable identifier" a system-wide
|
|
170
|
+
// invariant layered under the per-builder guards. These tests drive raw,
|
|
171
|
+
// unguarded names directly into the projection tree (bypassing every builder
|
|
172
|
+
// and zod guard) to prove the renderer is the last line of defense, and
|
|
173
|
+
// confirm legitimate output is never over-blocked.
|
|
174
|
+
describe("render-layer GraphQL Name fail-safe (W-23204027)", () => {
|
|
175
|
+
// The selection-set-breakout payload from the parent bug W-22735537: a raw
|
|
176
|
+
// fieldName closes the enclosing block early and hoists a sibling selection.
|
|
177
|
+
const INJECTION = "Id } injectedAlias: Name { value";
|
|
178
|
+
|
|
179
|
+
it("throws when a field node carries a raw, unguarded fieldName", () => {
|
|
180
|
+
const session = makeSession();
|
|
181
|
+
const injected: FieldProjectionNode = {
|
|
182
|
+
id: "fld_injected",
|
|
183
|
+
kind: "field",
|
|
184
|
+
parentId: null, // top-level → rendered by renderChildren(session, null, 1)
|
|
185
|
+
schemaPath: [INJECTION],
|
|
186
|
+
fieldName: INJECTION,
|
|
187
|
+
args: {},
|
|
188
|
+
directives: [],
|
|
189
|
+
};
|
|
190
|
+
session.nodes.push(injected);
|
|
191
|
+
// Pinned to the emitter so the test can't pass for the wrong reason
|
|
192
|
+
// (e.g. a future upstream guard throwing elsewhere).
|
|
193
|
+
expect(() => renderQuery(session)).toThrow(
|
|
194
|
+
/renderField: fieldName .* is not a valid GraphQL Name/,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("throws when selectLeaf stores a raw injection string as the leaf fieldName", () => {
|
|
199
|
+
// Proves the actual residual sink: selectLeaf stores the final path
|
|
200
|
+
// segment verbatim as node.fieldName with no assert of its own.
|
|
201
|
+
const session = makeSession();
|
|
202
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
203
|
+
selectLeaf(session, ["accounts", "edges", "node", INJECTION]);
|
|
204
|
+
expect(() => renderQuery(session)).toThrow(
|
|
205
|
+
/renderField: fieldName .* is not a valid GraphQL Name/,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("throws when an alias is a raw, unguarded name", () => {
|
|
210
|
+
const session = makeSession();
|
|
211
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
212
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
213
|
+
setAliasOnPath(session, ["accounts", "edges", "node", "name"], "evil { injected }");
|
|
214
|
+
expect(() => renderQuery(session)).toThrow(
|
|
215
|
+
/renderField: alias .* is not a valid GraphQL Name/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("throws when an inline-fragment type condition is a raw, unguarded name", () => {
|
|
220
|
+
const session = makeSession();
|
|
221
|
+
const frag: FragmentProjectionNode = {
|
|
222
|
+
id: "frag_injected",
|
|
223
|
+
kind: "fragment",
|
|
224
|
+
parentId: null,
|
|
225
|
+
schemaPath: ["[Account] { id } ... on Contact"],
|
|
226
|
+
onType: "Account { id } ... on Contact",
|
|
227
|
+
directives: [],
|
|
228
|
+
};
|
|
229
|
+
session.nodes.push(frag);
|
|
230
|
+
expect(() => renderQuery(session)).toThrow(
|
|
231
|
+
/renderInlineFragment: onType .* is not a valid GraphQL Name/,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("throws when a directive name is a raw, unguarded name", () => {
|
|
236
|
+
const session = makeSession();
|
|
237
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
238
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
239
|
+
const node = session.nodes.find(
|
|
240
|
+
(n): n is FieldProjectionNode => n.kind === "field" && n.fieldName === "name",
|
|
241
|
+
)!;
|
|
242
|
+
node.directives.push({ name: "skip } injected @", args: {} });
|
|
243
|
+
expect(() => renderQuery(session)).toThrow(
|
|
244
|
+
/renderDirective: directiveName .* is not a valid GraphQL Name/,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("throws when an operation name is a raw, unguarded name", () => {
|
|
249
|
+
const session = makeSession();
|
|
250
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
251
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
252
|
+
session.operationName = "Evil { injected } query X";
|
|
253
|
+
expect(() => renderQuery(session)).toThrow(
|
|
254
|
+
/renderQuery: operationName .* is not a valid GraphQL Name/,
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("throws when a variable name is a raw, unguarded name", () => {
|
|
259
|
+
const session = makeSession();
|
|
260
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
261
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
262
|
+
// Bypass addVariable's normalization to plant a raw name (the CLI `define`
|
|
263
|
+
// path only strips a leading `$`, so a hyphenated name reaches the renderer).
|
|
264
|
+
session.variables.push({ name: "foo-bar", type: "Int" });
|
|
265
|
+
expect(() => renderQuery(session)).toThrow(
|
|
266
|
+
/renderQuery: variableName .* is not a valid GraphQL Name/,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("does not over-block legitimate output: framework fields, aliases, and type conditions parse to one operation", () => {
|
|
271
|
+
const session = makeSession();
|
|
272
|
+
// Connection framework fields (edges/node) + a valid alias.
|
|
273
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
274
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
275
|
+
setAliasOnPath(session, ["accounts", "edges", "node", "name"], "accountName");
|
|
276
|
+
// Inline-fragment type condition (... on Account). selectLeafInSession
|
|
277
|
+
// resolves relative to navigationPath, so reset to the query root first.
|
|
278
|
+
session.navigationPath = ["query"];
|
|
279
|
+
selectLeafInSession(session, "search.[Account].name");
|
|
280
|
+
|
|
281
|
+
const query = renderQuery(session);
|
|
282
|
+
expect(() => renderQuery(session)).not.toThrow();
|
|
283
|
+
|
|
284
|
+
const doc = parse(query);
|
|
285
|
+
expect(doc.definitions).toHaveLength(1);
|
|
286
|
+
expect(doc.definitions[0].kind).toBe("OperationDefinition");
|
|
287
|
+
expect(query).toMatch(/accountName: name/);
|
|
288
|
+
expect(query).toMatch(/\.\.\. on Account \{/);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// W-23204027 (PR #694 review): argument KEYS are emitted verbatim as
|
|
293
|
+
// `<key>: <value>` and are NOT validated at the builder or zod layer
|
|
294
|
+
// (filter/orderBy are z.record(z.unknown()) with no key charset). A hostile
|
|
295
|
+
// filter key can close the argument object early and inject a sibling
|
|
296
|
+
// selection — reproduced as a fully parseable, schema-valid second connection
|
|
297
|
+
// (a silent selection-set injection). The render-layer assert on every
|
|
298
|
+
// emitted key is the universal choke point that closes it.
|
|
299
|
+
describe("render-layer argument-key fail-safe (W-23204027 / PR#694)", () => {
|
|
300
|
+
it("throws on a malicious filter object key emitted via the buildList path (the live sink)", () => {
|
|
301
|
+
// Mirrors buildList: JSON.stringify(spec.filter) stored as the `where`
|
|
302
|
+
// arg value, then rendered via jsonToGraphQL -> valueToGraphQL.
|
|
303
|
+
const session = makeSession();
|
|
304
|
+
session.navigationPath = ["query", "accounts"];
|
|
305
|
+
// A clean-breakout key: closes the object + field selection and opens an
|
|
306
|
+
// attacker-controlled second connection. Without the guard this renders
|
|
307
|
+
// fully parseable, schema-valid GraphQL.
|
|
308
|
+
const filter = {
|
|
309
|
+
'Name: {eq:"x"} }) { edges { node { Id } } } evilAlias: accounts(where: { Industry': {
|
|
310
|
+
eq: "1",
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
deepSetArg(session, ["accounts"], "where", [], JSON.stringify(filter));
|
|
314
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
315
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
316
|
+
expect(() => renderQuery(session)).toThrow(
|
|
317
|
+
/valueToGraphQL: argumentKey .* is not a valid GraphQL Name/,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("throws on a filter key that injects only an extra where-condition", () => {
|
|
322
|
+
const session = makeSession();
|
|
323
|
+
session.navigationPath = ["query", "accounts"];
|
|
324
|
+
const filter = { 'Name: { eq: "hack" }, Industry': { eq: "1" } };
|
|
325
|
+
deepSetArg(session, ["accounts"], "where", [], JSON.stringify(filter));
|
|
326
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
327
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
328
|
+
expect(() => renderQuery(session)).toThrow(
|
|
329
|
+
/valueToGraphQL: argumentKey .* is not a valid GraphQL Name/,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("throws on a raw, unguarded top-level argument key", () => {
|
|
334
|
+
const session = makeSession();
|
|
335
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
336
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
337
|
+
// A top-level arg name is normally a hardcoded builder literal, but
|
|
338
|
+
// sf_gql_raw's `set <path> @args/<key>` can plant an arbitrary one.
|
|
339
|
+
const node = session.nodes.find(
|
|
340
|
+
(n): n is FieldProjectionNode => n.kind === "field" && n.fieldName === "accounts",
|
|
341
|
+
)!;
|
|
342
|
+
node.args["first) { edges } evil"] = "1";
|
|
343
|
+
expect(() => renderQuery(session)).toThrow(
|
|
344
|
+
/renderField: argumentKey .* is not a valid GraphQL Name/,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("throws on a raw, unguarded directive argument key", () => {
|
|
349
|
+
const session = makeSession();
|
|
350
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
351
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
352
|
+
const node = session.nodes.find(
|
|
353
|
+
(n): n is FieldProjectionNode => n.kind === "field" && n.fieldName === "name",
|
|
354
|
+
)!;
|
|
355
|
+
node.directives.push({ name: "include", args: { "if) evil(x": "true" } });
|
|
356
|
+
expect(() => renderQuery(session)).toThrow(
|
|
357
|
+
/renderDirective: argumentKey .* is not a valid GraphQL Name/,
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("does not over-block a realistic complex filter (nested and/or/not, Custom__c fields, enums)", () => {
|
|
362
|
+
const session = makeSession();
|
|
363
|
+
session.navigationPath = ["query", "accounts"];
|
|
364
|
+
// Every key here is a legitimate GraphQL Name: logical operators, field
|
|
365
|
+
// operators, an SF custom field API name, and connection args.
|
|
366
|
+
const filter = {
|
|
367
|
+
and: [
|
|
368
|
+
{ Name: { like: "Acme%" } },
|
|
369
|
+
{ Custom_Field__c: { eq: "widget" } },
|
|
370
|
+
{ or: [{ AnnualRevenue: { gt: 1000 } }, { NumberOfEmployees: { lt: 50 } }] },
|
|
371
|
+
{ not: { Status: { in: ["Open", "Closed"] } } },
|
|
372
|
+
],
|
|
373
|
+
};
|
|
374
|
+
deepSetArg(session, ["accounts"], "where", [], JSON.stringify(filter));
|
|
375
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
376
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
377
|
+
|
|
378
|
+
expect(() => renderQuery(session)).not.toThrow();
|
|
379
|
+
const query = renderQuery(session);
|
|
380
|
+
expect(() => parse(query)).not.toThrow();
|
|
381
|
+
// Custom__c field key and logical operators all survive the guard.
|
|
382
|
+
expect(query).toMatch(/Custom_Field__c: \{ eq: "widget" \}/);
|
|
383
|
+
expect(query).toMatch(/and: \[/);
|
|
384
|
+
expect(query).toMatch(/not: \{ Status:/);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// W-23204027 (PR #694 review): a variable's default VALUE was emitted raw
|
|
389
|
+
// (`$name: Type = <defaultValue>`) — the last unformatted value position in the
|
|
390
|
+
// renderer. Raw emission both breaks legitimate output (a multi-word string
|
|
391
|
+
// default fails to parse) and is a live selection-set/operation injection sink
|
|
392
|
+
// reachable via sf_gql_raw's `var $x <path> '<default>'`. The default now goes
|
|
393
|
+
// through formatArgValue. NOTE: the reviewer's literal suggestion (route through
|
|
394
|
+
// formatArgValue) was necessary but NOT sufficient on its own — formatArgValue's
|
|
395
|
+
// quoted-string passthrough (`startsWith('"') && endsWith('"')`) let a payload
|
|
396
|
+
// that merely starts and ends with a quote break out anyway, so that branch was
|
|
397
|
+
// tightened to pass only a single well-formed literal. The passthrough test
|
|
398
|
+
// below is the regression guard for that hole.
|
|
399
|
+
describe("render-layer variable-default fail-safe (W-23204027 / PR#694)", () => {
|
|
400
|
+
function withDefault(type: string, defaultValue: string): string {
|
|
401
|
+
const session = makeSession();
|
|
402
|
+
addVariable(session, "w", type, defaultValue);
|
|
403
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
404
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
405
|
+
return renderQuery(session);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The security property is structural, not textual: an injection payload may
|
|
409
|
+
// still appear verbatim *inside a quoted string literal* (that's the point —
|
|
410
|
+
// it's been neutralized into a scalar value), so a naive substring check would
|
|
411
|
+
// misfire. Instead, assert the parsed document is exactly one operation whose
|
|
412
|
+
// only top-level field is the legitimate `accounts` — proving nothing broke
|
|
413
|
+
// out into the selection set or a second operation.
|
|
414
|
+
function topLevelFieldNames(query: string): string[] {
|
|
415
|
+
const doc = parse(query);
|
|
416
|
+
expect(doc.definitions).toHaveLength(1);
|
|
417
|
+
const op = doc.definitions[0];
|
|
418
|
+
if (op.kind !== "OperationDefinition") throw new Error(`not an operation: ${op.kind}`);
|
|
419
|
+
return op.selectionSet.selections.map((sel) =>
|
|
420
|
+
sel.kind === "Field" ? (sel.alias?.value ?? sel.name.value) : `<${sel.kind}>`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
it("quotes a legitimate multi-word string default so the query still parses", () => {
|
|
425
|
+
// Was: `= Acme Corp` -> "Syntax Error: Expected \"$\", found Name \"Corp\"".
|
|
426
|
+
const query = withDefault("String", "Acme Corp");
|
|
427
|
+
expect(query).toContain('$w: String = "Acme Corp"');
|
|
428
|
+
expect(topLevelFieldNames(query)).toEqual(["accounts"]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("neutralizes an unquoted operation-injection default (no second operation)", () => {
|
|
432
|
+
// Raw: `= 5) { stolen { Id } } query Decoy($z: Int` -> two operations.
|
|
433
|
+
// After the fix the payload is confined to one quoted scalar default, so
|
|
434
|
+
// the document stays a single operation with only the `accounts` field.
|
|
435
|
+
const query = withDefault("Int", "5) { stolen { Id } } query Decoy($z: Int");
|
|
436
|
+
expect(topLevelFieldNames(query)).toEqual(["accounts"]);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("neutralizes a QUOTED-passthrough breakout default (the reviewer's fix alone would miss this)", () => {
|
|
440
|
+
// This payload both starts and ends with `"`, so a bare quoted-string
|
|
441
|
+
// passthrough would emit it verbatim and render two operations / a sibling
|
|
442
|
+
// `stolen` field. The tightened passthrough re-encodes it into one safe
|
|
443
|
+
// literal instead, so `accounts` remains the only top-level field.
|
|
444
|
+
const query = withDefault("String", '"a") { stolen } query Y($q: String = "b"');
|
|
445
|
+
expect(topLevelFieldNames(query)).toEqual(["accounts"]);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("does not over-block legitimate scalar/enum defaults", () => {
|
|
449
|
+
// Numbers, bools, and bare enum tokens must stay unquoted; a real quoted
|
|
450
|
+
// string literal must pass through untouched.
|
|
451
|
+
expect(withDefault("Int", "10")).toContain("$w: Int = 10");
|
|
452
|
+
expect(withDefault("Boolean", "true")).toContain("$w: Boolean = true");
|
|
453
|
+
expect(withDefault("SortOrder", "DESC")).toContain("$w: SortOrder = DESC");
|
|
454
|
+
expect(withDefault("String", '"hello world"')).toContain('$w: String = "hello world"');
|
|
455
|
+
const legit: Record<string, string> = {
|
|
456
|
+
Int: "10",
|
|
457
|
+
Boolean: "true",
|
|
458
|
+
SortOrder: "DESC",
|
|
459
|
+
String: '"hello world"',
|
|
460
|
+
};
|
|
461
|
+
for (const [type, value] of Object.entries(legit)) {
|
|
462
|
+
expect(() => parse(withDefault(type, value))).not.toThrow();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("routes an input-object default through the arg-key guard (throws on a hostile key)", () => {
|
|
467
|
+
// An object default is JSON.stringify'd, so its keys flow through the same
|
|
468
|
+
// valueToGraphQL arg-key assert — a malicious input-object field name in a
|
|
469
|
+
// default is closed by the render-layer fail-safe, same as in `where`.
|
|
470
|
+
const evil: Record<string, unknown> = {};
|
|
471
|
+
evil["x } ) { stolen } q("] = 1;
|
|
472
|
+
expect(() => withDefault("AccountFilter", JSON.stringify(evil))).toThrow(
|
|
473
|
+
/valueToGraphQL: argumentKey .* is not a valid GraphQL Name/,
|
|
474
|
+
);
|
|
475
|
+
// ...while a well-formed object default renders fine.
|
|
476
|
+
const ok = withDefault("AccountFilter", JSON.stringify({ minRevenue: 100 }));
|
|
477
|
+
expect(ok).toContain("$w: AccountFilter = { minRevenue: 100 }");
|
|
478
|
+
expect(() => parse(ok)).not.toThrow();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// W-23204027 (PR #694 review, Round 3): the tests above only ever fed
|
|
482
|
+
// *valid* JSON to a `{`/`[` default, so they exercised jsonToGraphQL's
|
|
483
|
+
// success path. Its `catch` branch — hit when a `{`/`[`-prefixed default is
|
|
484
|
+
// NOT well-formed JSON — used to `return jsonStr` verbatim, skipping
|
|
485
|
+
// valueToGraphQL's arg-key guard entirely. That was a live injection sink:
|
|
486
|
+
// a GraphQL input-object literal like `{ minRevenue: 0 }` has an UNQUOTED
|
|
487
|
+
// key, so it fails JSON.parse and hit the catch. With a named operation and
|
|
488
|
+
// a wired-in decoy variable it rendered a fully parse- AND validate-clean
|
|
489
|
+
// second operation. The fix REJECTS (throws a typed UserInputError) instead
|
|
490
|
+
// of emitting raw. No legitimate producer reaches this branch — the builders
|
|
491
|
+
// JSON.stringify their objects (valid JSON), and the CLI set/assign path
|
|
492
|
+
// JSON-validates `{`/`[` literals before storing.
|
|
493
|
+
it("throws UserInputError on an invalid-JSON object default (the jsonToGraphQL catch sink)", () => {
|
|
494
|
+
// Unquoted key ⇒ invalid JSON ⇒ jsonToGraphQL catch. The reviewer's
|
|
495
|
+
// escalation payload: closes the arg + selection, opens a second op.
|
|
496
|
+
const payload =
|
|
497
|
+
"{ minRevenue: 0 }) { edges { node { id } } } } query Decoy($z: AccountFilter";
|
|
498
|
+
expect(() => withDefault("AccountFilter", payload)).toThrow(UserInputError);
|
|
499
|
+
expect(() => withDefault("AccountFilter", payload)).toThrow(
|
|
500
|
+
/is not valid JSON and cannot be rendered as a GraphQL literal/,
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("throws UserInputError on an invalid-JSON array default too", () => {
|
|
505
|
+
// The `[`-prefixed branch of formatArgValue routes here as well.
|
|
506
|
+
expect(() => withDefault("AccountFilter", "[1, 2) { stolen } q(")).toThrow(UserInputError);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// W-23204027 (PR #694 review, Round 3): a variable's TYPE is emitted verbatim
|
|
511
|
+
// as `$name: <type>`. It is NOT a bare Name (it carries `!`/`[]`), so it's
|
|
512
|
+
// guarded structurally by assertGraphqlType — peel the wrappers, then assert
|
|
513
|
+
// the innermost NamedType is a valid GraphQL Name. This is a defense-in-depth
|
|
514
|
+
// backstop: no current builder feeds a raw agent-controlled type (schema
|
|
515
|
+
// inference / createInputTypeName / hardcoded scalars only; CLI var/define set
|
|
516
|
+
// the NAME, not the type), but a tampered/migrated on-disk session or a future
|
|
517
|
+
// builder could. Without the guard, a type like `Int) { evil } query Decoy($z: Int`
|
|
518
|
+
// breaks out into a second operation with no default value needed.
|
|
519
|
+
describe("render-layer variable-type fail-safe (W-23204027 / PR#694)", () => {
|
|
520
|
+
function withRawType(type: string): () => string {
|
|
521
|
+
const session = makeSession();
|
|
522
|
+
// Bypass addVariable so the raw type reaches the renderer verbatim.
|
|
523
|
+
session.variables.push({ name: "v", type });
|
|
524
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
525
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
526
|
+
return () => renderQuery(session);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
it("throws when the innermost type is a breakout string", () => {
|
|
530
|
+
expect(withRawType("Int) { evil { id } } query Decoy($z: Int")).toThrow(
|
|
531
|
+
/renderQuery: variableType .* is not a valid GraphQL Name/,
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("throws when a non-null/list wrapper hides a breakout inner type", () => {
|
|
536
|
+
// The `!`/`[]` wrappers are peeled; the innermost NamedType is asserted.
|
|
537
|
+
expect(withRawType("[Int) { evil } q(!]!")).toThrow(
|
|
538
|
+
/renderQuery: variableType .* is not a valid GraphQL Name/,
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("does not over-block legitimate type references (scalars, non-null, lists, list-of-non-null)", () => {
|
|
543
|
+
for (const type of ["Int", "ID!", "[String]", "[ID!]!", "AccountFilter", "Custom__c"]) {
|
|
544
|
+
expect(withRawType(type)).not.toThrow();
|
|
545
|
+
const query = withRawType(type)();
|
|
546
|
+
expect(query).toContain(`$v: ${type}`);
|
|
547
|
+
expect(() => parse(query)).not.toThrow();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
});
|
|
163
551
|
});
|
package/src/lib/errors.ts
CHANGED
|
@@ -14,6 +14,119 @@
|
|
|
14
14
|
* Everything unmatched falls through to `Internal:`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Retryability disposition for a `Schema:` failure (W-23148365). Surfaced to the
|
|
19
|
+
* MCP host as a closed-set `[retry=...]` token appended to the error text so an
|
|
20
|
+
* agent can decide whether to retry, back off, or give up:
|
|
21
|
+
* - `"now"` — retry immediately; the failure involved no live org round-trip
|
|
22
|
+
* (a priming-lock wait timeout), so an instant retry is likely to
|
|
23
|
+
* succeed.
|
|
24
|
+
* - `"backoff"` — the introspection request failed transiently (HTTP 5xx/429/420
|
|
25
|
+
* or a network errno) AND the connection layer already exhausted
|
|
26
|
+
* its one built-in retry, so wait with increasing backoff before
|
|
27
|
+
* retrying — some org-side conditions (e.g. an API rate limit) may
|
|
28
|
+
* take longer than a second or two to clear.
|
|
29
|
+
* - `"no"` — permanent: a 4xx, a malformed/absent `__schema`, GraphQL errors
|
|
30
|
+
* in the introspection body, or no cached schema. Don't retry —
|
|
31
|
+
* fix the request, re-authenticate, or (re)prime via `sf_gql_connect`.
|
|
32
|
+
* `Auth` / `UserInput` / `Internal` errors are uniformly non-retryable and never
|
|
33
|
+
* carry a token; only `SchemaError` / `SchemaRefreshError` carry `retry`.
|
|
34
|
+
*/
|
|
35
|
+
export type RetryHint = "now" | "backoff" | "no";
|
|
36
|
+
|
|
37
|
+
// HTTP statuses worth a backoff-then-retry. Mirrors the connection layer's own
|
|
38
|
+
// retry set (introspect.ts INTROSPECTION_REQUEST_OPTIONS): 420 (Salesforce
|
|
39
|
+
// REQUEST_LIMIT_EXCEEDED legacy), 429 (Too Many Requests), 5xx gateway/server.
|
|
40
|
+
const TRANSIENT_STATUS = new Set([420, 429, 500, 502, 503, 504]);
|
|
41
|
+
// Deterministic client/redirect failures: retrying the identical request won't help.
|
|
42
|
+
const PERMANENT_STATUS = new Set([400, 401, 403, 404, 405, 409, 410, 422]);
|
|
43
|
+
// Node network errnos that typically clear on retry (transient org round-trip
|
|
44
|
+
// failures). These are org-reachability problems, so `backoff` correctly tells
|
|
45
|
+
// the agent to wait for the org/network to recover.
|
|
46
|
+
const TRANSIENT_ERRNO = new Set([
|
|
47
|
+
"ECONNRESET",
|
|
48
|
+
"ETIMEDOUT",
|
|
49
|
+
"EAI_AGAIN",
|
|
50
|
+
"ECONNREFUSED",
|
|
51
|
+
"EPIPE",
|
|
52
|
+
"ESOCKETTIMEDOUT",
|
|
53
|
+
]);
|
|
54
|
+
// Errnos that won't clear on a short retry: a wrong host (ENOTFOUND — deliberately
|
|
55
|
+
// treated as permanent since a typo'd instance URL is the common case, not a DNS
|
|
56
|
+
// hiccup), a missing path, or a permissions/read-only-fs failure — PLUS local
|
|
57
|
+
// resource-exhaustion errnos raised by the cache write (`atomicWriteJson`), which
|
|
58
|
+
// shares the download `try`: ENOSPC (disk full), EMFILE (fd exhaustion), and EAGAIN.
|
|
59
|
+
// These are host-side ops problems, not org round-trips, so a `backoff` hint would
|
|
60
|
+
// both mislead the agent ("the org was unreachable") and mask the real failure;
|
|
61
|
+
// classify them `no` so the operator sees the raw error instead of burned retries.
|
|
62
|
+
const PERMANENT_ERRNO = new Set([
|
|
63
|
+
"ENOTFOUND",
|
|
64
|
+
"ENOENT",
|
|
65
|
+
"EACCES",
|
|
66
|
+
"EROFS",
|
|
67
|
+
"ENOSPC",
|
|
68
|
+
"EMFILE",
|
|
69
|
+
"EAGAIN",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classify the underlying cause of a Schema failure into a {@link RetryHint}.
|
|
74
|
+
*
|
|
75
|
+
* Defensive by construction: inspects an untyped `cause` (the jsforce /
|
|
76
|
+
* `@salesforce/core` error that bubbled up from `connection.request`, or a Node
|
|
77
|
+
* `ErrnoException` from a cache write) without assuming a type. Reads three shapes,
|
|
78
|
+
* in order: a numeric HTTP `statusCode`, a parsed `errorCode` / `name` (e.g.
|
|
79
|
+
* `ERROR_HTTP_503`, `REQUEST_LIMIT_EXCEEDED`), then the network/IO `code` errno.
|
|
80
|
+
* For a real jsforce HTTP failure the `ERROR_HTTP_<nnn>` regex on `errorCode`/`name`
|
|
81
|
+
* is the load-bearing path — jsforce-node's `HttpApiError` sets string `name`/
|
|
82
|
+
* `errorCode` but NOT a numeric `.statusCode`, so the first branch is a defensive
|
|
83
|
+
* fallback for other cause shapes (and the contract tests' `statusCode`-bearing
|
|
84
|
+
* doubles), not the production trigger. Anything unrecognized returns `"no"` — we
|
|
85
|
+
* never INVENT retryability, and the connection layer has already spent its one
|
|
86
|
+
* transient retry before the error reaches us, so an unknown failure that survived
|
|
87
|
+
* that retry is treated as permanent.
|
|
88
|
+
*
|
|
89
|
+
* Every property read is wrapped so a `cause` with a throwing accessor cannot
|
|
90
|
+
* escape (this runs inside `runTool`'s catch, where an escaped throw would drop
|
|
91
|
+
* the sanitized `<Category>: <message>` envelope and leak a raw SDK error). Real
|
|
92
|
+
* causes (jsforce/`@salesforce/core`/`fs` errors) carry plain-data fields, so this
|
|
93
|
+
* is a defensive backstop, not a live path; a throw simply falls back to `"no"`.
|
|
94
|
+
*/
|
|
95
|
+
export function classifyCause(cause: unknown): RetryHint {
|
|
96
|
+
if (typeof cause !== "object" || cause === null) return "no";
|
|
97
|
+
const c = cause as Record<string, unknown>;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const status = typeof c.statusCode === "number" ? c.statusCode : undefined;
|
|
101
|
+
if (status !== undefined) {
|
|
102
|
+
if (TRANSIENT_STATUS.has(status)) return "backoff";
|
|
103
|
+
if (PERMANENT_STATUS.has(status)) return "no";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const codeStr = typeof c.errorCode === "string" ? c.errorCode : "";
|
|
107
|
+
const nameStr = typeof c.name === "string" ? c.name : "";
|
|
108
|
+
const httpMatch = /ERROR_HTTP_(\d{3})/.exec(`${codeStr} ${nameStr}`);
|
|
109
|
+
if (httpMatch) {
|
|
110
|
+
const httpStatus = Number(httpMatch[1]);
|
|
111
|
+
if (TRANSIENT_STATUS.has(httpStatus)) return "backoff";
|
|
112
|
+
if (PERMANENT_STATUS.has(httpStatus)) return "no";
|
|
113
|
+
}
|
|
114
|
+
if (codeStr === "REQUEST_LIMIT_EXCEEDED" || nameStr === "REQUEST_LIMIT_EXCEEDED") {
|
|
115
|
+
return "backoff";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Network/IO errno (`code` is the errno string for a NodeJS.ErrnoException).
|
|
119
|
+
const errno = typeof c.code === "string" ? c.code : "";
|
|
120
|
+
if (TRANSIENT_ERRNO.has(errno)) return "backoff";
|
|
121
|
+
if (PERMANENT_ERRNO.has(errno)) return "no";
|
|
122
|
+
} catch {
|
|
123
|
+
// A throwing getter on the cause → treat as unclassifiable (permanent).
|
|
124
|
+
return "no";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return "no";
|
|
128
|
+
}
|
|
129
|
+
|
|
17
130
|
/** Credential/auth resolution failure (e.g. unknown org, expired token). → `Auth:` */
|
|
18
131
|
export class AuthError extends Error {
|
|
19
132
|
constructor(message: string, opts?: { cause?: unknown }) {
|
|
@@ -36,10 +149,20 @@ export class UserInputError extends Error {
|
|
|
36
149
|
}
|
|
37
150
|
}
|
|
38
151
|
|
|
39
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Schema introspection / priming / build failure. → `Schema:`
|
|
154
|
+
*
|
|
155
|
+
* Carries a {@link RetryHint} (`retry`, default `"no"`) stamped at the throw site
|
|
156
|
+
* from the underlying cause (W-23148365). The MCP adapter reads this field to
|
|
157
|
+
* append the `[retry=...]` token; throw sites that know the disposition (a
|
|
158
|
+
* permanent missing-`__schema`, a transient lock timeout) set it explicitly,
|
|
159
|
+
* and the lazy-prime wrap derives it via {@link classifyCause}.
|
|
160
|
+
*/
|
|
40
161
|
export class SchemaError extends Error {
|
|
41
|
-
|
|
162
|
+
readonly retry: RetryHint;
|
|
163
|
+
constructor(message: string, opts?: { cause?: unknown; retry?: RetryHint }) {
|
|
42
164
|
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
43
165
|
this.name = "SchemaError";
|
|
166
|
+
this.retry = opts?.retry ?? "no";
|
|
44
167
|
}
|
|
45
168
|
}
|
package/src/lib/introspect.ts
CHANGED
|
@@ -278,12 +278,17 @@ export async function downloadSchema(auth: OrgAuth): Promise<SchemaMetadata> {
|
|
|
278
278
|
const messages = (rawResult.errors as any[])
|
|
279
279
|
.map((e: any) => e.message ?? JSON.stringify(e))
|
|
280
280
|
.join("\n ");
|
|
281
|
-
|
|
281
|
+
// GraphQL errors in a 200 body are deterministic — retrying the identical
|
|
282
|
+
// query won't help (W-23148365).
|
|
283
|
+
throw new SchemaError(`Introspection query returned errors:\n ${messages}`, { retry: "no" });
|
|
282
284
|
}
|
|
283
285
|
|
|
284
286
|
const rawSchema = rawResult?.data?.__schema ?? rawResult?.__schema;
|
|
285
287
|
if (!rawSchema) {
|
|
286
|
-
|
|
288
|
+
// A well-formed 200 with no __schema is a permanent shape problem.
|
|
289
|
+
throw new SchemaError("Introspection query did not return a __schema field", {
|
|
290
|
+
retry: "no",
|
|
291
|
+
});
|
|
287
292
|
}
|
|
288
293
|
|
|
289
294
|
const { result, removedCount } = stripDataCloudTypes(rawResult);
|
|
@@ -318,8 +323,11 @@ export async function downloadSchema(auth: OrgAuth): Promise<SchemaMetadata> {
|
|
|
318
323
|
export function loadIntrospectionResult(instanceUrl: string): any {
|
|
319
324
|
const fp = schemaPathForInstanceUrl(normalizeInstanceUrl(instanceUrl));
|
|
320
325
|
if (!fs.existsSync(fp)) {
|
|
326
|
+
// Retrying this read won't help — the org was never primed. The fix is to
|
|
327
|
+
// prime (a different action), so this is permanent (W-23148365).
|
|
321
328
|
throw new SchemaError(
|
|
322
329
|
`No cached schema for "${instanceUrl}". Run \`graphiti connect <org>\` first.`,
|
|
330
|
+
{ retry: "no" },
|
|
323
331
|
);
|
|
324
332
|
}
|
|
325
333
|
return JSON.parse(fs.readFileSync(fp, "utf-8"));
|
package/src/lib/prime-schema.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { getOrgAuth as realGetOrgAuth, type OrgAuth } from "./auth.js";
|
|
10
|
-
import { SchemaError } from "./errors.js";
|
|
10
|
+
import { classifyCause, type RetryHint, SchemaError } from "./errors.js";
|
|
11
11
|
import {
|
|
12
12
|
downloadSchema as realDownloadSchema,
|
|
13
13
|
getSchemaMetadata,
|
|
@@ -96,8 +96,13 @@ export async function withSchemaLock<T>(
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (Date.now() - startedWaitingAt > MAX_WAIT_MS) {
|
|
99
|
+
// Pure filesystem contention — another holder was downloading; an
|
|
100
|
+
// immediate retry likely finds a fresh cache. No live org round-trip
|
|
101
|
+
// occurred, so retry=now (W-23148365). Not covered by the connection
|
|
102
|
+
// layer's built-in retry, so the host's retry is the only one left.
|
|
99
103
|
throw new SchemaError(
|
|
100
104
|
`Timed out waiting ${MAX_WAIT_MS}ms for schema priming lock at ${lockPath}`,
|
|
105
|
+
{ retry: "now" },
|
|
101
106
|
);
|
|
102
107
|
}
|
|
103
108
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
@@ -157,18 +162,27 @@ export interface PrimeResult {
|
|
|
157
162
|
* returns null for a corrupt file, which correctly degrades to a hard failure
|
|
158
163
|
* (a corrupt cache is not usable). `instanceUrl` is always the resolved org URL
|
|
159
164
|
* (known before the download).
|
|
165
|
+
*
|
|
166
|
+
* Carries a {@link RetryHint} (`retry`, default `"no"`) for the MCP error surface
|
|
167
|
+
* (W-23148365), derived from the underlying cause via {@link classifyCause}. Note
|
|
168
|
+
* the surviving-cache case never reaches the MCP token surface: `buildConnect`
|
|
169
|
+
* intercepts a `SchemaRefreshError` with a defined `staleSince` (i.e. a cache
|
|
170
|
+
* survived) and returns a soft `warnings[]` success before `runTool` can stamp a
|
|
171
|
+
* token — so `retry` is only ever observed for the no-surviving-cache hard failure.
|
|
160
172
|
*/
|
|
161
173
|
export class SchemaRefreshError extends Error {
|
|
162
174
|
staleSince?: string;
|
|
163
175
|
instanceUrl: string;
|
|
176
|
+
readonly retry: RetryHint;
|
|
164
177
|
constructor(
|
|
165
178
|
message: string,
|
|
166
|
-
opts: { instanceUrl: string; staleSince?: string; cause?: unknown },
|
|
179
|
+
opts: { instanceUrl: string; staleSince?: string; cause?: unknown; retry?: RetryHint },
|
|
167
180
|
) {
|
|
168
181
|
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
169
182
|
this.name = "SchemaRefreshError";
|
|
170
183
|
this.staleSince = opts.staleSince;
|
|
171
184
|
this.instanceUrl = opts.instanceUrl;
|
|
185
|
+
this.retry = opts.retry ?? "no";
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
|
|
@@ -286,20 +300,34 @@ export async function primeSchemaWithLock(
|
|
|
286
300
|
// the underlying failure (FR-13.5/13.6). Wrap untyped causes (e.g. a
|
|
287
301
|
// raw @salesforce/core/jsforce network error from connection.request)
|
|
288
302
|
// in SchemaError so the MCP boundary classifies priming failures as
|
|
289
|
-
// `Schema:`; a cause that is already SchemaError passes through.
|
|
303
|
+
// `Schema:`; a cause that is already SchemaError passes through. The
|
|
304
|
+
// retry hint is derived from the cause (W-23148365): a 5xx/network
|
|
305
|
+
// failure that outlived the connection layer's one retry → backoff;
|
|
306
|
+
// a 4xx / unrecognized failure → no (permanent).
|
|
290
307
|
if (!forceRefresh) {
|
|
291
308
|
if (cause instanceof SchemaError) throw cause;
|
|
292
309
|
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
293
|
-
throw new SchemaError(`Schema priming failed for "${orgAlias}": ${msg}`, {
|
|
310
|
+
throw new SchemaError(`Schema priming failed for "${orgAlias}": ${msg}`, {
|
|
311
|
+
cause,
|
|
312
|
+
retry: classifyCause(cause),
|
|
313
|
+
});
|
|
294
314
|
}
|
|
295
315
|
// Forced refresh: atomic writes mean the old JSON is still on
|
|
296
316
|
// disk, so surface a staleness-aware error and leave every cache
|
|
297
317
|
// untouched.
|
|
298
318
|
const surviving = getSchemaMetadata(instanceUrl);
|
|
319
|
+
// The `retry` disposition is the raw cause disposition. When a cache
|
|
320
|
+
// survives, `buildConnect` intercepts this error (it keys on a defined
|
|
321
|
+
// `staleSince`) and returns a soft `warnings[]` success *before* the MCP
|
|
322
|
+
// adapter stamps a token — so `retry` is only ever surfaced on the
|
|
323
|
+
// no-surviving-cache hard failure, where the cause disposition is exactly
|
|
324
|
+
// what the host should act on. (Do not reintroduce a `surviving`-floored
|
|
325
|
+
// "now": it would be dead code on the MCP surface and no consumer reads it.)
|
|
299
326
|
throw new SchemaRefreshError(buildStaleMessage(orgAlias, surviving), {
|
|
300
327
|
staleSince: surviving?.downloadedAt,
|
|
301
328
|
instanceUrl,
|
|
302
329
|
cause,
|
|
330
|
+
retry: classifyCause(cause),
|
|
303
331
|
});
|
|
304
332
|
}
|
|
305
333
|
typeCount = meta.typeCount;
|