in2-agent-server 0.20260821.185429
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/README.md +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- package/viewer/server.mjs +433 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { fail, internalError, ok, stdoutTail } from "../core/result.js";
|
|
5
|
+
import { WorkspaceError, sessionDir } from "../core/workspace.js";
|
|
6
|
+
import { loadSession } from "./session.js";
|
|
7
|
+
import { SessionOrgError, resolveSessionOrg } from "../core/sessionOrg.js";
|
|
8
|
+
import { GateError, assertTargetOrg } from "../core/gate.js";
|
|
9
|
+
import { runProcess, sfCliEnv } from "../core/spawn.js";
|
|
10
|
+
import { sfQueryTruncation } from "../core/sfQuery.js";
|
|
11
|
+
import { appendReadRecord, classifySfRead } from "../core/readsLog.js";
|
|
12
|
+
// Turn an sf CLI failure into something a supervisor can act on.
|
|
13
|
+
//
|
|
14
|
+
// `sf --json` prints a full error object on failure — name, message, exitCode, a stack, and a cause
|
|
15
|
+
// carrying a SECOND stack. Echoing that raw put ~40 lines of jsforce internals into the transcript
|
|
16
|
+
// for what was usually a one-line fact, and buried the fact.
|
|
17
|
+
//
|
|
18
|
+
// The case that matters most is NOT_FOUND on an sobject describe, which in this workflow is
|
|
19
|
+
// ORDINARY rather than broken: the batch's whole purpose is to create objects that do not exist
|
|
20
|
+
// yet, so describing one before it is applied is the expected answer, not a malfunction. Rendered
|
|
21
|
+
// as a raw stack it reads like the toolchain is failing, and invites the supervisor to go
|
|
22
|
+
// debugging instead of getting on with creating the thing.
|
|
23
|
+
//
|
|
24
|
+
// Still fail-closed: this is reported as a failure, never as a clean empty read. It says what
|
|
25
|
+
// happened, not that nothing is wrong.
|
|
26
|
+
export function sfReadFailure(matched, args, exitCode, body, stderr) {
|
|
27
|
+
const command = matched.join(" ");
|
|
28
|
+
let code;
|
|
29
|
+
let message;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(body);
|
|
32
|
+
code = parsed.errorCode ?? parsed.data?.errorCode ?? parsed.name;
|
|
33
|
+
message = parsed.message;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Not JSON — fall through to the tail, which is still better than the whole buffer.
|
|
37
|
+
}
|
|
38
|
+
if (!code) {
|
|
39
|
+
return fail("SF_READ_FAILED", `sf ${command} exited ${exitCode}.\n${stdoutTail(body || stderr, 15)}`);
|
|
40
|
+
}
|
|
41
|
+
// Read the subject from ARGS, not from `matched` — `matched` is only the allowlist entry
|
|
42
|
+
// ("sobject describe"), so the flag is never in it. And guard the index: indexOf returning -1
|
|
43
|
+
// would make `[-1 + 1]` read element 0 and confidently report that "sobject" does not exist.
|
|
44
|
+
const flag = [args.indexOf("-s"), args.indexOf("--sobject")].find((i) => i >= 0);
|
|
45
|
+
const subject = flag === undefined ? undefined : args[flag + 1];
|
|
46
|
+
if (code === "NOT_FOUND" && matched.includes("describe")) {
|
|
47
|
+
return fail("SF_NOT_FOUND", [
|
|
48
|
+
`${subject ?? "That component"} does not exist in this org.`,
|
|
49
|
+
"If the batch is supposed to create it, this is the expected answer before it is applied —",
|
|
50
|
+
"carry on and create it. If it was supposed to already exist, the API name in the requirement",
|
|
51
|
+
"is wrong; project the org to see what is actually there."
|
|
52
|
+
].join("\n"));
|
|
53
|
+
}
|
|
54
|
+
// INVALID_TYPE is "that API cannot see this type", NOT "this type does not exist" — and the two
|
|
55
|
+
// have different remedies. Live-caught (EV-31…40): `data query` on CustomPermission failed here,
|
|
56
|
+
// the run read the refusal as absence, and `org list metadata -m CustomPermission` returned the
|
|
57
|
+
// very components it had just concluded were missing. The crib belongs in the message because
|
|
58
|
+
// this is the one moment the caller is certain to be reading it.
|
|
59
|
+
if (code === "INVALID_TYPE") {
|
|
60
|
+
const named = /sObject type '([^']+)' is not supported/.exec(message ?? "")?.[1];
|
|
61
|
+
const subject = named ?? "That type";
|
|
62
|
+
return fail("SF_READ_FAILED", [
|
|
63
|
+
`${subject} is not queryable through that API. INVALID_TYPE means the API cannot SEE it — not that it does not exist.`,
|
|
64
|
+
`Retry with --use-tooling-api; if that also refuses, enumerate it as metadata instead — ["org","list","metadata","-m","${named ?? "<Type>"}"] — which is the path that works for CustomPermission and other types no query API exposes.`
|
|
65
|
+
].join("\n"));
|
|
66
|
+
}
|
|
67
|
+
return fail("SF_READ_FAILED", `sf ${command} failed: ${code}${message ? ` — ${message}` : ""}`);
|
|
68
|
+
}
|
|
69
|
+
// Read-only advisory probes. Fail-closed posture throughout: an org-read error is
|
|
70
|
+
// reported as an error, never as "does not exist".
|
|
71
|
+
const OBJECT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,79}(__c|__mdt|__e)?$/;
|
|
72
|
+
// Read-only sf CLI passthrough. list_components covers a FIXED set of types,
|
|
73
|
+
// so anything outside it dead-ends (live-caught: a run needed a type the tool did not cover and
|
|
74
|
+
// had nowhere to go). SOQL + describe are open-world, so this closes the gap dynamically instead
|
|
75
|
+
// of growing another hand-maintained table.
|
|
76
|
+
//
|
|
77
|
+
// NOT the gate — this list only supplies a friendly label for messages. The gate is sfReadRefusal,
|
|
78
|
+
// which works on canonical segments and refuses by verb. There is no shell (execFile with an argv
|
|
79
|
+
// array), so redirection and command chaining remain impossible either way.
|
|
80
|
+
export const SF_READ_ONLY_COMMANDS = [
|
|
81
|
+
["data", "query"],
|
|
82
|
+
["data", "get", "record"],
|
|
83
|
+
["sobject", "describe"],
|
|
84
|
+
["sobject", "list"],
|
|
85
|
+
["org", "display"],
|
|
86
|
+
["org", "list"],
|
|
87
|
+
["limits", "display"]
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* DENY-list of everything that can change an org, a file tree, or an auth store. Anything not
|
|
91
|
+
* matched here is allowed through unchanged.
|
|
92
|
+
*
|
|
93
|
+
* This was an ALLOW-list of seven command prefixes, and it was the wrong shape. The Salesforce CLI
|
|
94
|
+
* has a large read surface — `data export`, `data search`, `org list metadata`, `apex list log`,
|
|
95
|
+
* `apex get log`, `package version list`, `force:schema` and more — and every one of them refused
|
|
96
|
+
* with "not on the allowlist" even though none can write. The tool therefore fought its user on
|
|
97
|
+
* legitimate questions while providing no real safety, because a model with Bash can call the same
|
|
98
|
+
* CLI directly; the allowlist only ever constrained the honest path. Measured cost across one week:
|
|
99
|
+
* 217 calls, 56 failures.
|
|
100
|
+
*
|
|
101
|
+
* Inverting it keeps the one property worth keeping — no mutation reaches an org except through
|
|
102
|
+
* direct_apply and its evidence loop — and stops pretending a curated list of seven commands is the
|
|
103
|
+
* whole of "reading Salesforce".
|
|
104
|
+
*
|
|
105
|
+
* Named families only. This list is the FIRST of three layers and the weakest — it says nothing
|
|
106
|
+
* about a command it has never heard of, which is how `sf deploy metadata` (a live alias for
|
|
107
|
+
* `project deploy start`), the colon spelling of every entry below, and 146 unenumerated commands
|
|
108
|
+
* all ran unchallenged until 2026-07-31. The verb rules in sfReadRefusal are what actually hold.
|
|
109
|
+
*
|
|
110
|
+
* Kept anyway: an explicit family gives a precise refusal message, and it names the specific
|
|
111
|
+
* commands this loop must never take even if someone later widens SF_READ_VERBS.
|
|
112
|
+
*/
|
|
113
|
+
export const SF_WRITE_COMMANDS = [
|
|
114
|
+
["project", "deploy"],
|
|
115
|
+
["project", "retrieve"],
|
|
116
|
+
["project", "delete"],
|
|
117
|
+
["project", "convert"],
|
|
118
|
+
["project", "generate"],
|
|
119
|
+
["data", "create"],
|
|
120
|
+
["data", "update"],
|
|
121
|
+
["data", "delete"],
|
|
122
|
+
["data", "upsert"],
|
|
123
|
+
["data", "import"],
|
|
124
|
+
["data", "resume"],
|
|
125
|
+
["apex", "run"],
|
|
126
|
+
["apex", "test"],
|
|
127
|
+
["org", "create"],
|
|
128
|
+
["org", "delete"],
|
|
129
|
+
["org", "login"],
|
|
130
|
+
["org", "logout"],
|
|
131
|
+
["org", "resume"],
|
|
132
|
+
["org", "enable"],
|
|
133
|
+
["org", "disable"],
|
|
134
|
+
["org", "assign"],
|
|
135
|
+
["package", "create"],
|
|
136
|
+
["package", "install"],
|
|
137
|
+
["package", "uninstall"],
|
|
138
|
+
// NOT ["package","version"] wholesale — `package version list` and `package version report` are
|
|
139
|
+
// reads. Only the mutating verbs.
|
|
140
|
+
["package", "version", "create"],
|
|
141
|
+
["package", "version", "delete"],
|
|
142
|
+
["package", "version", "promote"],
|
|
143
|
+
["package", "version", "update"],
|
|
144
|
+
["alias", "set"],
|
|
145
|
+
["alias", "unset"],
|
|
146
|
+
["config", "set"],
|
|
147
|
+
["config", "unset"],
|
|
148
|
+
["schema", "generate"],
|
|
149
|
+
["sobject", "create"],
|
|
150
|
+
["force", "source"],
|
|
151
|
+
["force", "data"],
|
|
152
|
+
["force", "apex"],
|
|
153
|
+
["force", "org"],
|
|
154
|
+
["force", "mdapi"],
|
|
155
|
+
["force", "package"],
|
|
156
|
+
["agent", "create"],
|
|
157
|
+
["lightning", "generate"],
|
|
158
|
+
["static-resource", "generate"],
|
|
159
|
+
["visualforce", "generate"],
|
|
160
|
+
["community", "create"],
|
|
161
|
+
["analytics", "generate"]
|
|
162
|
+
];
|
|
163
|
+
/**
|
|
164
|
+
* The command as the CLI will actually resolve it: lowercased segments, before any flag.
|
|
165
|
+
*
|
|
166
|
+
* oclif accepts `sf project deploy start`, `sf project:deploy:start` and `sf project deploy:start`
|
|
167
|
+
* interchangeably. A gate that compares raw argv segments sees three different commands and blocks
|
|
168
|
+
* one of them, which is exactly what happened here — `matchedWriteCommand` matched literal argv
|
|
169
|
+
* from index 0, so `["project:deploy:start", "-x", "package.xml"]` walked straight through and
|
|
170
|
+
* deployed metadata. Normalising first is what makes any of the checks below meaningful.
|
|
171
|
+
*/
|
|
172
|
+
export function canonicalSegments(args) {
|
|
173
|
+
const leading = [];
|
|
174
|
+
for (const arg of args) {
|
|
175
|
+
if (arg.startsWith("-"))
|
|
176
|
+
break;
|
|
177
|
+
leading.push(arg);
|
|
178
|
+
}
|
|
179
|
+
return leading
|
|
180
|
+
.join(":")
|
|
181
|
+
.split(":")
|
|
182
|
+
.map((segment) => segment.trim().toLowerCase())
|
|
183
|
+
.filter((segment) => segment.length > 0);
|
|
184
|
+
}
|
|
185
|
+
// Verbs that CHANGE something — an org, an auth store, a local tree, a package, a subscriber.
|
|
186
|
+
// Checked against every segment, so a write verb anywhere in the path is refused whatever the topic
|
|
187
|
+
// and whatever position it sits in (`org create user`, `force source deploy`, `package1 version
|
|
188
|
+
// create`). Topic-prefix matching cannot do this: it has to know the topic in advance.
|
|
189
|
+
const SF_WRITE_VERBS = new Set([
|
|
190
|
+
"create", "update", "delete", "upsert", "import", "install", "uninstall", "deploy", "retrieve",
|
|
191
|
+
"convert", "generate", "set", "unset", "run", "execute", "publish", "activate", "deactivate",
|
|
192
|
+
"enable", "disable", "assign", "promote", "reset", "refresh", "login", "logout",
|
|
193
|
+
"store", "grant", "revoke", "cancel", "schedule", "push-upgrade", "start", "restart", "stop",
|
|
194
|
+
"add", "remove", "rename", "move", "copy", "write", "apply", "migrate", "seed", "init"
|
|
195
|
+
// NOT "resume": `data query resume` collects a bulk query's results and only reads. The write
|
|
196
|
+
// resumes (`data import resume`, `data upsert resume`) already carry a write verb of their own,
|
|
197
|
+
// and `org resume sandbox` has no read verb, so it is refused by default anyway.
|
|
198
|
+
]);
|
|
199
|
+
// Verbs that only READ. A command must contain one of these to run at all, so anything Salesforce
|
|
200
|
+
// ships in the future is refused until someone looks at it — the property the old comment claimed
|
|
201
|
+
// ("an unknown FUTURE subcommand of a dangerous family is blocked") and the old code did not have.
|
|
202
|
+
//
|
|
203
|
+
// The verb is not always last (`data get record`, `org list metadata`) and not always second
|
|
204
|
+
// (`package version list`), which is why this matches ANY segment rather than a fixed position.
|
|
205
|
+
const SF_READ_VERBS = new Set([
|
|
206
|
+
"query", "get", "list", "describe", "display", "report", "search", "export", "preview", "diff",
|
|
207
|
+
"check", "validate", "status", "history", "view", "read", "print", "show", "info", "log", "logs"
|
|
208
|
+
]);
|
|
209
|
+
/**
|
|
210
|
+
* Commands that hand back a CREDENTIAL rather than data.
|
|
211
|
+
*
|
|
212
|
+
* `org auth show-sfdx-auth-url` returns a `force://` URL containing the REFRESH TOKEN — durable,
|
|
213
|
+
* full-privilege access to the org from any machine, long after this session ends. It reads rather
|
|
214
|
+
* than writes, so no verb rule can catch it; it has to be named. `org open` is the same shape (a
|
|
215
|
+
* frontdoor URL carrying a live session), and `org display --verbose` prints the same auth URL as
|
|
216
|
+
* `show-sfdx-auth-url`.
|
|
217
|
+
*
|
|
218
|
+
* `org display` WITHOUT --verbose still returns an access token and is still allowed, because it is
|
|
219
|
+
* how the loop resolves instance hosts and it was reachable under the old allowlist too. That is a
|
|
220
|
+
* deliberate line, not an oversight: a short-lived session token the harness already holds, versus
|
|
221
|
+
* a refresh token it does not.
|
|
222
|
+
*/
|
|
223
|
+
function credentialExposure(segments, args) {
|
|
224
|
+
if (segments.includes("auth"))
|
|
225
|
+
return segments.join(" ");
|
|
226
|
+
if (segments[0] === "org" && segments[1] === "open")
|
|
227
|
+
return "org open";
|
|
228
|
+
if (segments[0] === "org" && segments[1] === "display" && args.includes("--verbose")) {
|
|
229
|
+
return "org display --verbose";
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
/** The write family this command belongs to, or null when no explicit family matches. */
|
|
234
|
+
export function matchedWriteCommand(args) {
|
|
235
|
+
const segments = canonicalSegments(args);
|
|
236
|
+
return SF_WRITE_COMMANDS.find((cmd) => cmd.every((seg, i) => segments[i] === seg)) ?? null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Why this command may not run, or null to allow it.
|
|
240
|
+
*
|
|
241
|
+
* Three layers, cheapest first. The explicit family list is kept because it names the specific
|
|
242
|
+
* commands this loop must never take, but it is no longer load-bearing on its own: the verb rules
|
|
243
|
+
* below are what make an UNLISTED command safe by default. Before this, `sf deploy metadata` (a
|
|
244
|
+
* live alias for `project deploy start`) and `sf api request graphql --body mutation.txt` were both
|
|
245
|
+
* permitted, and so was every one of the 146 commands nobody had thought to enumerate.
|
|
246
|
+
*/
|
|
247
|
+
export function sfReadRefusal(args) {
|
|
248
|
+
const segments = canonicalSegments(args);
|
|
249
|
+
if (segments.length === 0)
|
|
250
|
+
return "sf_read needs a command.";
|
|
251
|
+
const credential = credentialExposure(segments, args);
|
|
252
|
+
if (credential) {
|
|
253
|
+
return `sf_read will not run "${credential}" — it returns a credential rather than data. If an org needs re-authenticating, the operator does that through org_login.`;
|
|
254
|
+
}
|
|
255
|
+
const family = SF_WRITE_COMMANDS.find((cmd) => cmd.every((seg, i) => segments[i] === seg));
|
|
256
|
+
const writeVerb = segments.find((segment) => SF_WRITE_VERBS.has(segment));
|
|
257
|
+
if (family || writeVerb) {
|
|
258
|
+
const named = family ? family.join(" ") : `${segments.join(" ")}`;
|
|
259
|
+
return `sf_read is read-only and "${named}" mutates. Org changes flow through direct_apply, which projects before and after, diffs three ways and leaves an evidence bundle — none of which happens if a write goes around it.`;
|
|
260
|
+
}
|
|
261
|
+
if (!segments.some((segment) => SF_READ_VERBS.has(segment))) {
|
|
262
|
+
return `sf_read does not recognise "${segments.join(" ")}" as a read. Commands are allowed by their verb (query, list, describe, display, get, report, search…), so anything new is refused until it is reviewed rather than assumed safe. If this genuinely only reads, add its verb to SF_READ_VERBS.`;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
export const SF_TARGET_FLAGS = new Set(["-o", "--target-org", "-u", "--targetusername"]);
|
|
267
|
+
const SF_OUTPUT_CAP = 24_000;
|
|
268
|
+
export function matchedReadCommand(args) {
|
|
269
|
+
return SF_READ_ONLY_COMMANDS.find((cmd) => cmd.every((seg, i) => args[i] === seg)) ?? null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the org a READ should hit, allowing the symbolic role "campfire".
|
|
273
|
+
*
|
|
274
|
+
* A session is bound to TWO orgs — the target org it deploys to, and the Campfire consultant org
|
|
275
|
+
* it reads requirements from and writes Configuration Items back to. Every read tool defaulted to
|
|
276
|
+
* the target and could otherwise only take a literal alias, so verifying a writeback meant the
|
|
277
|
+
* supervisor GUESSING the consultant org's alias from nothing.
|
|
278
|
+
*
|
|
279
|
+
* Live (CF77): the first verification query ran against the deploy target and failed
|
|
280
|
+
* `INVALID_TYPE: Campfire_Configuration_Item__c is not supported` — correct, that object lives in
|
|
281
|
+
* the other org. The next attempt hard-coded an alias remembered from an unrelated context. The
|
|
282
|
+
* session knew the answer the whole time; the campfire write tools resolve it by role on every
|
|
283
|
+
* call. This just exposes the same lookup to reads.
|
|
284
|
+
*
|
|
285
|
+
* Returns the ORG ID as well as the alias. Read provenance joins on the id (R9): an alias is
|
|
286
|
+
* operator-controlled and can be repointed, so keying a contamination report on one would let a
|
|
287
|
+
* repoint silently merge two orgs' read-sets. A literal `targetOrg` override names an org this
|
|
288
|
+
* session was never bound to, so there is no recorded id to report — null, honestly, rather than an
|
|
289
|
+
* `sf org display` round-trip on every read.
|
|
290
|
+
*/
|
|
291
|
+
async function resolveReadOrg(sessionId, override) {
|
|
292
|
+
const requested = override?.trim();
|
|
293
|
+
if (requested && requested !== "campfire" && requested !== "target")
|
|
294
|
+
return { alias: requested, orgId: null };
|
|
295
|
+
const role = requested === "campfire" ? "campfire" : "target";
|
|
296
|
+
// verify:false — reading the wrong org is visible and harmless, and the round-trip is not free.
|
|
297
|
+
const binding = await resolveSessionOrg(await loadSession(sessionId), role, { verify: false });
|
|
298
|
+
return { alias: binding.alias, orgId: binding.orgId ?? null };
|
|
299
|
+
}
|
|
300
|
+
/** Drop the `attributes` envelope Salesforce puts on every SOQL record. */
|
|
301
|
+
function stripAttributes(value) {
|
|
302
|
+
if (Array.isArray(value))
|
|
303
|
+
return value.map(stripAttributes);
|
|
304
|
+
if (!value || typeof value !== "object")
|
|
305
|
+
return value;
|
|
306
|
+
const out = {};
|
|
307
|
+
for (const [k, v] of Object.entries(value)) {
|
|
308
|
+
if (k === "attributes")
|
|
309
|
+
continue;
|
|
310
|
+
out[k] = stripAttributes(v);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
export function registerOrgTools(server) {
|
|
315
|
+
server.registerTool("sf_read", {
|
|
316
|
+
description: "READ-ONLY sf CLI passthrough — works like the CLI. Pass any `sf` arguments WITHOUT the leading `sf` and without a target flag (the org is injected); --json is added unless you pass it. " +
|
|
317
|
+
"Commands are allowed BY VERB, so any non-mutating command runs (`data query` — add --use-tooling-api for Tooling objects — `sobject describe`, `org list metadata`, `apex get log`, and anything else that only reads); a mutating or unrecognised one is refused with the reason and the alternative. " +
|
|
318
|
+
"OVERSIZE OUTPUT IS NEVER DISCARDED: past the inline cap the full result is written to <session>/sf-read/ and the response carries the path, the row count and the first 2,000 chars, so a big describe becomes a file to grep rather than a refusal.",
|
|
319
|
+
inputSchema: {
|
|
320
|
+
sessionId: z.string().describe("The session whose org to read. Supplies the org so the alias never has to be restated."),
|
|
321
|
+
targetOrg: z
|
|
322
|
+
.string()
|
|
323
|
+
.optional()
|
|
324
|
+
.describe("Omit for the session's deploy target, which is the normal case. Pass \"campfire\" to read the session's Campfire CONSULTANT org — where requirements, Configuration Items and Deployment Tasks live — without knowing its alias; that is how you verify a writeback landed. Any other value is a literal alias, the open-world escape hatch for an org this session is not bound to."),
|
|
325
|
+
args: z
|
|
326
|
+
.array(z.string())
|
|
327
|
+
.min(1)
|
|
328
|
+
.describe('sf arguments without the leading "sf", e.g. ["data","query","-q","SELECT Id FROM Account LIMIT 1"] or ["sobject","describe","-s","CF71_Intake_Request__c"].'),
|
|
329
|
+
timeoutMs: z.number().optional().describe("Default 120000.")
|
|
330
|
+
}
|
|
331
|
+
}, async ({ sessionId, targetOrg: targetOrgOverride, args, timeoutMs }) => {
|
|
332
|
+
try {
|
|
333
|
+
// Session-derived by default; an explicit targetOrg is an intentional override for reading
|
|
334
|
+
// an org this session is not bound to. Reads skip the alias re-check (verify:false) —
|
|
335
|
+
// reading the wrong org is visible and harmless, and the round-trip is not free.
|
|
336
|
+
const org = await resolveReadOrg(sessionId, targetOrgOverride);
|
|
337
|
+
const targetOrg = org.alias;
|
|
338
|
+
assertTargetOrg(targetOrg);
|
|
339
|
+
// Gated on the CLI's own canonical form (colon and space spellings collapse to one), by
|
|
340
|
+
// VERB rather than by topic prefix, so an unlisted command is refused rather than assumed
|
|
341
|
+
// safe. See sfReadRefusal.
|
|
342
|
+
const refusal = sfReadRefusal(args);
|
|
343
|
+
if (refusal)
|
|
344
|
+
return fail("SF_COMMAND_NOT_ALLOWED", refusal);
|
|
345
|
+
// Label for messages only. An unrecognised-but-safe command is fine and simply names itself.
|
|
346
|
+
const matched = matchedReadCommand(args) ?? args.slice(0, 2);
|
|
347
|
+
const conflicting = args.find((a) => SF_TARGET_FLAGS.has(a));
|
|
348
|
+
if (conflicting) {
|
|
349
|
+
return fail("SF_TARGET_FLAG_CONFLICT", `Remove ${conflicting} from args — the target org is injected from the targetOrg parameter.`);
|
|
350
|
+
}
|
|
351
|
+
const full = [...args, "--target-org", targetOrg, ...(args.includes("--json") ? [] : ["--json"])];
|
|
352
|
+
const run = await runProcess("sf", full, { env: sfCliEnv(), timeoutMs: timeoutMs ?? 120_000 });
|
|
353
|
+
const raw = run.stdout.trim();
|
|
354
|
+
// READ PROVENANCE (R9, R11-R12). Recorded HERE — after the org answered, before any branch
|
|
355
|
+
// returns — so every path that reached the org leaves exactly one record and no path that
|
|
356
|
+
// did not (a gate refusal, a target-flag conflict) leaves any.
|
|
357
|
+
//
|
|
358
|
+
// sf_read is the open-world door, so it is the one probe that cannot know in advance what it
|
|
359
|
+
// is looking at. classifySfRead derives keys only from metadata-shaped reads and marks
|
|
360
|
+
// everything else OPAQUE rather than silently clean; the RAW QUERY never enters the record,
|
|
361
|
+
// only the object it named, because the reads segment ships in a provenance parcel while
|
|
362
|
+
// <session>/sf-read/ stays on this machine.
|
|
363
|
+
//
|
|
364
|
+
// Parsed ONCE, here, and reused by both branches below. A 5 MB describe is exactly the case
|
|
365
|
+
// this tool exists to handle well, and parsing it three times to answer three questions
|
|
366
|
+
// about the same bytes is the kind of cost that only shows up on the reads that hurt.
|
|
367
|
+
let parsedRaw;
|
|
368
|
+
try {
|
|
369
|
+
parsedRaw = JSON.parse(raw);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// Not JSON — classification falls through to an opaque record, which is the truth.
|
|
373
|
+
}
|
|
374
|
+
{
|
|
375
|
+
const seen = classifySfRead(args, parsedRaw);
|
|
376
|
+
const succeeded = run.exitCode === 0 && !run.timedOut;
|
|
377
|
+
await appendReadRecord(sessionId, {
|
|
378
|
+
tool: "sf_read",
|
|
379
|
+
orgId: org.orgId,
|
|
380
|
+
orgAlias: targetOrg,
|
|
381
|
+
scope: seen.scope,
|
|
382
|
+
pattern: seen.pattern,
|
|
383
|
+
keys: succeeded ? seen.keys : [],
|
|
384
|
+
rows: succeeded ? seen.rows : 0,
|
|
385
|
+
durationMs: run.durationMs,
|
|
386
|
+
outcome: succeeded ? "ok" : "failed",
|
|
387
|
+
...(seen.object ? { object: seen.object } : {}),
|
|
388
|
+
// A FAILED read saw nothing, so it is not an unaccounted-for read — counting it opaque
|
|
389
|
+
// would inflate the very number that bounds the clean claim.
|
|
390
|
+
...(succeeded && seen.opaque ? { opaque: true } : {})
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (run.timedOut)
|
|
394
|
+
return fail("SF_TIMEOUT", `sf ${matched.join(" ")} timed out.`);
|
|
395
|
+
const truncated = raw.length > SF_OUTPUT_CAP;
|
|
396
|
+
const body = truncated ? `${raw.slice(0, SF_OUTPUT_CAP)}\n…[truncated ${raw.length - SF_OUTPUT_CAP} chars — narrow the query]` : raw;
|
|
397
|
+
if (run.exitCode !== 0) {
|
|
398
|
+
return sfReadFailure(matched, args, run.exitCode, body, run.stderr);
|
|
399
|
+
}
|
|
400
|
+
if (truncated) {
|
|
401
|
+
// SPILL, don't refuse.
|
|
402
|
+
//
|
|
403
|
+
// This used to answer a legitimate question with nothing: the org had replied, the answer
|
|
404
|
+
// was correct, and the tool discarded it because it was large. That is the single worst
|
|
405
|
+
// thing a read tool can do — worse than truncating, because the caller is left with no
|
|
406
|
+
// data AND no file to go look at. It is what a developer would never accept from `sf`,
|
|
407
|
+
// where the obvious move is `sf ... > out.json` and then grep it.
|
|
408
|
+
//
|
|
409
|
+
// The full output now goes to disk and the response carries a readable head plus the
|
|
410
|
+
// path, so the answer is always recoverable: Read it with an offset, grep it, or narrow
|
|
411
|
+
// the query — the caller's choice, made with the data in hand rather than instead of it.
|
|
412
|
+
//
|
|
413
|
+
// (The cap itself stays: a 5 MB describe pasted into the context window is its own kind
|
|
414
|
+
// of failure, and the reason the cap existed in the first place.)
|
|
415
|
+
const dir = path.join(sessionDir(sessionId), "sf-read");
|
|
416
|
+
await fs.mkdir(dir, { recursive: true });
|
|
417
|
+
const file = path.join(dir, `${Date.now()}-${matched.join("-").replace(/[^a-z0-9-]/gi, "")}.json`);
|
|
418
|
+
await fs.writeFile(file, raw, "utf8");
|
|
419
|
+
// Not every read is a query envelope; the file is the answer either way.
|
|
420
|
+
const envelope = parsedRaw?.result;
|
|
421
|
+
const rows = envelope?.totalSize ?? envelope?.records?.length;
|
|
422
|
+
const describe = matched[0] === "sobject" && matched[1] === "describe";
|
|
423
|
+
const subject = args[args.indexOf("-s") + 1] ?? args[args.indexOf("--sobject") + 1];
|
|
424
|
+
return ok([
|
|
425
|
+
`sf ${matched.join(" ")} returned ${raw.length.toLocaleString()} chars${rows !== undefined ? ` (${rows.toLocaleString()} rows)` : ""} — over the ${SF_OUTPUT_CAP.toLocaleString()}-char inline cap, so the FULL output was written to:`,
|
|
426
|
+
` ${file}`,
|
|
427
|
+
"",
|
|
428
|
+
"Nothing was lost. Read that file (with an offset for a slice), grep it, or re-run a narrower query.",
|
|
429
|
+
describe
|
|
430
|
+
? `For a field inventory specifically, describe_object {sessionId, objectApiName: "${subject ?? "<object>"}", match: "<term>"} answers the same question compactly.`
|
|
431
|
+
: "",
|
|
432
|
+
"",
|
|
433
|
+
"First 2,000 chars:",
|
|
434
|
+
raw.slice(0, 2_000)
|
|
435
|
+
]
|
|
436
|
+
.filter(Boolean)
|
|
437
|
+
.join("\n"), {
|
|
438
|
+
command: matched.join(" "),
|
|
439
|
+
truncated: true,
|
|
440
|
+
spilledTo: file,
|
|
441
|
+
bytes: raw.length,
|
|
442
|
+
...(rows !== undefined ? { rows } : {})
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// THE ROWS MUST BE IN structuredContent, not only in the text.
|
|
446
|
+
//
|
|
447
|
+
// Live-caught CF75: this returned {command, truncated} as its structured payload and put the
|
|
448
|
+
// records in the text alone. A client that renders structuredContent when present therefore
|
|
449
|
+
// showed the caller an envelope with no rows, on every single query. The run concluded the
|
|
450
|
+
// tool was broken, stopped using it, and — worse — briefly asserted a namespace was empty on
|
|
451
|
+
// the strength of a read that had returned nothing. A read tool that silently returns
|
|
452
|
+
// nothing is a correctness hazard, not an inconvenience.
|
|
453
|
+
//
|
|
454
|
+
// The two tools that appeared to "work" prove the mechanism: fail() nests the message inside
|
|
455
|
+
// structuredContent, and describe_object nests its `fields` there — so errors and field
|
|
456
|
+
// inventories both rendered, while sf_read's rows never did.
|
|
457
|
+
//
|
|
458
|
+
// `parsedRaw` is `body` here — truncation returned above, so raw and body are the same
|
|
459
|
+
// bytes. Undefined when the output was not JSON, in which case the text is the only
|
|
460
|
+
// representation, which is correct: there is nothing structured to hand back.
|
|
461
|
+
let parsed;
|
|
462
|
+
// Salesforce echoes an `attributes` block ({type, url}) on EVERY record. It is never the
|
|
463
|
+
// answer to anything — the type is in the query, the id is already a field — and it costs
|
|
464
|
+
// ~109 chars per row. One 80-row enumeration in the CF78 run carried 8.5 KB of it, and
|
|
465
|
+
// every query pays proportionally. Stripped here rather than asked of every caller.
|
|
466
|
+
if (parsedRaw && typeof parsedRaw === "object" && "result" in parsedRaw) {
|
|
467
|
+
parsed = { result: stripAttributes(parsedRaw.result) };
|
|
468
|
+
}
|
|
469
|
+
// The mirror is off exactly when `result` rides structuredContent, and on otherwise.
|
|
470
|
+
// `parsed === undefined` is NOT "parsing failed" — it also covers a valid JSON envelope
|
|
471
|
+
// with no `result` key (org list, some tooling reads). Both cases mirror, and must: with
|
|
472
|
+
// the client dropping the content channel (ok()'s header), whatever is not in
|
|
473
|
+
// structuredContent does not exist for the model, and CF75 was precisely a read whose rows
|
|
474
|
+
// rode the text alone. The wire carries the body twice on the mirrored branches; the model
|
|
475
|
+
// sees it once either way, which is the budget that matters.
|
|
476
|
+
return ok(body || "(empty result)", {
|
|
477
|
+
command: matched.join(" "),
|
|
478
|
+
truncated,
|
|
479
|
+
...(parsed ?? {})
|
|
480
|
+
}, { mirrorText: parsed === undefined });
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
if (error instanceof GateError)
|
|
484
|
+
return fail(error.code, error.message);
|
|
485
|
+
if (error instanceof SessionOrgError)
|
|
486
|
+
return fail(error.code, error.message);
|
|
487
|
+
if (error instanceof WorkspaceError)
|
|
488
|
+
return fail(error.code, error.message);
|
|
489
|
+
return internalError(error);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
server.registerTool("list_components", {
|
|
493
|
+
description: "Enumerate the org's actual candidates for one component family — DISCOVERY, which project_org cannot do (it can only project components you can already name). Use when a binding is ambiguous or missing: 19 Account layouts, no 'Sales User' profile. Read-only, fail-closed, session-scoped. Layout and RecordType require objectApiName. Pass `match` to narrow. Prefer this over hand-writing the SOQL through sf_read: the queries encode traps (Layout needs the Tooling API; PermissionSet must exclude IsOwnedByProfile, or profile-owned sets come back looking like real ones).",
|
|
494
|
+
inputSchema: {
|
|
495
|
+
sessionId: z.string().describe("The session whose org to read. Supplies the org so the alias never has to be restated."),
|
|
496
|
+
targetOrg: z
|
|
497
|
+
.string()
|
|
498
|
+
.optional()
|
|
499
|
+
.describe("Omit for the session's deploy target, which is the normal case. Pass \"campfire\" to describe/list against the session's Campfire CONSULTANT org without knowing its alias — use it before querying Campfire objects rather than guessing a field or relationship name. Any other value is a literal alias."),
|
|
500
|
+
typeName: z.enum(["Layout", "Profile", "PermissionSet", "PermissionSetGroup", "RecordType"]),
|
|
501
|
+
objectApiName: z
|
|
502
|
+
.string()
|
|
503
|
+
.optional()
|
|
504
|
+
.describe("Required for Layout and RecordType: the object whose candidates to list."),
|
|
505
|
+
match: z.string().optional().describe("Case-insensitive substring filter on the returned names. An org can have hundreds of profiles and permission sets.")
|
|
506
|
+
}
|
|
507
|
+
}, async ({ sessionId, targetOrg: targetOrgOverride, typeName, objectApiName, match }) => {
|
|
508
|
+
try {
|
|
509
|
+
// Session-derived by default; an explicit targetOrg is an intentional override for reading
|
|
510
|
+
// an org this session is not bound to. Reads skip the alias re-check (verify:false) —
|
|
511
|
+
// reading the wrong org is visible and harmless, and the round-trip is not free.
|
|
512
|
+
const org = await resolveReadOrg(sessionId, targetOrgOverride);
|
|
513
|
+
const targetOrg = org.alias;
|
|
514
|
+
assertTargetOrg(targetOrg);
|
|
515
|
+
const needsObject = typeName === "Layout" || typeName === "RecordType";
|
|
516
|
+
if (needsObject) {
|
|
517
|
+
if (!objectApiName) {
|
|
518
|
+
return fail("OBJECT_REQUIRED", `${typeName} enumeration requires objectApiName.`);
|
|
519
|
+
}
|
|
520
|
+
if (!OBJECT_NAME_RE.test(objectApiName)) {
|
|
521
|
+
return fail("INVALID_OBJECT_NAME", `Object API name ${JSON.stringify(objectApiName)} fails the allowlist charset guard.`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
let soql;
|
|
525
|
+
let tooling = false;
|
|
526
|
+
let mapRow;
|
|
527
|
+
switch (typeName) {
|
|
528
|
+
case "Layout":
|
|
529
|
+
soql = `SELECT Name FROM Layout WHERE EntityDefinition.QualifiedApiName = '${objectApiName}' ORDER BY Name`;
|
|
530
|
+
tooling = true;
|
|
531
|
+
mapRow = (row) => ({
|
|
532
|
+
name: row.Name,
|
|
533
|
+
fullName: `${objectApiName}-${String(row.Name)}`
|
|
534
|
+
});
|
|
535
|
+
break;
|
|
536
|
+
case "Profile":
|
|
537
|
+
soql = "SELECT Name FROM Profile ORDER BY Name";
|
|
538
|
+
mapRow = (row) => ({ name: row.Name });
|
|
539
|
+
break;
|
|
540
|
+
case "PermissionSet":
|
|
541
|
+
soql = "SELECT Name, Label FROM PermissionSet WHERE IsOwnedByProfile = false ORDER BY Name";
|
|
542
|
+
mapRow = (row) => ({ name: row.Name, label: row.Label });
|
|
543
|
+
break;
|
|
544
|
+
case "PermissionSetGroup":
|
|
545
|
+
soql = "SELECT DeveloperName, MasterLabel FROM PermissionSetGroup ORDER BY DeveloperName";
|
|
546
|
+
mapRow = (row) => ({ name: row.DeveloperName, label: row.MasterLabel });
|
|
547
|
+
break;
|
|
548
|
+
case "RecordType":
|
|
549
|
+
soql = `SELECT DeveloperName, Name FROM RecordType WHERE SobjectType = '${objectApiName}' ORDER BY DeveloperName`;
|
|
550
|
+
mapRow = (row) => ({ name: row.DeveloperName, label: row.Name });
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
const args = ["data", "query", "-q", soql, "--target-org", targetOrg, "--json"];
|
|
554
|
+
if (tooling)
|
|
555
|
+
args.splice(2, 0, "--use-tooling-api");
|
|
556
|
+
const run = await runProcess("sf", args, { env: sfCliEnv(), timeoutMs: 60_000 });
|
|
557
|
+
const scope = `${typeName}${objectApiName ? ` ${objectApiName}` : ""}${match ? ` match=${match}` : ""}`;
|
|
558
|
+
if (run.exitCode !== 0) {
|
|
559
|
+
// Still a record: the call reached the org (R9). A failed read saw nothing, so it carries
|
|
560
|
+
// no keys — and is not counted opaque, which is reserved for reads that DID return.
|
|
561
|
+
await appendReadRecord(sessionId, {
|
|
562
|
+
tool: "list_components", orgId: org.orgId, orgAlias: targetOrg, scope,
|
|
563
|
+
pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed",
|
|
564
|
+
...(objectApiName ? { object: objectApiName } : {})
|
|
565
|
+
});
|
|
566
|
+
return fail("ORG_READ_FAILED", `sf data query exited ${run.exitCode}${run.timedOut ? " (timeout)" : ""}. Fail-closed: candidates are UNKNOWN.\n${stdoutTail(run.stderr || run.stdout, 15)}`);
|
|
567
|
+
}
|
|
568
|
+
const parsed = JSON.parse(run.stdout);
|
|
569
|
+
const truncation = sfQueryTruncation(parsed);
|
|
570
|
+
if (truncation) {
|
|
571
|
+
// Same posture as the exit-code branch above: a capped read saw SOMETHING, but
|
|
572
|
+
// delivering it as complete would let absence of data read as absence of records.
|
|
573
|
+
// Refused, so nothing reaches the supervisor — recorded like a failed read.
|
|
574
|
+
await appendReadRecord(sessionId, {
|
|
575
|
+
tool: "list_components", orgId: org.orgId, orgAlias: targetOrg, scope,
|
|
576
|
+
pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed",
|
|
577
|
+
...(objectApiName ? { object: objectApiName } : {})
|
|
578
|
+
});
|
|
579
|
+
return fail("ORG_READ_TRUNCATED", `${truncation} Fail-closed: candidates are INCOMPLETE.`);
|
|
580
|
+
}
|
|
581
|
+
const every = (parsed.result?.records ?? []).map(mapRow);
|
|
582
|
+
// Filtered server-side for the same reason as describe_object: candidates the supervisor is
|
|
583
|
+
// not hunting for cost context whether or not it reads them.
|
|
584
|
+
const needle = match?.trim().toLowerCase();
|
|
585
|
+
const rows = needle
|
|
586
|
+
? every.filter((row) => Object.values(row).some((v) => String(v).toLowerCase().includes(needle)))
|
|
587
|
+
: every;
|
|
588
|
+
// READ PROVENANCE (R9, R12). This tool's whole purpose is DISCOVERY, so its pattern is
|
|
589
|
+
// always ENUMERATION — it lists a family, it never reads a definition.
|
|
590
|
+
//
|
|
591
|
+
// Keyed off the DELIVERED rows, not the org's full answer. `match` filters after the query,
|
|
592
|
+
// so a filtered call caused a wide enumeration but the supervisor only ever saw the
|
|
593
|
+
// matches — and a name nobody saw cannot have influenced anything. Over-reporting here
|
|
594
|
+
// would make a narrow lookup read like a corpus sweep in the contamination report.
|
|
595
|
+
await appendReadRecord(sessionId, {
|
|
596
|
+
tool: "list_components",
|
|
597
|
+
orgId: org.orgId,
|
|
598
|
+
orgAlias: targetOrg,
|
|
599
|
+
scope,
|
|
600
|
+
pattern: "enumeration",
|
|
601
|
+
keys: rows
|
|
602
|
+
.map((row) => typeName === "Layout"
|
|
603
|
+
? String(row.fullName)
|
|
604
|
+
: typeName === "RecordType"
|
|
605
|
+
? `${objectApiName}.${String(row.name)}`
|
|
606
|
+
: String(row.name))
|
|
607
|
+
.map((fullName) => `${typeName}:${fullName}`),
|
|
608
|
+
rows: rows.length,
|
|
609
|
+
durationMs: run.durationMs,
|
|
610
|
+
outcome: "ok",
|
|
611
|
+
...(objectApiName ? { object: objectApiName } : {})
|
|
612
|
+
});
|
|
613
|
+
if (needle && rows.length === 0) {
|
|
614
|
+
return ok(`${typeName}${objectApiName ? ` (${objectApiName})` : ""}: no candidate matching "${match}" among ${every.length}. Fail-closed — that is "none matched", not "none exist"; re-run without match to see them all.`, { typeName, objectApiName: objectApiName ?? null, match, matched: 0, total: every.length, candidates: [] });
|
|
615
|
+
}
|
|
616
|
+
const head = rows
|
|
617
|
+
.slice(0, 50)
|
|
618
|
+
.map((row) => Object.values(row).join("\t"))
|
|
619
|
+
.join("\n");
|
|
620
|
+
return ok(`${typeName}${objectApiName ? ` (${objectApiName})` : ""} in ${targetOrg}: ${needle ? `${rows.length} of ${every.length} matching "${match}"` : `${rows.length} candidate(s)`}\n${head}` +
|
|
621
|
+
(rows.length > 50 ? `\n… (${rows.length - 50} more)` : ""), { typeName, objectApiName: objectApiName ?? null, total: every.length, ...(needle ? { match } : {}), candidates: rows });
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
if (error instanceof GateError)
|
|
625
|
+
return fail(error.code, error.message);
|
|
626
|
+
if (error instanceof SessionOrgError)
|
|
627
|
+
return fail(error.code, error.message);
|
|
628
|
+
if (error instanceof WorkspaceError)
|
|
629
|
+
return fail(error.code, error.message);
|
|
630
|
+
return internalError(error);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
server.registerTool("describe_object", {
|
|
634
|
+
description: "Field inventory for one object via the Tooling API (FieldDefinition): apiName, dataType, label per field. Read-only, session-scoped. THE tool for binding work (mapping prose field references to real API names) — `sf_read sobject describe` returns ~800 KB for a standard object (it now spills the full answer to a file and hands back the path, so it is usable but costs a file read), and project_org costs a read per component. Pass `match` to get just the candidates you are hunting for instead of every field.",
|
|
635
|
+
inputSchema: {
|
|
636
|
+
sessionId: z.string().describe("The session whose org to read. Supplies the org so the alias never has to be restated."),
|
|
637
|
+
targetOrg: z
|
|
638
|
+
.string()
|
|
639
|
+
.optional()
|
|
640
|
+
.describe("Omit for the session's deploy target, which is the normal case. Pass \"campfire\" to describe/list against the session's Campfire CONSULTANT org without knowing its alias — use it before querying Campfire objects rather than guessing a field or relationship name. Any other value is a literal alias."),
|
|
641
|
+
objectApiName: z.string().describe("Object API name, e.g. Account or Invoice__c."),
|
|
642
|
+
match: z
|
|
643
|
+
.string()
|
|
644
|
+
.optional()
|
|
645
|
+
.describe("Case-insensitive substring filter on apiName or label. Binding work is a lookup, not an inventory: Account has 365 fields, and `match: \"priority\"` answers the question in two lines instead of twenty kilobytes. Omit only when you genuinely need the whole object.")
|
|
646
|
+
}
|
|
647
|
+
}, async ({ sessionId, targetOrg: targetOrgOverride, objectApiName, match }) => {
|
|
648
|
+
try {
|
|
649
|
+
// Session-derived by default; an explicit targetOrg is an intentional override for reading
|
|
650
|
+
// an org this session is not bound to. Reads skip the alias re-check (verify:false) —
|
|
651
|
+
// reading the wrong org is visible and harmless, and the round-trip is not free.
|
|
652
|
+
const org = await resolveReadOrg(sessionId, targetOrgOverride);
|
|
653
|
+
const targetOrg = org.alias;
|
|
654
|
+
assertTargetOrg(targetOrg);
|
|
655
|
+
if (!OBJECT_NAME_RE.test(objectApiName)) {
|
|
656
|
+
return fail("INVALID_OBJECT_NAME", `Object API name ${JSON.stringify(objectApiName)} fails the allowlist charset guard.`);
|
|
657
|
+
}
|
|
658
|
+
const soql = "SELECT QualifiedApiName, DataType, Label FROM FieldDefinition " +
|
|
659
|
+
`WHERE EntityDefinition.QualifiedApiName = '${objectApiName}' ORDER BY QualifiedApiName`;
|
|
660
|
+
const run = await runProcess("sf", ["data", "query", "--use-tooling-api", "-q", soql, "--target-org", targetOrg, "--json"], { env: sfCliEnv(), timeoutMs: 60_000 });
|
|
661
|
+
const scope = `${objectApiName}${match ? ` match=${match}` : ""}`;
|
|
662
|
+
if (run.exitCode !== 0) {
|
|
663
|
+
await appendReadRecord(sessionId, {
|
|
664
|
+
tool: "describe_object", orgId: org.orgId, orgAlias: targetOrg, scope, object: objectApiName,
|
|
665
|
+
pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed"
|
|
666
|
+
});
|
|
667
|
+
return fail("ORG_READ_FAILED", `sf data query exited ${run.exitCode}${run.timedOut ? " (timeout)" : ""}. Fail-closed.\n${stdoutTail(run.stderr || run.stdout, 15)}`);
|
|
668
|
+
}
|
|
669
|
+
const parsed = JSON.parse(run.stdout);
|
|
670
|
+
const truncation = sfQueryTruncation(parsed);
|
|
671
|
+
if (truncation) {
|
|
672
|
+
await appendReadRecord(sessionId, {
|
|
673
|
+
tool: "describe_object", orgId: org.orgId, orgAlias: targetOrg, scope, object: objectApiName,
|
|
674
|
+
pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed"
|
|
675
|
+
});
|
|
676
|
+
return fail("ORG_READ_TRUNCATED", `${truncation} Fail-closed: the field inventory is INCOMPLETE.`);
|
|
677
|
+
}
|
|
678
|
+
const records = parsed.result?.records ?? [];
|
|
679
|
+
const all = records.map((record) => ({
|
|
680
|
+
apiName: record.QualifiedApiName,
|
|
681
|
+
dataType: record.DataType,
|
|
682
|
+
label: record.Label
|
|
683
|
+
}));
|
|
684
|
+
// Filtered HERE rather than by the caller reading everything: the point is to keep the
|
|
685
|
+
// other 363 fields out of the transcript, which only works if they never arrive.
|
|
686
|
+
const needle = match?.trim().toLowerCase();
|
|
687
|
+
const fields = needle
|
|
688
|
+
? all.filter((f) => f.apiName.toLowerCase().includes(needle) || f.label.toLowerCase().includes(needle))
|
|
689
|
+
: all;
|
|
690
|
+
// READ PROVENANCE (R9, R12) — ENUMERATION-PER-FIELD of the described object. The object is
|
|
691
|
+
// named because the call named it; each field is enumerated because the call listed them.
|
|
692
|
+
// Neither is a body read: this returns apiName/dataType/label, never a definition.
|
|
693
|
+
//
|
|
694
|
+
// Standard fields are keyed alongside custom ones — a field is a `CustomField` component
|
|
695
|
+
// regardless of suffix, and prior-arm work on a standard field has to stay joinable. Keys
|
|
696
|
+
// follow the DELIVERED rows for the same reason list_components does: `match` filters
|
|
697
|
+
// before the supervisor sees anything, and a name nobody saw influenced nothing.
|
|
698
|
+
await appendReadRecord(sessionId, {
|
|
699
|
+
tool: "describe_object",
|
|
700
|
+
orgId: org.orgId,
|
|
701
|
+
orgAlias: targetOrg,
|
|
702
|
+
scope,
|
|
703
|
+
object: objectApiName,
|
|
704
|
+
pattern: "enumeration",
|
|
705
|
+
// Zero rows means the object may not exist at all, so the object key is withheld:
|
|
706
|
+
// positive evidence only, and "we read CustomObject:X" would be a claim about something
|
|
707
|
+
// this call just failed to find.
|
|
708
|
+
keys: records.length
|
|
709
|
+
? [`CustomObject:${objectApiName}`, ...fields.map((f) => `CustomField:${objectApiName}.${f.apiName}`)]
|
|
710
|
+
: [],
|
|
711
|
+
rows: fields.length,
|
|
712
|
+
durationMs: run.durationMs,
|
|
713
|
+
outcome: "ok"
|
|
714
|
+
});
|
|
715
|
+
if (records.length === 0) {
|
|
716
|
+
return ok(`object ${objectApiName}: 0 fields returned — the object may not exist in ${targetOrg} (verify with project_org, scope.components {typeName: CustomObject}).`, { object: objectApiName, exists: "unknown", fields: [] });
|
|
717
|
+
}
|
|
718
|
+
if (needle && fields.length === 0) {
|
|
719
|
+
return ok(`object ${objectApiName}: no field matching "${match}" among ${all.length} field(s). The prose name is not the API name — widen the match or read the projection.`, { object: objectApiName, match, matched: 0, totalFields: all.length, fields: [] });
|
|
720
|
+
}
|
|
721
|
+
const head = fields
|
|
722
|
+
.slice(0, 40)
|
|
723
|
+
.map((field) => `${field.apiName}\t${field.dataType}\t${field.label}`)
|
|
724
|
+
.join("\n");
|
|
725
|
+
return ok(`object ${objectApiName}: ${needle ? `${fields.length} of ${all.length} field(s) matching "${match}"` : `${fields.length} field(s)`}\napiName\tdataType\tlabel\n${head}` +
|
|
726
|
+
(fields.length > 40 ? `\n… (${fields.length - 40} more)` : "") +
|
|
727
|
+
(!needle && all.length > 60 ? `\nThis is the whole object. For a lookup, pass match to get only the candidates.` : ""), { object: objectApiName, totalFields: all.length, ...(needle ? { match } : {}), fields });
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
if (error instanceof GateError)
|
|
731
|
+
return fail(error.code, error.message);
|
|
732
|
+
if (error instanceof SessionOrgError)
|
|
733
|
+
return fail(error.code, error.message);
|
|
734
|
+
if (error instanceof WorkspaceError)
|
|
735
|
+
return fail(error.code, error.message);
|
|
736
|
+
return internalError(error);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|