in2-agent-server 0.20261025.25549 → 0.20261025.32045
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 +13 -3
- package/dist/core/chapterInstructions.js +14 -6
- package/dist/core/gate.js +60 -25
- package/dist/core/orgAuth.js +184 -0
- package/dist/core/resume.js +9 -2
- package/dist/core/sessionOrg.js +1 -1
- package/dist/core/supervisorContract.js +5 -4
- package/dist/direct/applyLoop.js +386 -34
- package/dist/projection/project.js +18 -3
- package/dist/tools/auth.js +58 -9
- package/dist/tools/direct.js +61 -25
- package/dist/tools/orgReauth.js +410 -0
- package/dist/tools/phase.js +83 -15
- package/package.json +1 -1
- package/reference/operator-voice.md +21 -0
- package/reference/supervisor-playbook.md +10 -8
- package/skills/in2-delivery/SKILL.md +11 -9
- package/skills/in2-delivery/references/operator-voice.md +22 -1
package/README.md
CHANGED
|
@@ -260,9 +260,19 @@ load-bearing and skipping one is refused by name. Ending a run early is legal fr
|
|
|
260
260
|
|
|
261
261
|
`resume_session {sessionId}` picks up a run a previous session left unfinished: it reconstructs the
|
|
262
262
|
position from the ledger plus the artifacts, reports where the two disagree rather than reconciling
|
|
263
|
-
them, and **checks the org
|
|
264
|
-
|
|
265
|
-
and
|
|
263
|
+
them, and **checks the org's sign-in instead of assuming it** — an activation sign-in can die while
|
|
264
|
+
nobody is watching (its lifetime has never been measured; see `docs/orgs-and-credentials.md` trap 1),
|
|
265
|
+
and `sf org display` still names the org for a dead one, so the resume probes liveness and tells a dead
|
|
266
|
+
sign-in (a card: sign in again with `org_login {sessionId}`) from one it could not check (try again).
|
|
267
|
+
It never re-authenticates anything itself.
|
|
268
|
+
|
|
269
|
+
**A sign-in that dies mid-run is recovered in the same run** (BUG-011). The write gate refuses
|
|
270
|
+
`ORG_AUTH_EXPIRED` only on a definite negative — a network blip or maintenance page is re-checked
|
|
271
|
+
once and passed — and `advance_phase` checks it once more on entering delivery. The operator is
|
|
272
|
+
carded, signs in again through `org_login {sessionId}` (a browser flow under a staging alias, rebound
|
|
273
|
+
only after the new sign-in proves to be the session's own org), and the refused step re-runs. A wave
|
|
274
|
+
that loses its sign-in after Salesforce has the job is `AMBIGUOUS_DELIVERY` naming the job id, and
|
|
275
|
+
re-running that step polls the job instead of submitting it again.
|
|
266
276
|
|
|
267
277
|
## Tools
|
|
268
278
|
|
|
@@ -53,9 +53,10 @@ const BRIEFS = {
|
|
|
53
53
|
"This batch is provider-bound: every step from here to close_batch uses the mcp__in2__ tools only,",
|
|
54
54
|
"whatever other Salesforce MCPs this client has connected.",
|
|
55
55
|
"",
|
|
56
|
-
"NEVER ask for a username, password or token, and never re-authenticate an org
|
|
57
|
-
"
|
|
58
|
-
"
|
|
56
|
+
"NEVER ask for a username, password or token, and never re-authenticate an org on your own",
|
|
57
|
+
"initiative. Salesforce auto-freezes an integration user on rapid retries, so every attempt is one the",
|
|
58
|
+
"operator asked for: a sign-in that dies mid-run is carded first, and `org_login {sessionId}` runs only",
|
|
59
|
+
"on their yes.",
|
|
59
60
|
"",
|
|
60
61
|
"CARRY THE sessionId, NOT AN ORG. Every org-touching tool derives its org from the session, bound and",
|
|
61
62
|
"verified when the session was created. `ALIAS_REPOINTED` means the alias moved to another org since",
|
|
@@ -421,11 +422,18 @@ const BRIEFS = {
|
|
|
421
422
|
" shipping a VALIDATED member beside a refused one. `objectFacetUpdates` have no practice mode at all.",
|
|
422
423
|
"- A ListView delete is a MANUAL SETUP TASK by default. \"Nothing references it\" is not evidence.",
|
|
423
424
|
"",
|
|
424
|
-
"
|
|
425
|
-
"
|
|
425
|
+
"SIGN-IN LOST MID-RUN (`ORG_AUTH_EXPIRED`, `ORG_READ_FAILED` / `Session expired`). NEVER RE-AUTH THE ORG",
|
|
426
|
+
"YOURSELF: card the operator — sign in again (recommended) or stop. On yes, `org_login {sessionId}`, a",
|
|
427
|
+
"browser flow THEY complete; call it again until it says signed back in. It refuses a sign-in to any",
|
|
428
|
+
"other org. Then re-run the refused step as it was: the refusal deployed nothing and the approval",
|
|
429
|
+
"stands. An `AMBIGUOUS_DELIVERY` whose notes say the sign-in was lost: sign in, then re-run the same",
|
|
430
|
+
"step — it polls the job the interrupted attempt submitted and never resubmits it.",
|
|
431
|
+
"If they cannot sign in now: card, then `close_batch {sessionId}`, then stop — IN THAT ORDER.",
|
|
426
432
|
"`close_batch` makes no org round-trip, so it works with every token dead; a run that stops at the",
|
|
427
433
|
"card reads as in-progress-forever and the waves that DID land are never reported. Rapid retries trip",
|
|
428
|
-
"Salesforce's integration-user auto-freeze,
|
|
434
|
+
"Salesforce's integration-user auto-freeze: one attempt per yes, and never touch a token endpoint.",
|
|
435
|
+
"`ORG_UNREACHABLE`: nothing was deployed and the sign-in's state is unknown — re-run once the connection",
|
|
436
|
+
"is back; only if Salesforce is reachable and it still fails, card the sign-in.",
|
|
429
437
|
"",
|
|
430
438
|
"A FAILED APPLY IS NEVER ANOTHER PROVIDER'S TO RETRY. This is the chapter where the temptation is",
|
|
431
439
|
"real: `REJECTED_BY_ORG`, a refusal, a wave that will not lint — and another Salesforce MCP connected",
|
package/dist/core/gate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTH_PROBE_TIMEOUT_MS, checkOrgLiveness } from "./orgAuth.js";
|
|
1
2
|
import { runProcess, sfCliEnv } from "./spawn.js";
|
|
2
3
|
import { isOrgGranted } from "./writeGrants.js";
|
|
3
4
|
// Execution-gate semantics copied from apps/in2-interactive/lib/flue/execution-gate.ts:
|
|
@@ -12,6 +13,14 @@ export class GateError extends Error {
|
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
const orgIdCache = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Drop the memoized org id for an alias (BUG-011). The cache is safe only while an alias keeps
|
|
18
|
+
* pointing at the same credential; a mid-run re-sign-in (`org_login {sessionId}`) repoints the alias
|
|
19
|
+
* at a fresh auth file, and the next resolution must come from the CLI rather than from before it.
|
|
20
|
+
*/
|
|
21
|
+
export function forgetOrgId(alias) {
|
|
22
|
+
orgIdCache.delete(alias);
|
|
23
|
+
}
|
|
15
24
|
export async function defaultOrgIdResolver(alias) {
|
|
16
25
|
const cached = orgIdCache.get(alias);
|
|
17
26
|
if (cached)
|
|
@@ -52,9 +61,11 @@ export async function defaultOrgIdResolver(alias) {
|
|
|
52
61
|
* itself, as a less legible error, once the gate has passed from cache once.
|
|
53
62
|
*/
|
|
54
63
|
export async function defaultOrgLivenessProbe(alias) {
|
|
64
|
+
// Killed just after checkOrgLiveness's own cap (BUG-011): offline, this call takes ~32 s, and a
|
|
65
|
+
// probe the caller has already given up on must not linger as a child process.
|
|
55
66
|
const run = await runProcess("sf", ["org", "display", "--target-org", alias, "--json"], {
|
|
56
67
|
env: sfCliEnv(),
|
|
57
|
-
timeoutMs:
|
|
68
|
+
timeoutMs: AUTH_PROBE_TIMEOUT_MS + 2_000
|
|
58
69
|
});
|
|
59
70
|
if (run.timedOut)
|
|
60
71
|
return null;
|
|
@@ -62,8 +73,15 @@ export async function defaultOrgLivenessProbe(alias) {
|
|
|
62
73
|
const parsed = JSON.parse(run.stdout);
|
|
63
74
|
if (parsed.result)
|
|
64
75
|
return { ...(parsed.result.connectedStatus ? { connectedStatus: parsed.result.connectedStatus } : {}) };
|
|
65
|
-
//
|
|
66
|
-
//
|
|
76
|
+
// The envelope's NAME travels with its message (BUG-011 simulation runbook §1.3 #2): offline with
|
|
77
|
+
// a day-old API-version cache, the CLI answers `{name: "DomainNotFoundError", message: "Parsing
|
|
78
|
+
// --target-org ... The org cannot be found"}`, and only the name says it is a resolution failure.
|
|
79
|
+
if (parsed.message && parsed.name)
|
|
80
|
+
return { connectedStatus: `${parsed.name}: ${parsed.message}` };
|
|
81
|
+
// A parseable answer with no result is the CLI's error envelope. Its message is passed on as the
|
|
82
|
+
// status and CLASSIFIED like any other (core/orgAuth.ts): "No authorization information found"
|
|
83
|
+
// is a definite negative, but the same envelope also carries lock-file and decipher errors from
|
|
84
|
+
// an auth file another sf child is rewriting, which are not. Until BUG-011 every envelope refused.
|
|
67
85
|
return parsed.message ? { connectedStatus: parsed.message } : null;
|
|
68
86
|
}
|
|
69
87
|
catch {
|
|
@@ -127,7 +145,7 @@ export function campfireAllowlistedOrgIds() {
|
|
|
127
145
|
const ids = raw ? raw.split(",").map((entry) => entry.trim()).filter(Boolean) : [...CONSULTANT_ORG_IDS];
|
|
128
146
|
return ids.map(normalizeOrgId);
|
|
129
147
|
}
|
|
130
|
-
export async function assertCampfireWriteAllowed(consultantOrg, resolveOrgId = defaultOrgIdResolver) {
|
|
148
|
+
export async function assertCampfireWriteAllowed(consultantOrg, resolveOrgId = defaultOrgIdResolver, probeLiveness = defaultOrgLivenessProbe, sleep) {
|
|
131
149
|
assertTargetOrg(consultantOrg);
|
|
132
150
|
if (!campfireWriteEnabled()) {
|
|
133
151
|
throw new GateError("CAMPFIRE_WRITE_NOT_AUTHORIZED", "Campfire writes are default-closed (the consultant org is real and shared). Start the server with CC_MCP_CAMPFIRE_WRITE=1 to enable them — this gate is deliberately separate from CC_MCP_EXECUTION.");
|
|
@@ -140,6 +158,18 @@ export async function assertCampfireWriteAllowed(consultantOrg, resolveOrgId = d
|
|
|
140
158
|
if (!allowlist.includes(normalizeOrgId(orgId))) {
|
|
141
159
|
throw new GateError("CONSULTANT_ORG_NOT_ALLOWLISTED", `Org ${orgId} (alias ${consultantOrg}) is not on the Campfire write allowlist [${allowlist.join(", ")}].`);
|
|
142
160
|
}
|
|
161
|
+
// BUG-011 slice 4, the writeback half of .audit/oauth-expiry.md item 5. This gate resolved the org
|
|
162
|
+
// id from a per-process cache and never asked whether the credential still worked, so a dead
|
|
163
|
+
// consultant sign-in surfaced as a jsforce error at the FIRST upsert, after the delivery had
|
|
164
|
+
// landed. Same probe and classifier as the direct tier, after the allowlist for the same reason:
|
|
165
|
+
// authority first, then reachability.
|
|
166
|
+
const liveness = await checkOrgLiveness(consultantOrg, { probe: probeLiveness, ...(sleep ? { sleep } : {}) });
|
|
167
|
+
if (liveness.state === "dead") {
|
|
168
|
+
throw new GateError("ORG_AUTH_EXPIRED", `The Campfire org ${orgId} (alias ${consultantOrg}) is the right org, but its stored sign-in no longer works (${liveness.status ?? "no status"}). ` +
|
|
169
|
+
`Nothing was written to Campfire, and the delivery itself is unaffected: only the record of it is waiting.\n` +
|
|
170
|
+
`Fix in this run: ask the operator in one card whether to sign in to Campfire again now. On yes, call org_login {sessionId, role: "campfire"} (a browser sign-in THEY complete, checked against the Campfire org id before anything is rebound), then re-run this writeback. One attempt per "yes".\n` +
|
|
171
|
+
`If they cannot sign in now: card what is unwritten, then close_batch {sessionId}, then stop.`);
|
|
172
|
+
}
|
|
143
173
|
}
|
|
144
174
|
// Direct working tier (Amendment A, plan 2026-07-22-001): raw metadata writes with
|
|
145
175
|
// mandatory lint -> checkOnly -> deploy -> re-project -> three-way diff. Own gate,
|
|
@@ -153,7 +183,7 @@ export function directAllowlistedOrgIds() {
|
|
|
153
183
|
const ids = raw ? raw.split(",").map((entry) => entry.trim()).filter(Boolean) : [T_PINTO_ORG_ID];
|
|
154
184
|
return ids.map(normalizeOrgId);
|
|
155
185
|
}
|
|
156
|
-
export async function assertDirectWriteAllowed(targetOrg, resolveOrgId = defaultOrgIdResolver, orgIsGranted = isOrgGranted, probeLiveness = defaultOrgLivenessProbe) {
|
|
186
|
+
export async function assertDirectWriteAllowed(targetOrg, resolveOrgId = defaultOrgIdResolver, orgIsGranted = isOrgGranted, probeLiveness = defaultOrgLivenessProbe, sleep) {
|
|
157
187
|
assertTargetOrg(targetOrg);
|
|
158
188
|
if (!directWriteEnabled()) {
|
|
159
189
|
throw new GateError("DIRECT_WRITE_NOT_AUTHORIZED", "The direct working tier is default-closed. Start the server with CC_MCP_DIRECT_WRITE=1 to enable it (org authority then comes from activate_session, or from CC_MCP_DIRECT_ALLOWLIST for internal testing).");
|
|
@@ -191,27 +221,32 @@ export async function assertDirectWriteAllowed(targetOrg, resolveOrgId = default
|
|
|
191
221
|
}
|
|
192
222
|
// THE GRANT AND THE TOKEN IT AUTHORIZES ARE ON DIFFERENT CLOCKS, and until this check existed the
|
|
193
223
|
// gate read only the slower one. A grant lasts 30 days (`writeGrants.ts`); the activation refresh
|
|
194
|
-
// token it authorizes
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
224
|
+
// token it authorizes has NO measured lifetime. The IN2 Launch app's metadata says
|
|
225
|
+
// `refreshTokenPolicy=infinite`, yet activation tokens demonstrably die: revoked, the per-user
|
|
226
|
+
// approval limit, a target org that manages the app with its own policy (`docs/orgs-and-
|
|
227
|
+
// credentials.md` trap 1). An earlier version of this comment said "about one day"; nobody ever
|
|
228
|
+
// measured that. Either way the install could believe it was authorized to write to an org it
|
|
229
|
+
// could no longer reach, and the truth arrived from Salesforce as `Session expired` mid-deploy,
|
|
230
|
+
// discovered on the first request rather than at connect (`direct/rollbackRun.ts` header), which
|
|
231
|
+
// is the worst possible moment for it.
|
|
198
232
|
//
|
|
199
|
-
// A liveness probe rather than a shorter calendar TTL, deliberately. The
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
233
|
+
// A liveness probe rather than a shorter calendar TTL, deliberately. The in-run recovery,
|
|
234
|
+
// `org_login {sessionId}`, re-mints the credential for the SAME org under the session's alias
|
|
235
|
+
// without touching the grant. Shortening the TTL would revoke authority the operator had just
|
|
236
|
+
// legitimately restored. Calendar time bounds the ENGAGEMENT; the probe answers whether the org is
|
|
237
|
+
// reachable right now.
|
|
204
238
|
//
|
|
205
|
-
// ONLY A DEFINITE NEGATIVE REFUSES
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
`
|
|
215
|
-
`
|
|
239
|
+
// ONLY A DEFINITE NEGATIVE REFUSES, and BUG-011 gave that sentence a definition (core/orgAuth.ts).
|
|
240
|
+
// The CLI writes whatever its refresh threw into `connectedStatus`, so a DNS blip or a maintenance
|
|
241
|
+
// page used to refuse here as an expired sign-in and end the run. An unclear answer is now
|
|
242
|
+
// re-checked once and then passed through (unverified is not expired), and anything that then
|
|
243
|
+
// fails inside the wave is labelled by the apply loop as what it is, never as the org rejecting
|
|
244
|
+
// the change.
|
|
245
|
+
const liveness = await checkOrgLiveness(targetOrg, { probe: probeLiveness, ...(sleep ? { sleep } : {}) });
|
|
246
|
+
if (liveness.state === "dead") {
|
|
247
|
+
throw new GateError("ORG_AUTH_EXPIRED", `Org ${orgId} (alias ${targetOrg}) is authorized by this install, but its stored sign-in no longer works (${liveness.status ?? "no status"}). ` +
|
|
248
|
+
`Nothing was deployed, so there is nothing to undo.\n` +
|
|
249
|
+
`Fix in this run (recommended): ask the operator in one card whether to sign in again now. On yes, call org_login {sessionId} (a browser sign-in THEY complete, checked against this session's org id before anything is rebound), then re-run this same step. One attempt per "yes": rapid retries trip Salesforce's auto-freeze.\n` +
|
|
250
|
+
`If they cannot sign in now: card, then close_batch {sessionId}, then stop. Re-sending from Campfire and redeeming a fresh code with activate_session also restores access, as a new batch.`);
|
|
216
251
|
}
|
|
217
252
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// IS THIS ORG'S SIGN-IN DEAD, OR COULD WE JUST NOT TELL? (BUG-011)
|
|
2
|
+
//
|
|
3
|
+
// The write gate used to refuse ORG_AUTH_EXPIRED on any `connectedStatus` other than "Connected",
|
|
4
|
+
// and that field is not a status enum. The sf CLI fills it with whatever its refresh attempt threw:
|
|
5
|
+
// `authErrorHandler` in plugin-org/lib/shared/orgListUtil.js (CLI 2.137.7) returns
|
|
6
|
+
// `'Down (Maintenance)'` for a maintenance page, `'Bad Response'` for any other HTML, and otherwise
|
|
7
|
+
// `error.code ?? error.message` — so `ENOTFOUND`, `ETIMEDOUT` and a revoked token all arrive in the
|
|
8
|
+
// same field. A DNS blip during a long build therefore read as "your sign-in expired", and the
|
|
9
|
+
// doctrine then ended the run. One hiccup was enough.
|
|
10
|
+
//
|
|
11
|
+
// So the status is classified, and ONLY A DEFINITE NEGATIVE refuses:
|
|
12
|
+
// dead — the org (or the CLI's own store) answered that this credential no longer works
|
|
13
|
+
// live — "Connected"
|
|
14
|
+
// unknown — no answer we can trust either way (network, maintenance, a half-written auth file)
|
|
15
|
+
// unreported — the CLI answered with an org and no status at all (some auth shapes do this)
|
|
16
|
+
//
|
|
17
|
+
// ORDER MATTERS. jsforce wraps a refresh failure verbatim — `Unable to refresh session due to:
|
|
18
|
+
// Error authenticating with the refresh token due to: <cause>` (jsforce session-refresh-delegate.js,
|
|
19
|
+
// @salesforce/core authInfo.js `refreshTokenAuthError`) — so a network failure DURING a refresh
|
|
20
|
+
// produces the same prefix as a revoked token. Only the cause decides. The OAuth server's own
|
|
21
|
+
// answers (`expired access/refresh token`, `invalid_grant`, `inactive user`) can only come back from
|
|
22
|
+
// a server that was reached, so they are checked first; network markers next; and the bare error
|
|
23
|
+
// NAMES last, because a name like `RefreshTokenAuthError` hides its cause and is only trusted when
|
|
24
|
+
// nothing else in the string contradicts it. The names are a SECONDARY match: measured on a dead
|
|
25
|
+
// T-Pinto copy (BUG-011 simulation runbook §1.1), the CLI's error envelopes for a dead refresh token
|
|
26
|
+
// are named plain `Error`, and only the message ("expired access/refresh token") identifies them.
|
|
27
|
+
/** Answers only the token endpoint gives — the server was reached and said no. */
|
|
28
|
+
const SERVER_SAID_NO = [
|
|
29
|
+
/expired access\/refresh token/i,
|
|
30
|
+
/\binvalid_grant\b/i,
|
|
31
|
+
/\binactive (user|organization)\b/i,
|
|
32
|
+
/\b(user|account) (is|has been) (frozen|inactive|deactivated)\b/i
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Could not reach Salesforce, or reached something that was not an answer.
|
|
36
|
+
*
|
|
37
|
+
* `DomainNotFoundError` is here and NOT in the dead list, deliberately and against the first draft
|
|
38
|
+
* of the BUG-011 plan: @salesforce/core throws it from `Connection.isResolvable` when the instance
|
|
39
|
+
* host does not resolve, which is exactly what a laptop with its Wi-Fi off produces. A deleted org
|
|
40
|
+
* produces it too, but a deleted org will fail every later call loudly; an offline laptop told its
|
|
41
|
+
* sign-in expired is sent to sign in again for nothing.
|
|
42
|
+
*/
|
|
43
|
+
const UNREACHABLE = [
|
|
44
|
+
/\bE(NOTFOUND|TIMEDOUT|CONNRESET|CONNREFUSED|CONNABORTED|AI_AGAIN|NETUNREACH|HOSTUNREACH|PIPE)\b/,
|
|
45
|
+
/\bDomainNotFoundError\b/,
|
|
46
|
+
/socket hang up/i,
|
|
47
|
+
/fetch failed/i,
|
|
48
|
+
/network (error|is unreachable)/i,
|
|
49
|
+
// Not a bare /maintenance/: this list also reads projection read errors, which can quote a
|
|
50
|
+
// component's label ("Maintenance Plan") — the CLI's own string, and Salesforce's page wording only.
|
|
51
|
+
/Down \(Maintenance\)|\bunder maintenance\b|\b(scheduled )?maintenance (window|mode)\b/i,
|
|
52
|
+
/^Bad Response$/,
|
|
53
|
+
/<html|<!DOCTYPE HTML/i,
|
|
54
|
+
/\b(502|503|504)\b.*(gateway|unavailable)/i
|
|
55
|
+
];
|
|
56
|
+
/** The CLI's names for "this credential is gone", trusted only when nothing above contradicts them. */
|
|
57
|
+
const DEAD_BY_NAME = [
|
|
58
|
+
/\bRefreshTokenAuthError\b/,
|
|
59
|
+
/\bNamedOrgNotFound(Error)?\b/,
|
|
60
|
+
/No authori[sz]ation information found/i,
|
|
61
|
+
/\bINVALID_SESSION_ID\b/,
|
|
62
|
+
/\bexpired\b/i
|
|
63
|
+
];
|
|
64
|
+
export function classifyOrgStatus(status) {
|
|
65
|
+
if (status === undefined)
|
|
66
|
+
return "unreported";
|
|
67
|
+
const s = status.trim();
|
|
68
|
+
if (s === "Connected")
|
|
69
|
+
return "live";
|
|
70
|
+
if (SERVER_SAID_NO.some((re) => re.test(s)))
|
|
71
|
+
return "dead";
|
|
72
|
+
if (UNREACHABLE.some((re) => re.test(s)))
|
|
73
|
+
return "unknown";
|
|
74
|
+
if (DEAD_BY_NAME.some((re) => re.test(s)))
|
|
75
|
+
return "dead";
|
|
76
|
+
// Everything else — a decipher error from an auth file mid-rewrite, a lock-file error, an empty
|
|
77
|
+
// string — is the CLI failing to say, which is not the org saying no.
|
|
78
|
+
return "unknown";
|
|
79
|
+
}
|
|
80
|
+
/** A deploy/read failure whose text says the sign-in is gone — never a component the org refused. */
|
|
81
|
+
export function isAuthDeadFailure(text) {
|
|
82
|
+
return classifyFailureText(text) === "dead";
|
|
83
|
+
}
|
|
84
|
+
/** A deploy/read failure whose text says Salesforce could not be reached at all. */
|
|
85
|
+
export function isOrgUnreachableFailure(text) {
|
|
86
|
+
return classifyFailureText(text) === "unreachable";
|
|
87
|
+
}
|
|
88
|
+
function classifyFailureText(text) {
|
|
89
|
+
if (!text)
|
|
90
|
+
return null;
|
|
91
|
+
if (SERVER_SAID_NO.some((re) => re.test(text)))
|
|
92
|
+
return "dead";
|
|
93
|
+
if (UNREACHABLE.some((re) => re.test(text)))
|
|
94
|
+
return "unreachable";
|
|
95
|
+
// The bare-name list carries a loose `expired`, which is fine for a status field and far too loose
|
|
96
|
+
// for free text — a component failure can say "expired" about a date. Only the names here.
|
|
97
|
+
if (DEAD_BY_NAME.slice(0, 4).some((re) => re.test(text)))
|
|
98
|
+
return "dead";
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The wave lost its org connection BEFORE anything could have changed in the org (BUG-011 slice 2).
|
|
103
|
+
*
|
|
104
|
+
* Thrown by the apply loop only where it can prove nothing was submitted: the pre-projection, the
|
|
105
|
+
* checkOnly, a real deploy that left no job in the sf deploy cache, or a first write refused for
|
|
106
|
+
* auth. `deployed` is a literal `false` so no caller can mistake this for a half-applied wave —
|
|
107
|
+
* those come back as AMBIGUOUS_DELIVERY instead, because the job may still land.
|
|
108
|
+
*
|
|
109
|
+
* `kind` keeps "the sign-in is gone" (sign in again) apart from "Salesforce could not be reached"
|
|
110
|
+
* (try again), which send the operator to different places.
|
|
111
|
+
*/
|
|
112
|
+
export class OrgAuthLostError extends Error {
|
|
113
|
+
stage;
|
|
114
|
+
kind;
|
|
115
|
+
detail;
|
|
116
|
+
deployed = false;
|
|
117
|
+
constructor(stage, kind, detail) {
|
|
118
|
+
super(kind === "dead"
|
|
119
|
+
? `the org sign-in was lost during ${stage} (${detail}); nothing was deployed`
|
|
120
|
+
: `Salesforce could not be reached during ${stage} (${detail}); nothing was deployed`);
|
|
121
|
+
this.stage = stage;
|
|
122
|
+
this.kind = kind;
|
|
123
|
+
this.detail = detail;
|
|
124
|
+
this.name = "OrgAuthLostError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Dead, unreachable, or neither, for one piece of failure text. */
|
|
128
|
+
export function failureAuthClass(text) {
|
|
129
|
+
return classifyFailureText(text);
|
|
130
|
+
}
|
|
131
|
+
/** How long to wait before the one re-check. A blip long enough to outlast it is not a blip. */
|
|
132
|
+
export const AUTH_REPROBE_DELAY_MS = 5_000;
|
|
133
|
+
/**
|
|
134
|
+
* The most one liveness probe may take. Measured in the BUG-011 simulation: offline, `sf org display`
|
|
135
|
+
* takes ~32 s (the CLI retries the refresh), so the gate's re-check held a wave ~70 s before passing
|
|
136
|
+
* through. A probe that has not answered in ten seconds has answered "unknown", which is all the
|
|
137
|
+
* longer wait would have said too.
|
|
138
|
+
*/
|
|
139
|
+
export const AUTH_PROBE_TIMEOUT_MS = 10_000;
|
|
140
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
141
|
+
const TIMED_OUT = Symbol("timed-out");
|
|
142
|
+
/**
|
|
143
|
+
* Probe, and on an unclear answer probe ONCE more (BUG-011, decision Q2: re-check once, then pass
|
|
144
|
+
* through). A null probe (the CLI would not answer) is unclear too and earns the re-check; an org
|
|
145
|
+
* that answers with NO status is not, because asking again gets the same silence and costs five
|
|
146
|
+
* seconds on every write to such an org.
|
|
147
|
+
*
|
|
148
|
+
* Each probe is capped at `AUTH_PROBE_TIMEOUT_MS` and a timeout counts as "unknown". When the first
|
|
149
|
+
* probe timed out, the five-second pause is skipped: ten seconds of waiting for an answer already
|
|
150
|
+
* was the pause, and adding five more is how an outage became a seventy-second stall.
|
|
151
|
+
*
|
|
152
|
+
* Never more than one re-check: a gate that polled until Salesforce answered would hang a wave on
|
|
153
|
+
* an outage, and the downstream failure is now labelled honestly anyway (direct/applyLoop.ts).
|
|
154
|
+
*/
|
|
155
|
+
export async function checkOrgLiveness(alias, deps) {
|
|
156
|
+
const cap = deps.probeTimeoutMs ?? AUTH_PROBE_TIMEOUT_MS;
|
|
157
|
+
const read = async () => {
|
|
158
|
+
let timer;
|
|
159
|
+
const answer = await Promise.race([
|
|
160
|
+
deps.probe(alias),
|
|
161
|
+
new Promise((resolve) => {
|
|
162
|
+
timer = setTimeout(() => resolve(TIMED_OUT), cap);
|
|
163
|
+
})
|
|
164
|
+
]).finally(() => clearTimeout(timer));
|
|
165
|
+
if (answer === TIMED_OUT)
|
|
166
|
+
return { state: "unknown", timedOut: true };
|
|
167
|
+
if (!answer)
|
|
168
|
+
return { state: "unknown", timedOut: false };
|
|
169
|
+
return {
|
|
170
|
+
state: classifyOrgStatus(answer.connectedStatus),
|
|
171
|
+
...(answer.connectedStatus !== undefined ? { status: answer.connectedStatus } : {}),
|
|
172
|
+
timedOut: false
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
const first = await read();
|
|
176
|
+
if (first.state !== "unknown")
|
|
177
|
+
return { state: first.state, ...(first.status !== undefined ? { status: first.status } : {}), probes: 1 };
|
|
178
|
+
if (!first.timedOut)
|
|
179
|
+
await (deps.sleep ?? defaultSleep)(AUTH_REPROBE_DELAY_MS);
|
|
180
|
+
const second = await read();
|
|
181
|
+
// Keep whichever answer actually said something, so a refusal or a note can quote it.
|
|
182
|
+
const status = second.status ?? first.status ?? (second.timedOut ? `no answer within ${Math.round(cap / 1000)} s` : undefined);
|
|
183
|
+
return { state: second.state, ...(status !== undefined ? { status } : {}), probes: 2 };
|
|
184
|
+
}
|
package/dist/core/resume.js
CHANGED
|
@@ -234,8 +234,15 @@ export function renderReconstruction(r, auth) {
|
|
|
234
234
|
""
|
|
235
235
|
];
|
|
236
236
|
lines.push("ORG ACCESS:", ` ${auth.summary}`);
|
|
237
|
-
if (!auth.usable) {
|
|
238
|
-
|
|
237
|
+
if (!auth.usable && auth.unreachable) {
|
|
238
|
+
// Connection first, not a sign-in card (BUG-011 simulation C4b): an offline operator sent through
|
|
239
|
+
// a browser sign-in is asked to fix what may not be broken. But nothing here is a claim about the
|
|
240
|
+
// sign-in either — offline, its state is UNKNOWN, and on a token that had already died "this is
|
|
241
|
+
// not an expired sign-in" would be false (simulation tester, 4ed783e).
|
|
242
|
+
lines.push(" You cannot deploy, project or read the org until Salesforce answers, and whether the sign-in still", " works is unknown. Tell the operator the org could not be reached and nothing has changed; ask them", " to check the connection, and call resume_session again. Only if Salesforce is reachable and it", " still fails, card them to sign in again. Do not plan around an org you could not read.");
|
|
243
|
+
}
|
|
244
|
+
else if (!auth.usable) {
|
|
245
|
+
lines.push(" You cannot deploy, project or read the org until this is fixed, and you must NOT fix it yourself.", " Card the operator with exactly one ask: sign in again now (the browser flow is theirs; it is", " `org_login {sessionId}`, and the result says whether it landed on this session's org), or stop.", " Wait for their answer. Do not plan around a dead org — a plan built on a projection you could not", " take is a plan built on the requirement text alone.");
|
|
239
246
|
}
|
|
240
247
|
lines.push("");
|
|
241
248
|
if (r.batchId) {
|
package/dist/core/sessionOrg.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function resolveSessionOrg(session, role, opts = {}) {
|
|
|
49
49
|
if (!live) {
|
|
50
50
|
// Deliberately a GateError: the caller's fail-closed handling for "we cannot tell which org
|
|
51
51
|
// this is" already exists and is the correct response here too.
|
|
52
|
-
throw new GateError("ORG_ID_UNRESOLVED", `Session ${session.sessionId} is bound to alias ${JSON.stringify(binding.alias)} (org ${binding.orgId}), but that alias does not resolve — it may have been logged out.
|
|
52
|
+
throw new GateError("ORG_ID_UNRESOLVED", `Session ${session.sessionId} is bound to alias ${JSON.stringify(binding.alias)} (org ${binding.orgId}), but that alias does not resolve — it may have been logged out, or this machine cannot reach Salesforce (offline, the CLI cannot resolve an org's host). Check the connection first; if it is fine, the operator signs in again. Refusing fail-closed.`);
|
|
53
53
|
}
|
|
54
54
|
if (live.slice(0, 15) !== binding.orgId.slice(0, 15)) {
|
|
55
55
|
throw new SessionOrgError("ALIAS_REPOINTED", `Alias ${JSON.stringify(binding.alias)} now points at org ${live}, but session ${session.sessionId} was bound to ${binding.orgId}. Refusing — an alias is operator-controlled and can be repointed, and acting on the new one would apply this session's work to a different org than it was approved against.`);
|
|
@@ -59,10 +59,11 @@ URL is your FIRST line to the operator, verbatim and on its own line — and fol
|
|
|
59
59
|
\`OPEN IT IN THE IN-APP BROWSER\` steps exactly when its tools exist here. Then read the requirements, \`project_org\`, and STOP: "N requirements imported,
|
|
60
60
|
watch at <url> — start analysis?" The fan-out starts on their go, never before it. Any other start ->
|
|
61
61
|
\`create_session\` then \`import_requirements\`. An unauthenticated org -> \`org_login\`, a browser
|
|
62
|
-
flow THEY complete. Never ask for a password or token, and never re-authenticate an org
|
|
63
|
-
|
|
64
|
-
\`
|
|
65
|
-
|
|
62
|
+
flow THEY complete. Never ask for a password or token, and never re-authenticate an org on your
|
|
63
|
+
own initiative. Sign-in lost mid-run (\`ORG_AUTH_EXPIRED\`): CARD them; on yes, \`org_login
|
|
64
|
+
{sessionId}\` (THEY complete it; never accept a sign-in to a different org), then re-run the step.
|
|
65
|
+
If they cannot: card, then \`close_batch\`, then stop — in that order. \`close_batch\` makes no
|
|
66
|
+
org round-trip, so it works with every token dead; a run you walk away from reads as ABANDONED.
|
|
66
67
|
|
|
67
68
|
PROVIDER BOUNDARY: an IN2 delivery runs on IN2's tools alone, activation through close_batch.
|
|
68
69
|
Never substitute another Salesforce MCP's tools for any part of it — reads, deploys, verification,
|