@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.
- package/CHANGELOG.md +14 -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
package/src/lib/query-builder.ts
CHANGED
|
@@ -4,12 +4,24 @@
|
|
|
4
4
|
* For full license text, see the LICENSE.txt file
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { UserInputError } from "./errors.js";
|
|
8
|
+
import { assertGraphqlName, GRAPHQL_NAME_RE } from "./graphql-name.js";
|
|
8
9
|
import type { QuerySession, ProjectionNode, DirectiveNode } from "./session.js";
|
|
9
10
|
import { getChildren, getEffectiveArgs } from "./session.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Renders a QuerySession's selection tree into a properly formatted GraphQL query string.
|
|
14
|
+
*
|
|
15
|
+
* @throws if any emitted GraphQL Name (operation name, variable name, field
|
|
16
|
+
* name, alias, inline-fragment type condition, or directive name) is not a
|
|
17
|
+
* valid GraphQL Name, if a variable's type reference has a non-Name innermost
|
|
18
|
+
* NamedType, or if a `{`/`[`-prefixed argument/default value is not valid JSON
|
|
19
|
+
* — the W-23204027 render-layer fail-safe. This fires only on a programmer
|
|
20
|
+
* error (a builder/CLI path that stored a raw identifier without its own guard)
|
|
21
|
+
* or a hostile input that slipped past the per-builder guards; every legitimate
|
|
22
|
+
* value passes. Name violations throw an `Error` whose message matches
|
|
23
|
+
* USER_INPUT_RE; the JSON-literal violation throws a typed `UserInputError` —
|
|
24
|
+
* both classify as UserInput at the MCP boundary (`runTool`).
|
|
13
25
|
*/
|
|
14
26
|
export function renderQuery(session: QuerySession): string {
|
|
15
27
|
const parts: string[] = [];
|
|
@@ -19,14 +31,41 @@ export function renderQuery(session: QuerySession): string {
|
|
|
19
31
|
session.variables.length > 0
|
|
20
32
|
? `(${session.variables
|
|
21
33
|
.map((v) => {
|
|
34
|
+
// Fail-safe: the variable NAME is a GraphQL Name position emitted
|
|
35
|
+
// verbatim as `$<name>`. The TYPE is also emitted verbatim, but it
|
|
36
|
+
// is not a bare Name — it carries type-reference syntax (`!`, `[]`)
|
|
37
|
+
// — so it is guarded structurally by assertGraphqlType, which walks
|
|
38
|
+
// the wrappers and asserts only the innermost NamedType.
|
|
39
|
+
assertGraphqlName(v.name, "renderQuery", "variableName");
|
|
40
|
+
assertGraphqlType(v.type, "renderQuery");
|
|
22
41
|
let def = `$${v.name}: ${v.type}`;
|
|
23
|
-
|
|
42
|
+
// W-23204027 (PR #694 review): the default VALUE is a value position,
|
|
43
|
+
// not a Name — it must be formatted like any other arg value, not
|
|
44
|
+
// concatenated raw. Raw emission both breaks legitimate output (a
|
|
45
|
+
// multi-word string default renders `= Acme Corp`, which fails to
|
|
46
|
+
// parse) and is a live selection-set/operation injection sink: a
|
|
47
|
+
// default of `5) { stolen { Id } } query Decoy($z: Int` (reachable
|
|
48
|
+
// via `sf_gql_raw`'s `var $x <path> '<default>'`) would otherwise
|
|
49
|
+
// render a second, attacker-controlled operation. formatArgValue
|
|
50
|
+
// quotes strings, passes through numbers/enums/bools/`$refs`, and
|
|
51
|
+
// routes `{`/`[` defaults through jsonToGraphQL, which either emits
|
|
52
|
+
// an arg-key-guarded input-object literal (valid JSON) or throws
|
|
53
|
+
// (invalid JSON — see Round 3 fix in jsonToGraphQL). No variant
|
|
54
|
+
// reaches raw emission.
|
|
55
|
+
if (v.defaultValue !== undefined) def += ` = ${formatArgValue(v.defaultValue)}`;
|
|
24
56
|
return def;
|
|
25
57
|
})
|
|
26
58
|
.join(", ")})`
|
|
27
59
|
: "";
|
|
28
60
|
|
|
29
61
|
const operationKeyword = session.operation === "aggregate" ? "query" : session.operation;
|
|
62
|
+
// Fail-safe: operationName is a GraphQL Name position emitted verbatim in the
|
|
63
|
+
// operation header. Guarded at every builder, but a deserialized/migrated
|
|
64
|
+
// session (loadSession reads it from disk with no re-validation) or a future
|
|
65
|
+
// builder could bypass that — so backstop it here too.
|
|
66
|
+
if (session.operationName) {
|
|
67
|
+
assertGraphqlName(session.operationName, "renderQuery", "operationName");
|
|
68
|
+
}
|
|
30
69
|
const operationName = session.operationName ? ` ${session.operationName}` : "";
|
|
31
70
|
const operationBody = renderChildren(session, null, 1);
|
|
32
71
|
if (operationBody) {
|
|
@@ -40,6 +79,63 @@ export function renderQuery(session: QuerySession): string {
|
|
|
40
79
|
return parts.join("\n");
|
|
41
80
|
}
|
|
42
81
|
|
|
82
|
+
/**
|
|
83
|
+
* W-23204027 render-layer fail-safe for GraphQL argument KEYS. An argument key
|
|
84
|
+
* is emitted verbatim as `<key>: <value>` in three places — a field's argument
|
|
85
|
+
* names (renderField), a directive's argument names (renderDirective), and the
|
|
86
|
+
* keys of a nested input-object literal (valueToGraphQL). The last is
|
|
87
|
+
* attacker-reachable today: `sf_gql_list` / `sf_gql_aggregate` accept a `filter`
|
|
88
|
+
* / `orderBy` typed as `z.record(z.unknown())` (no charset on keys), the builder
|
|
89
|
+
* `JSON.stringify`s it into an arg value, and valueToGraphQL then emits each
|
|
90
|
+
* input field name / operator as a key. A key such as
|
|
91
|
+
* `Name: {eq:"x"} }) { edges { node { Id } } } evilAlias: accounts(where: { Industry`
|
|
92
|
+
* would otherwise render a fully parseable, schema-valid second connection —
|
|
93
|
+
* a silent selection-set injection. Keys are NOT validated at the builder or
|
|
94
|
+
* zod layer, so this render-layer assert is the only universal choke point
|
|
95
|
+
* (everything funnels through the renderer). Every legitimate key — operators
|
|
96
|
+
* (eq/ne/and/or/not/…), SObject field API names incl. `Custom__c`, connection
|
|
97
|
+
* args — is a valid GraphQL Name, so this never fires on real input.
|
|
98
|
+
*/
|
|
99
|
+
function assertArgumentKey(key: string, emitter: string): void {
|
|
100
|
+
assertGraphqlName(key, emitter, "argumentKey");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* W-23204027 render-layer fail-safe for a variable's TYPE reference. Unlike a
|
|
105
|
+
* Name position, a type carries GraphQL type-reference syntax (`!` for
|
|
106
|
+
* non-null, `[...]` for lists) and so cannot be a bare `assertGraphqlName` —
|
|
107
|
+
* that would wrongly reject legitimate types like `Int!` or `[ID!]!`. Instead
|
|
108
|
+
* we walk the type-reference grammar structurally (October 2021 §2.11): strip a
|
|
109
|
+
* trailing non-null `!`, unwrap a `[ ... ]` list wrapper (recursing on the inner
|
|
110
|
+
* type), and finally assert the innermost NamedType is a valid GraphQL Name.
|
|
111
|
+
*
|
|
112
|
+
* This is a defense-in-depth backstop, not a live-reachable sink today: every
|
|
113
|
+
* `addVariable` call site derives the type from schema inference,
|
|
114
|
+
* `createInputTypeName`, or a hardcoded scalar — none accept a raw type from the
|
|
115
|
+
* agent (the CLI `var`/`define` verbs set only the NAME, never the type). It
|
|
116
|
+
* guards the residual paths a Name backstop would otherwise miss: a
|
|
117
|
+
* deserialized/migrated session (`loadSession` does no type re-validation) or a
|
|
118
|
+
* future builder that stores a raw type. Without it, a type such as
|
|
119
|
+
* `Int) { evil { id } } query Decoy($z: Int` emitted verbatim as `$v: <type>`
|
|
120
|
+
* breaks out into a second operation with no default value needed.
|
|
121
|
+
*/
|
|
122
|
+
function assertGraphqlType(type: string, emitter: string): void {
|
|
123
|
+
let inner = type.trim();
|
|
124
|
+
// Peel any number of non-null / list wrappers from the outside in.
|
|
125
|
+
while (true) {
|
|
126
|
+
if (inner.endsWith("!")) {
|
|
127
|
+
inner = inner.slice(0, -1).trim();
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
131
|
+
inner = inner.slice(1, -1).trim();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
assertGraphqlName(inner, emitter, "variableType");
|
|
137
|
+
}
|
|
138
|
+
|
|
43
139
|
function renderChildren(session: QuerySession, parentId: string | null, depth: number): string {
|
|
44
140
|
const _indent = " ".repeat(depth);
|
|
45
141
|
const lines: string[] = [];
|
|
@@ -63,17 +159,31 @@ function renderField(
|
|
|
63
159
|
const indent = " ".repeat(depth);
|
|
64
160
|
let line = indent;
|
|
65
161
|
|
|
66
|
-
//
|
|
162
|
+
// W-23204027 render-layer fail-safe: assert every emitted GraphQL Name is
|
|
163
|
+
// valid, making "the renderer never emits an injectable identifier" a
|
|
164
|
+
// system-wide invariant layered UNDER the per-builder guards (W-22735537),
|
|
165
|
+
// not replacing them. Fires only on a programmer error — a future builder
|
|
166
|
+
// that calls selectLeaf/selectDottedFieldPath and forgets its assert — so
|
|
167
|
+
// failing loud is correct. The message matches USER_INPUT_RE, so the MCP
|
|
168
|
+
// path classifies it as UserInput rather than crashing. Argument *keys* are
|
|
169
|
+
// covered too: they are emitted verbatim as `<key>: <value>` here and in
|
|
170
|
+
// renderDirective/valueToGraphQL, so a malicious filter/orderBy key (a
|
|
171
|
+
// z.record(z.unknown()) with no charset at the schema boundary — see
|
|
172
|
+
// assertArgumentKey) can otherwise break out of the argument object into
|
|
173
|
+
// the selection set. That sink is NOT closed at the builder or zod layer.
|
|
67
174
|
if (node.alias) {
|
|
175
|
+
assertGraphqlName(node.alias, "renderField", "alias");
|
|
68
176
|
line += `${node.alias}: `;
|
|
69
177
|
}
|
|
70
178
|
|
|
179
|
+
assertGraphqlName(node.fieldName, "renderField", "fieldName");
|
|
71
180
|
line += node.fieldName;
|
|
72
181
|
|
|
73
182
|
// Arguments
|
|
74
183
|
const argEntries = Object.entries(getEffectiveArgs(session, node));
|
|
75
184
|
if (argEntries.length > 0) {
|
|
76
185
|
const argParts = argEntries.map(([name, value]) => {
|
|
186
|
+
assertArgumentKey(name, "renderField");
|
|
77
187
|
return `${name}: ${formatArgValue(value)}`;
|
|
78
188
|
});
|
|
79
189
|
line += `(${argParts.join(", ")})`;
|
|
@@ -102,6 +212,9 @@ function renderInlineFragment(
|
|
|
102
212
|
depth: number,
|
|
103
213
|
): string {
|
|
104
214
|
const indent = " ".repeat(depth);
|
|
215
|
+
// W-23204027 render-layer fail-safe (see renderField): a type condition is a
|
|
216
|
+
// GraphQL Name position emitted verbatim after `... on `.
|
|
217
|
+
assertGraphqlName(frag.onType, "renderInlineFragment", "onType");
|
|
105
218
|
let line = `${indent}... on ${frag.onType}`;
|
|
106
219
|
|
|
107
220
|
for (const dir of frag.directives) {
|
|
@@ -119,11 +232,19 @@ function renderInlineFragment(
|
|
|
119
232
|
}
|
|
120
233
|
|
|
121
234
|
function renderDirective(dir: DirectiveNode): string {
|
|
235
|
+
// W-23204027 render-layer fail-safe (see renderField): a directive name is a
|
|
236
|
+
// GraphQL Name position emitted verbatim after `@`. The only directive the
|
|
237
|
+
// declarative/MCP surface adds is `@optional` (a valid Name), so this never
|
|
238
|
+
// fires on legitimate output.
|
|
239
|
+
assertGraphqlName(dir.name, "renderDirective", "directiveName");
|
|
122
240
|
const argEntries = Object.entries(dir.args);
|
|
123
241
|
if (argEntries.length === 0) {
|
|
124
242
|
return `@${dir.name}`;
|
|
125
243
|
}
|
|
126
|
-
const argParts = argEntries.map(([name, value]) =>
|
|
244
|
+
const argParts = argEntries.map(([name, value]) => {
|
|
245
|
+
assertArgumentKey(name, "renderDirective");
|
|
246
|
+
return `${name}: ${formatArgValue(value)}`;
|
|
247
|
+
});
|
|
127
248
|
return `@${dir.name}(${argParts.join(", ")})`;
|
|
128
249
|
}
|
|
129
250
|
|
|
@@ -153,8 +274,21 @@ function formatArgValue(value: string): string {
|
|
|
153
274
|
return jsonToGraphQL(trimmed);
|
|
154
275
|
}
|
|
155
276
|
|
|
156
|
-
// Quoted string — pass through
|
|
157
|
-
|
|
277
|
+
// Quoted string — pass through ONLY when it is a single, well-formed string
|
|
278
|
+
// literal. W-23204027 (PR #694 review): a bare `startsWith('"') && endsWith('"')`
|
|
279
|
+
// check is an injection hole — a payload like
|
|
280
|
+
// `"a") { stolen } query Y($q: String = "b"` also starts and ends with a quote
|
|
281
|
+
// yet breaks out of the value into a second operation. JSON.parse yielding a
|
|
282
|
+
// string proves the whole token is ONE literal (interior quotes are escaped);
|
|
283
|
+
// anything else falls through to JSON.stringify, which re-encodes it as a safe
|
|
284
|
+
// single literal.
|
|
285
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
286
|
+
try {
|
|
287
|
+
if (typeof JSON.parse(trimmed) === "string") return trimmed;
|
|
288
|
+
} catch {
|
|
289
|
+
// Not a single well-formed literal — re-encode below.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
158
292
|
|
|
159
293
|
// Default: a string literal. JSON.stringify produces a spec-valid GraphQL string
|
|
160
294
|
// literal — it escapes line terminators and control chars (\n \r \t \b \f) and
|
|
@@ -169,13 +303,35 @@ function formatArgValue(value: string): string {
|
|
|
169
303
|
* GraphQL uses unquoted keys: { Status: { ne: "Closed" } }
|
|
170
304
|
*/
|
|
171
305
|
function jsonToGraphQL(jsonStr: string): string {
|
|
306
|
+
let parsed: unknown;
|
|
172
307
|
try {
|
|
173
|
-
|
|
174
|
-
return valueToGraphQL(parsed);
|
|
308
|
+
parsed = JSON.parse(jsonStr);
|
|
175
309
|
} catch {
|
|
176
|
-
//
|
|
177
|
-
|
|
310
|
+
// W-23204027 (PR #694 review, Round 3): REJECT — do NOT return the raw
|
|
311
|
+
// string. `formatArgValue` only routes here when the value starts with `{`
|
|
312
|
+
// or `[`, so a JSON.parse failure means it is a `{`/`[`-prefixed string
|
|
313
|
+
// that is NOT well-formed JSON — never legitimate GraphQL. Every real
|
|
314
|
+
// producer of a `{`/`[` value delivers valid JSON (the builders
|
|
315
|
+
// `JSON.stringify` filter/orderBy; the CLI `set`/`assign` path
|
|
316
|
+
// JSON-validates `{`/`[` literals in `validateLiteralAssignment` before
|
|
317
|
+
// storing), so this rejects zero legitimate flows. Returning it verbatim
|
|
318
|
+
// bypassed valueToGraphQL's `assertArgumentKey`, making it a live
|
|
319
|
+
// selection-set/operation injection sink: a variable default of
|
|
320
|
+
// `{ minRevenue: 0 }) { edges { node { id } } } } query Decoy($z: Filter`
|
|
321
|
+
// (unquoted key ⇒ invalid JSON ⇒ this catch; reachable via `sf_gql_raw`'s
|
|
322
|
+
// `var $x <path> '<default>'`) rendered a second, attacker-controlled
|
|
323
|
+
// operation. Throw a typed UserInputError (NOT a bare Error whose text
|
|
324
|
+
// would miss USER_INPUT_RE and misclassify as Internal) so `runTool`
|
|
325
|
+
// classifies it UserInput.
|
|
326
|
+
throw new UserInputError(
|
|
327
|
+
`jsonToGraphQL: value beginning with '{' or '[' is not valid JSON and cannot be rendered as a GraphQL literal: ${jsonStr.slice(0, 60)}`,
|
|
328
|
+
);
|
|
178
329
|
}
|
|
330
|
+
// valueToGraphQL runs OUTSIDE the try: it enforces the W-23204027 arg-key
|
|
331
|
+
// fail-safe by throwing on a malicious input-object key, and that assertion
|
|
332
|
+
// must propagate — not be swallowed and fall back to emitting the raw
|
|
333
|
+
// (injectable) string.
|
|
334
|
+
return valueToGraphQL(parsed);
|
|
179
335
|
}
|
|
180
336
|
|
|
181
337
|
function valueToGraphQL(value: unknown): string {
|
|
@@ -199,7 +355,16 @@ function valueToGraphQL(value: unknown): string {
|
|
|
199
355
|
}
|
|
200
356
|
if (typeof value === "object") {
|
|
201
357
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
202
|
-
const parts = entries.map(([k, v]) =>
|
|
358
|
+
const parts = entries.map(([k, v]) => {
|
|
359
|
+
// The live arg-key injection sink: filter/orderBy objects are
|
|
360
|
+
// JSON.stringify'd into an arg value by the builders, so every input
|
|
361
|
+
// field name / operator here is an attacker-controllable key emitted
|
|
362
|
+
// verbatim into a GraphQL input-object literal. Guard it (see
|
|
363
|
+
// assertArgumentKey). Array elements never reach this branch as keys —
|
|
364
|
+
// the Array case above emits `[...]` with no `<key>:`.
|
|
365
|
+
assertArgumentKey(k, "valueToGraphQL");
|
|
366
|
+
return `${k}: ${valueToGraphQL(v)}`;
|
|
367
|
+
});
|
|
203
368
|
return `{ ${parts.join(", ")} }`;
|
|
204
369
|
}
|
|
205
370
|
return String(value);
|
|
@@ -224,7 +224,8 @@ describe("mcp/tools error surface — category prefixes (contract)", () => {
|
|
|
224
224
|
|
|
225
225
|
it("Schema: an introspection/priming failure surfaces with the Schema prefix", async () => {
|
|
226
226
|
// No prior disk cache (fresh GRAPHITI_HOME) + a download that throws an
|
|
227
|
-
// untyped error → primeSchemaWithLock wraps it as
|
|
227
|
+
// untyped error WITH NO structured cause → primeSchemaWithLock wraps it as
|
|
228
|
+
// SchemaError and classifyCause(undefined) → "no", so no retry token.
|
|
228
229
|
const primeDeps: PrimeDeps = {
|
|
229
230
|
...makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
|
|
230
231
|
downloadSchema: async () => {
|
|
@@ -239,6 +240,54 @@ describe("mcp/tools error surface — category prefixes (contract)", () => {
|
|
|
239
240
|
});
|
|
240
241
|
expect(result.isError).toBe(true);
|
|
241
242
|
expect(errorText(result)).toMatch(/^Schema: /);
|
|
243
|
+
// An untyped cause-less failure is treated as permanent (no token).
|
|
244
|
+
expect(errorText(result)).not.toMatch(/\[retry=/);
|
|
245
|
+
} finally {
|
|
246
|
+
await client.close();
|
|
247
|
+
await server.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("Schema (retry=backoff): a transient network cause surfaces a backoff token end-to-end", async () => {
|
|
252
|
+
// W-23148365: a download failure whose cause carries a transient errno is
|
|
253
|
+
// wrapped as SchemaError({ retry: classifyCause(cause) }) → "backoff".
|
|
254
|
+
const primeDeps: PrimeDeps = {
|
|
255
|
+
...makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
|
|
256
|
+
downloadSchema: async () => {
|
|
257
|
+
throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
const { client, server } = await connectWith(primeDeps);
|
|
261
|
+
try {
|
|
262
|
+
const result = await client.callTool({
|
|
263
|
+
name: "sf_gql_list",
|
|
264
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"] },
|
|
265
|
+
});
|
|
266
|
+
expect(result.isError).toBe(true);
|
|
267
|
+
expect(errorText(result)).toMatch(/^Schema: /);
|
|
268
|
+
expect(errorText(result)).toMatch(/ \[retry=backoff\]$/);
|
|
269
|
+
} finally {
|
|
270
|
+
await client.close();
|
|
271
|
+
await server.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("Schema (permanent): a 4xx cause surfaces NO retry token end-to-end", async () => {
|
|
276
|
+
const primeDeps: PrimeDeps = {
|
|
277
|
+
...makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
|
|
278
|
+
downloadSchema: async () => {
|
|
279
|
+
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
const { client, server } = await connectWith(primeDeps);
|
|
283
|
+
try {
|
|
284
|
+
const result = await client.callTool({
|
|
285
|
+
name: "sf_gql_list",
|
|
286
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"] },
|
|
287
|
+
});
|
|
288
|
+
expect(result.isError).toBe(true);
|
|
289
|
+
expect(errorText(result)).toMatch(/^Schema: /);
|
|
290
|
+
expect(errorText(result)).not.toMatch(/\[retry=/);
|
|
242
291
|
} finally {
|
|
243
292
|
await client.close();
|
|
244
293
|
await server.close();
|
|
@@ -21,7 +21,7 @@ export function registerSfGqlConnectTool(
|
|
|
21
21
|
"sf_gql_connect",
|
|
22
22
|
{
|
|
23
23
|
description:
|
|
24
|
-
"Connect to a Salesforce org and prime its GraphQL schema cache. With forceRefresh, re-download the schema and coherently clear all caches so subsequent tools see freshly-deployed metadata. Concurrent refreshes coalesce into a single introspection. Returns { org, instanceUrl, refreshed, cached, durationMs, warnings? } — not the standard ToolOutput envelope. If a refresh fails but a usable cached schema survives, returns refreshed:false with a staleness warning instead of erroring. Error convention (all sf_gql_* tools): on failure the isError text is prefixed with a category — `UserInput:` (fix the request), `Auth:` (re-authenticate the org), `Schema:` (introspection/cache problem), or `Internal:` (unexpected) — so you can decide whether to fix inputs, re-auth, or retry.",
|
|
24
|
+
"Connect to a Salesforce org and prime its GraphQL schema cache. With forceRefresh, re-download the schema and coherently clear all caches so subsequent tools see freshly-deployed metadata. Concurrent refreshes coalesce into a single introspection. Returns { org, instanceUrl, refreshed, cached, durationMs, warnings? } — not the standard ToolOutput envelope. If a refresh fails but a usable cached schema survives, returns refreshed:false with a staleness warning instead of erroring. Error convention (all sf_gql_* tools): on failure the isError text is prefixed with a category — `UserInput:` (fix the request), `Auth:` (re-authenticate the org), `Schema:` (introspection/cache problem), or `Internal:` (unexpected) — so you can decide whether to fix inputs, re-auth, or retry. A `Schema:` error additionally ends with a retryability token: `[retry=now]` (retry immediately — e.g. a priming-lock timeout, where no live org round-trip occurred) or `[retry=backoff]` (the org was unreachable and an automatic retry already failed — wait with increasing backoff, e.g. 2^n seconds capped around 8s, and retry serially; do not fan out concurrent retries against an org that is already failing, and note some conditions such as an org API rate limit may take longer than a few seconds to clear); ABSENCE of any `[retry=...]` token means the failure is permanent (404, missing/malformed `__schema`, GraphQL errors in the body, or no cached schema) — do not retry, fix the request or re-prime via sf_gql_connect. This token convention applies to every sf_gql_* tool, not just this one. Example error text: `Schema: introspection request failed [retry=backoff]`; extract the disposition with the end-anchored regex ` /\\s\\[retry=(now|backoff)\\]$/` (no match ⇒ permanent).",
|
|
25
25
|
inputSchema,
|
|
26
26
|
},
|
|
27
27
|
async (args) => runTool(() => buildConnect(args, opts)),
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
-
import { AuthError, SchemaError, UserInputError } from "../../lib/errors.js";
|
|
9
|
+
import { AuthError, classifyCause, SchemaError, UserInputError } from "../../lib/errors.js";
|
|
10
10
|
import { SchemaRefreshError } from "../../lib/prime-schema.js";
|
|
11
11
|
import { MutationContextError } from "../../lib/walker.js";
|
|
12
12
|
import { classifyError, neutralizeControlChars, PATH_MARKERS, runTool } from "../tool-adapter.js";
|
|
@@ -49,6 +49,21 @@ describe("schemas/tool-adapter — classifyError", () => {
|
|
|
49
49
|
const r = classifyError(new SchemaError("buildList: is not a valid GraphQL Name"));
|
|
50
50
|
expect(r.category).toBe("Schema");
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
// W-23204027 (PR #694 review, Round 3): the render-layer jsonToGraphQL
|
|
54
|
+
// catch-sink fix throws a *typed* UserInputError. This pins WHY it must be
|
|
55
|
+
// typed and not a bare Error: its message ("… is not valid JSON and cannot
|
|
56
|
+
// be rendered as a GraphQL literal") is NOT anchored by USER_INPUT_RE, so a
|
|
57
|
+
// bare Error carrying it would fall through to Internal — misclassifying a
|
|
58
|
+
// user-input mistake and hiding an injection attempt in the operator log.
|
|
59
|
+
it("routes the jsonToGraphQL literal-rejection UserInputError to UserInput", () => {
|
|
60
|
+
const msg =
|
|
61
|
+
"jsonToGraphQL: value beginning with '{' or '[' is not valid JSON and cannot be rendered as a GraphQL literal: { minRevenue: 0 })";
|
|
62
|
+
expect(classifyError(new UserInputError(msg)).category).toBe("UserInput");
|
|
63
|
+
// The same message on a BARE Error is NOT anchored by USER_INPUT_RE —
|
|
64
|
+
// this is the exact trap the typed throw avoids.
|
|
65
|
+
expect(classifyError(new Error(msg)).category).toBe("Internal");
|
|
66
|
+
});
|
|
52
67
|
});
|
|
53
68
|
|
|
54
69
|
describe("heuristic routing (untyped fallbacks)", () => {
|
|
@@ -443,3 +458,149 @@ describe("schemas/tool-adapter — runTool", () => {
|
|
|
443
458
|
expect(spy).not.toHaveBeenCalled();
|
|
444
459
|
});
|
|
445
460
|
});
|
|
461
|
+
|
|
462
|
+
describe("schemas/tool-adapter — Schema retryability hint (W-23148365)", () => {
|
|
463
|
+
afterEach(() => {
|
|
464
|
+
vi.restoreAllMocks();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("classifyCause — the new cause-inspection logic", () => {
|
|
468
|
+
it("maps transient HTTP statuses to backoff", () => {
|
|
469
|
+
for (const statusCode of [420, 429, 500, 502, 503, 504]) {
|
|
470
|
+
expect(classifyCause({ statusCode })).toBe("backoff");
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("maps deterministic 4xx statuses to no", () => {
|
|
475
|
+
for (const statusCode of [400, 401, 403, 404, 409, 422]) {
|
|
476
|
+
expect(classifyCause({ statusCode })).toBe("no");
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("maps transient network errnos (org round-trip failures) to backoff", () => {
|
|
481
|
+
for (const code of ["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ECONNREFUSED", "EPIPE"]) {
|
|
482
|
+
expect(classifyCause({ code })).toBe("backoff");
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("maps permanent errnos (wrong host, no perms) to no", () => {
|
|
487
|
+
for (const code of ["ENOTFOUND", "EACCES", "EROFS", "ENOENT"]) {
|
|
488
|
+
expect(classifyCause({ code })).toBe("no");
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Review #2: local resource-exhaustion errnos come from the cache write
|
|
493
|
+
// (atomicWriteJson), which shares the download try — they are NOT org
|
|
494
|
+
// round-trips, so a `backoff` ("org unreachable, wait 1-2s") hint would be
|
|
495
|
+
// wrong and mask an ops problem. They must classify as `no`.
|
|
496
|
+
it("maps local resource-exhaustion errnos (disk full / fd exhaustion) to no", () => {
|
|
497
|
+
for (const code of ["ENOSPC", "EMFILE", "EAGAIN"]) {
|
|
498
|
+
expect(classifyCause({ code })).toBe("no");
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("parses ERROR_HTTP_<nnn> from errorCode/name and REQUEST_LIMIT_EXCEEDED", () => {
|
|
503
|
+
expect(classifyCause({ errorCode: "ERROR_HTTP_502" })).toBe("backoff");
|
|
504
|
+
expect(classifyCause({ name: "ERROR_HTTP_503" })).toBe("backoff");
|
|
505
|
+
expect(classifyCause({ errorCode: "ERROR_HTTP_404" })).toBe("no");
|
|
506
|
+
expect(classifyCause({ errorCode: "REQUEST_LIMIT_EXCEEDED" })).toBe("backoff");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("defaults to no for an absent/unrecognized cause (never invents retryability)", () => {
|
|
510
|
+
expect(classifyCause(undefined)).toBe("no");
|
|
511
|
+
expect(classifyCause(null)).toBe("no");
|
|
512
|
+
expect(classifyCause("a bare string")).toBe("no");
|
|
513
|
+
expect(classifyCause({ unrelated: true })).toBe("no");
|
|
514
|
+
expect(classifyCause({ statusCode: 418 })).toBe("no");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Defensive: a cause with a throwing accessor must not escape (classifyCause
|
|
518
|
+
// runs inside runTool's catch; an escaped throw would drop the sanitized
|
|
519
|
+
// envelope). It falls back to the conservative "no" default.
|
|
520
|
+
it("returns no for a cause whose property accessor throws (no escape)", () => {
|
|
521
|
+
const booby = {};
|
|
522
|
+
Object.defineProperty(booby, "statusCode", {
|
|
523
|
+
get() {
|
|
524
|
+
throw new Error("boom");
|
|
525
|
+
},
|
|
526
|
+
enumerable: true,
|
|
527
|
+
});
|
|
528
|
+
expect(() => classifyCause(booby)).not.toThrow();
|
|
529
|
+
expect(classifyCause(booby)).toBe("no");
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("classifyError().retry", () => {
|
|
534
|
+
it("reads the stamped retry off a typed SchemaError", () => {
|
|
535
|
+
expect(classifyError(new SchemaError("lock timeout", { retry: "now" })).retry).toBe("now");
|
|
536
|
+
expect(classifyError(new SchemaError("priming failed", { retry: "backoff" })).retry).toBe(
|
|
537
|
+
"backoff",
|
|
538
|
+
);
|
|
539
|
+
expect(classifyError(new SchemaError("no cache", { retry: "no" })).retry).toBe("no");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("falls back to the cause chain for an untyped throw routed to Schema", () => {
|
|
543
|
+
// Untyped Error whose message matches SCHEMA_RE, carrying a transient cause.
|
|
544
|
+
const e = new Error("No cached schema after socket hang up");
|
|
545
|
+
(e as { cause?: unknown }).cause = { statusCode: 503 };
|
|
546
|
+
const r = classifyError(e);
|
|
547
|
+
expect(r.category).toBe("Schema");
|
|
548
|
+
expect(r.retry).toBe("backoff");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("a bare SchemaError (no stamp, no cause) defaults to no — regression guard for existing cases", () => {
|
|
552
|
+
expect(classifyError(new SchemaError("No cached schema for org")).retry).toBe("no");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("a SchemaRefreshError carries its stamped disposition", () => {
|
|
556
|
+
const transient = new SchemaRefreshError("refresh failed; keeping cache", {
|
|
557
|
+
instanceUrl: "https://x.my.salesforce.com",
|
|
558
|
+
staleSince: "2026-06-29T00:00:00.000Z",
|
|
559
|
+
retry: "backoff",
|
|
560
|
+
});
|
|
561
|
+
expect(classifyError(transient).retry).toBe("backoff");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("non-Schema categories are uniformly no", () => {
|
|
565
|
+
expect(classifyError(new AuthError("Failed to get org info")).retry).toBe("no");
|
|
566
|
+
expect(classifyError(new UserInputError("bad input")).retry).toBe("no");
|
|
567
|
+
expect(classifyError(new Error("kaboom")).retry).toBe("no"); // Internal
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe("runTool token suffix", () => {
|
|
572
|
+
it("appends [retry=backoff] to a transient Schema error, after the Schema: prefix", async () => {
|
|
573
|
+
const result = await runTool(async () => {
|
|
574
|
+
throw new SchemaError("priming failed", { retry: "backoff" });
|
|
575
|
+
});
|
|
576
|
+
const text = result.content[0]?.text ?? "";
|
|
577
|
+
expect(text.startsWith("Schema: ")).toBe(true);
|
|
578
|
+
expect(text).toMatch(/ \[retry=backoff\]$/);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("appends [retry=now] to a retry-now Schema error", async () => {
|
|
582
|
+
const result = await runTool(async () => {
|
|
583
|
+
throw new SchemaError("lock timeout", { retry: "now" });
|
|
584
|
+
});
|
|
585
|
+
expect(result.content[0]?.text).toMatch(/ \[retry=now\]$/);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("appends NO token to a permanent Schema error (regression: bare 'No cached schema')", async () => {
|
|
589
|
+
const result = await runTool(async () => {
|
|
590
|
+
throw new SchemaError("No cached schema");
|
|
591
|
+
});
|
|
592
|
+
const text = result.content[0]?.text ?? "";
|
|
593
|
+
expect(text).toBe("Schema: No cached schema");
|
|
594
|
+
expect(text).not.toMatch(/\[retry=/);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("never appends a token to a non-Schema category", async () => {
|
|
598
|
+
const result = await runTool(async () => {
|
|
599
|
+
throw new AuthError("Failed to get org info");
|
|
600
|
+
});
|
|
601
|
+
const text = result.content[0]?.text ?? "";
|
|
602
|
+
expect(text.startsWith("Auth: ")).toBe(true);
|
|
603
|
+
expect(text).not.toMatch(/\[retry=/);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
});
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import os from "node:os";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
AuthError,
|
|
10
|
+
classifyCause,
|
|
11
|
+
type RetryHint,
|
|
12
|
+
SchemaError,
|
|
13
|
+
UserInputError,
|
|
14
|
+
} from "../lib/errors.js";
|
|
9
15
|
import { graphitiHome, schemaDir } from "../lib/introspect.js";
|
|
10
16
|
import { SchemaRefreshError } from "../lib/prime-schema.js";
|
|
11
17
|
import { MutationContextError } from "../lib/walker.js";
|
|
@@ -27,6 +33,17 @@ import { MutationContextError } from "../lib/walker.js";
|
|
|
27
33
|
* untyped throws. The sanitized message is returned for EVERY category — not
|
|
28
34
|
* just Internal — because typed Auth/Schema errors embed cache/lock paths and
|
|
29
35
|
* wrapped jsforce/@salesforce/core causes embed `~/.sfdx/...` paths.
|
|
36
|
+
*
|
|
37
|
+
* Retryability hint (W-23148365): a `Schema:` error — and ONLY a `Schema:` error —
|
|
38
|
+
* additionally ends with a closed-set token telling the host whether to retry:
|
|
39
|
+
* ` [retry=now]` (retry immediately; no live org round-trip occurred — a priming-
|
|
40
|
+
* lock timeout, or a refresh where a usable cached schema survives), ` [retry=backoff]`
|
|
41
|
+
* (the introspection request failed transiently AND already exhausted the
|
|
42
|
+
* connection layer's one built-in retry — wait briefly), or NO token (permanent:
|
|
43
|
+
* 4xx, malformed/absent `__schema`, GraphQL errors in the body, no cached schema —
|
|
44
|
+
* don't retry; fix the request, re-auth, or re-prime). The `<Category>: ` prefix is
|
|
45
|
+
* unchanged. The disposition comes from the typed error's stamped `retry` field,
|
|
46
|
+
* with a defensive `cause`-chain fallback (`classifyCause`) for untyped throws.
|
|
30
47
|
*/
|
|
31
48
|
|
|
32
49
|
export type ErrorCategory = "UserInput" | "Auth" | "Schema" | "Internal";
|
|
@@ -178,19 +195,42 @@ function categoryOf(e: unknown, message: string): ErrorCategory {
|
|
|
178
195
|
return "Internal";
|
|
179
196
|
}
|
|
180
197
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Retryability disposition for a Schema-category error (W-23148365). The typed
|
|
200
|
+
* SchemaError/SchemaRefreshError carry an authoritative `retry` stamped at the
|
|
201
|
+
* throw site; for an untyped throw that reached the Schema bucket via the
|
|
202
|
+
* SCHEMA_RE heuristic we fall back to inspecting its `cause`. Only ever called
|
|
203
|
+
* for the Schema category — Auth/UserInput/Internal are uniformly `"no"`.
|
|
204
|
+
*/
|
|
205
|
+
function retryHintFor(e: unknown): RetryHint {
|
|
206
|
+
if (e instanceof SchemaError || e instanceof SchemaRefreshError) return e.retry;
|
|
207
|
+
if (typeof e === "object" && e !== null && "cause" in e) {
|
|
208
|
+
return classifyCause((e as { cause?: unknown }).cause);
|
|
209
|
+
}
|
|
210
|
+
return "no";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Classify a thrown error into a category, a Schema-only retry hint, and a
|
|
215
|
+
* sanitized message text. `retry` is `"no"` for every non-Schema category.
|
|
216
|
+
*/
|
|
217
|
+
export function classifyError(e: unknown): {
|
|
218
|
+
category: ErrorCategory;
|
|
219
|
+
retry: RetryHint;
|
|
220
|
+
text: string;
|
|
221
|
+
} {
|
|
183
222
|
const message = e instanceof Error ? e.message : String(e);
|
|
184
223
|
const category = categoryOf(e, message);
|
|
224
|
+
const retry = category === "Schema" ? retryHintFor(e) : "no";
|
|
185
225
|
const safeMessage = sanitizePaths(message);
|
|
186
|
-
if (category !== "Internal") return { category, text: safeMessage };
|
|
226
|
+
if (category !== "Internal") return { category, retry, text: safeMessage };
|
|
187
227
|
|
|
188
228
|
// Internal: unexpected. Attach a truncated, path-stripped stack for the host.
|
|
189
229
|
const frames = truncatedStack(e);
|
|
190
230
|
const text = frames.length
|
|
191
231
|
? `${safeMessage}\n${frames.map((f) => ` ${f}`).join("\n")}`
|
|
192
232
|
: safeMessage;
|
|
193
|
-
return { category, text };
|
|
233
|
+
return { category, retry, text };
|
|
194
234
|
}
|
|
195
235
|
|
|
196
236
|
/**
|
|
@@ -206,13 +246,17 @@ export async function runTool(fn: () => Promise<unknown>): Promise<ToolTextResul
|
|
|
206
246
|
// carry them and JSON.stringify won't escape them (MCP TS SDK #2155).
|
|
207
247
|
return { content: [{ type: "text", text: stripLineSeparators(JSON.stringify(output)) }] };
|
|
208
248
|
} catch (e) {
|
|
209
|
-
const { category, text } = classifyError(e);
|
|
249
|
+
const { category, retry, text } = classifyError(e);
|
|
210
250
|
if (category === "Internal") {
|
|
211
251
|
console.error("[graphiti-mcp] Internal tool error:", e);
|
|
212
252
|
}
|
|
253
|
+
// Append the retry hint as an end-anchored token (W-23148365). Only a
|
|
254
|
+
// Schema error ever carries a non-"no" hint; the `<Category>: ` prefix is
|
|
255
|
+
// unchanged so existing host parses (startsWith / split) still work.
|
|
256
|
+
const suffix = retry === "no" ? "" : ` [retry=${retry}]`;
|
|
213
257
|
return {
|
|
214
258
|
isError: true,
|
|
215
|
-
content: [{ type: "text", text: stripLineSeparators(`${category}: ${text}`) }],
|
|
259
|
+
content: [{ type: "text", text: stripLineSeparators(`${category}: ${text}${suffix}`) }],
|
|
216
260
|
};
|
|
217
261
|
}
|
|
218
262
|
}
|