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 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 auth instead of assuming it** — an activation-minted token lasts about a
264
- day, so the commonest resume has intact artifacts and a dead org, which is a card for the operator
265
- and never a re-authentication.
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 yourself when a",
57
- "session dies. Salesforce auto-freezes an integration user on rapid retries, so a second attempt is",
58
- "not free — stop, and tell the operator exactly what to run.",
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
- "NEVER RE-AUTH THE ORG YOURSELF. On `ORG_READ_FAILED` / `Session expired`: card the operator with the",
425
- "fix (one line, re-auth from Campfire), then `close_batch {sessionId}`, then stop — IN THAT ORDER.",
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, so never touch the token endpoint yourself.",
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: 60_000
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
- // A parseable answer with no result is the CLI saying it has no auth for this alias — a real
66
- // answer, and a negative one. Distinct from the unparseable case below, which is no answer.
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 lasts about ONE (`docs/orgs-and-credentials.md`). So for 29 of 30 days an
195
- // install believed it could write to an org it could no longer reach, and the truth arrived from
196
- // Salesforce as `Session expired` mid-deploy — discovered on the first request rather than at
197
- // connect (`direct/rollbackRun.ts` header), which is the worst possible moment for it.
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 doctrine's own
200
- // first-choice recovery is `sf org login web --alias <the session alias>`, which re-mints a
201
- // durable CLI token under the SAME alias without touching the grant. Shortening the TTL would
202
- // revoke authority the operator had just legitimately restored. Calendar time bounds the
203
- // ENGAGEMENT; the probe answers whether the org is reachable right now.
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. A probe that returns nothing, or an answer that names no
206
- // status at all, is the CLI declining to say — which is the same evidence state as not having
207
- // looked, and this file's own doctrine is that "I could not verify that" is only true after you
208
- // looked. Refusing on silence would turn a flaky child process, or an org whose auth shape does
209
- // not report a status, into a blocked delivery.
210
- const liveness = await probeLiveness(targetOrg);
211
- if (liveness?.connectedStatus && liveness.connectedStatus !== "Connected") {
212
- throw new GateError("ORG_AUTH_EXPIRED", `Org ${orgId} (alias ${targetOrg}) is authorized by this install, but its stored sign-in is no longer valid (${liveness.connectedStatus}). ` +
213
- `Activation-minted tokens last about a day, so a session resumed the next morning reads this way — the write authority outlives the credential it authorizes.\n` +
214
- `Fix, either way: sign in directly with \`sf org login web --alias ${targetOrg} --instance-url <their org url>\` (one clean attempt — rapid retries trip Salesforce's integration-user auto-freeze), or re-send the requirements from Campfire and redeem a fresh code with activate_session.\n` +
215
- `Refusing fail-closed: nothing was deployed, so there is nothing to undo.`);
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
+ }
@@ -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
- 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, then stop. 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.");
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) {
@@ -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. Re-authenticate it, then retry. Refusing fail-closed.`);
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 yourself
63
- when a session dies: CARD them with the fix, then \`close_batch\`, then stop — in that order.
64
- \`close_batch\` makes no org round-trip, so it still works with every token on the machine dead, and
65
- a run you walk away from instead reads as ABANDONED with no record that it ever ended.
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,