@salesforce/graphiti 11.14.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/intent/build-aggregate.js +6 -0
  3. package/dist/intent/build-aggregate.js.map +1 -1
  4. package/dist/intent/build-output.d.ts +15 -1
  5. package/dist/intent/build-output.js +15 -1
  6. package/dist/intent/build-output.js.map +1 -1
  7. package/dist/lib/errors.d.ts +54 -1
  8. package/dist/lib/errors.js +104 -8
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/introspect.js +10 -3
  11. package/dist/lib/introspect.js.map +1 -1
  12. package/dist/lib/prime-schema.d.ts +10 -0
  13. package/dist/lib/prime-schema.js +31 -4
  14. package/dist/lib/prime-schema.js.map +1 -1
  15. package/dist/lib/query-builder.d.ts +11 -0
  16. package/dist/lib/query-builder.js +174 -12
  17. package/dist/lib/query-builder.js.map +1 -1
  18. package/dist/mcp/tools/sf-gql-connect.js +1 -1
  19. package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
  20. package/dist/schemas/tool-adapter.d.ts +17 -1
  21. package/dist/schemas/tool-adapter.js +29 -6
  22. package/dist/schemas/tool-adapter.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/intent/__tests__/build-aggregate.spec.ts +24 -0
  25. package/src/intent/__tests__/build-output.spec.ts +24 -1
  26. package/src/intent/build-aggregate.ts +6 -0
  27. package/src/intent/build-output.ts +15 -1
  28. package/src/lib/__tests__/query-builder.spec.ts +388 -0
  29. package/src/lib/errors.ts +125 -2
  30. package/src/lib/introspect.ts +10 -2
  31. package/src/lib/prime-schema.ts +32 -4
  32. package/src/lib/query-builder.ts +176 -11
  33. package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +50 -1
  34. package/src/mcp/tools/sf-gql-connect.ts +1 -1
  35. package/src/schemas/__tests__/tool-adapter.spec.ts +162 -1
  36. 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
- /** Schema introspection / priming / build failure. → `Schema:` */
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
- constructor(message: string, opts?: { cause?: unknown }) {
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
  }
@@ -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
- throw new SchemaError(`Introspection query returned errors:\n ${messages}`);
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
- throw new SchemaError("Introspection query did not return a __schema field");
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"));
@@ -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}`, { cause });
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;