@codeyam-editor/codeyam-editor 0.1.5 → 0.1.6
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/npm/cli.js +5 -1
- package/npm/cloud.js +1884 -210
- package/npm/codesign-plan.js +68 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +114 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +182 -6
- package/npm/scenario-check.js +282 -10
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +286 -12
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-DgM0pTAx.js} +1 -1
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-DCHqRrwO.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
package/npm/fleet-vm-access.js
CHANGED
|
@@ -105,6 +105,132 @@ function normalizeAccessList(input) {
|
|
|
105
105
|
return out;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// The env/config key holding the standing default-operator principal(s) — the
|
|
109
|
+
// fleet owner / ops identity that must ALWAYS be able to reach an IAP-secured VM.
|
|
110
|
+
// Comma-separated `user:`/`group:`/`domain:` principals (a bare email defaults to
|
|
111
|
+
// `user:`), e.g. `VM_ACCESS_DEFAULT=user:jared@codeyam.com`. Read from config, not
|
|
112
|
+
// hardcoded here, so the default operator is tenant-configurable.
|
|
113
|
+
const DEFAULT_OPERATOR_ENV = 'VM_ACCESS_DEFAULT';
|
|
114
|
+
|
|
115
|
+
// The default gcloud-account resolver, split out so it is the ONLY function that
|
|
116
|
+
// touches child_process — the module stays side-effect-free at load, and
|
|
117
|
+
// resolveDefaultOperator takes an injectable `exec` so its fallback is unit
|
|
118
|
+
// testable without a real gcloud. Returns the active account email, or '' when
|
|
119
|
+
// gcloud is absent / errors (never throws to the caller).
|
|
120
|
+
function defaultGcloudAccountExec() {
|
|
121
|
+
const { spawnSync } = require('child_process');
|
|
122
|
+
const r = spawnSync('gcloud', ['config', 'get-value', 'account'], { encoding: 'utf8' });
|
|
123
|
+
if (!r || r.status !== 0) return '';
|
|
124
|
+
return String(r.stdout || '').trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Read the standing default-operator principal(s) from `.codeyam/cloud.json`'s
|
|
128
|
+
// `vmAccessDefault` key — the durable per-project config that resolves the default
|
|
129
|
+
// operator even when a launchd/systemd env was never re-baked with
|
|
130
|
+
// `VM_ACCESS_DEFAULT`. Returns the raw config value ('' when the file is missing or
|
|
131
|
+
// malformed, or the key is absent / not a string) so a broken cloud.json is treated
|
|
132
|
+
// as "no config" and never throws. Split out (like defaultGcloudAccountExec) so the
|
|
133
|
+
// cloud.json layer of resolveDefaultOperator is injectable/unit-testable without a
|
|
134
|
+
// real file. `.codeyam/cloud.json` is resolved via `rootDir` — the same repo root
|
|
135
|
+
// the access sidecar and other state files use. `cloudJsonPath` is a parameter
|
|
136
|
+
// (defaulting to that resolved path) so the reader is unit-testable against a temp
|
|
137
|
+
// file, the same injectable-for-testability posture as defaultGcloudAccountExec.
|
|
138
|
+
// The path also honors `VM_ACCESS_CLOUD_JSON` — the same env-override isolation
|
|
139
|
+
// posture as `VM_ACCESS_FILE` above — so a test process (or an operator) can point
|
|
140
|
+
// this read at a throwaway path and never pick up the developer's real
|
|
141
|
+
// `.codeyam/cloud.json` `vmAccessDefault`.
|
|
142
|
+
function defaultCloudConfigRead(
|
|
143
|
+
cloudJsonPath = process.env.VM_ACCESS_CLOUD_JSON || path.join(rootDir, '.codeyam', 'cloud.json'),
|
|
144
|
+
) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = fs.readFileSync(cloudJsonPath, 'utf8');
|
|
147
|
+
const data = JSON.parse(raw);
|
|
148
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
149
|
+
return typeof data.vmAccessDefault === 'string' ? data.vmAccessDefault : '';
|
|
150
|
+
}
|
|
151
|
+
} catch { /* missing or malformed cloud.json → no config */ }
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolve the standing default-operator principal(s) that must be granted IAP
|
|
156
|
+
* access whenever a VM's IAP is enabled — the fix for "IAP enabled but nobody
|
|
157
|
+
* granted" (the Error-51-by-default footgun). Config first, most-specific to
|
|
158
|
+
* least:
|
|
159
|
+
* 1. `VM_ACCESS_DEFAULT` (the env knob) — normalized and returned.
|
|
160
|
+
* 2. `.codeyam/cloud.json`'s `vmAccessDefault` — the DURABLE per-project config,
|
|
161
|
+
* consulted when the env var is unset. This closes the gap that made every
|
|
162
|
+
* fleet Add grant nobody: the dashboard/Add runs as the automation SERVICE
|
|
163
|
+
* ACCOUNT, and with no `VM_ACCESS_DEFAULT` in the (un-re-baked) launchd env
|
|
164
|
+
* the resolver used to fall straight through to the gcloud fallback and skip
|
|
165
|
+
* the SA — resolving `[]`. Reading cloud.json here means every caller
|
|
166
|
+
* (iap-access.sh, cloud.js, the dashboard) inherits the default operator
|
|
167
|
+
* without an env re-bake.
|
|
168
|
+
* 3. Fallback: the active gcloud account, so a HUMAN operator running these
|
|
169
|
+
* scripts locally gets access with no extra config. A SERVICE-ACCOUNT
|
|
170
|
+
* session (the automation SA, whose account ends in `gserviceaccount.com`)
|
|
171
|
+
* is skipped — granting the automation SA to itself is a pointless no-op,
|
|
172
|
+
* and the human/ops identity it should grant is unknowable from the SA
|
|
173
|
+
* session, so config (env or cloud.json) is REQUIRED there.
|
|
174
|
+
* Returns a normalized principal array (possibly empty — callers treat empty as
|
|
175
|
+
* "no default operator configured" and keep their prior behavior). `exec` and
|
|
176
|
+
* `readCloudConfig` are injectable so the gcloud and cloud.json fallbacks are
|
|
177
|
+
* unit-testable without a real gcloud / file. */
|
|
178
|
+
function resolveDefaultOperator({ env = process.env, exec, readCloudConfig } = {}) {
|
|
179
|
+
const configured = env[DEFAULT_OPERATOR_ENV];
|
|
180
|
+
if (configured != null && String(configured).trim() !== '') {
|
|
181
|
+
try { return normalizeAccessList(configured); } catch { return []; }
|
|
182
|
+
}
|
|
183
|
+
let fromCloud = '';
|
|
184
|
+
try { fromCloud = String((readCloudConfig || defaultCloudConfigRead)() || '').trim(); }
|
|
185
|
+
catch { fromCloud = ''; }
|
|
186
|
+
if (fromCloud !== '') {
|
|
187
|
+
try { return normalizeAccessList(fromCloud); } catch { return []; }
|
|
188
|
+
}
|
|
189
|
+
let account = '';
|
|
190
|
+
try { account = String((exec || defaultGcloudAccountExec)() || '').trim(); }
|
|
191
|
+
catch { account = ''; }
|
|
192
|
+
if (account === '' || /gserviceaccount\.com$/i.test(account)) return [];
|
|
193
|
+
try { return normalizeAccessList(account); } catch { return []; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The loud degraded reason(s) to attach to a freshly-added VM whose IAP would
|
|
197
|
+
* authorize NOBODY. Fires only for a SUCCESSFUL `add` (`ok`) in LB mode (`lbMode`
|
|
198
|
+
* — a FLEET_DOMAIN is configured so the VM's public URL is IAP-gated) when the
|
|
199
|
+
* default operator resolved empty (`resolvedOperatorCount === 0`): the automation-
|
|
200
|
+
* SA identity with no `VM_ACCESS_DEFAULT` env AND no `.codeyam/cloud.json`
|
|
201
|
+
* `vmAccessDefault` fallback (the 2026-07-09 footgun). In that case the URL comes
|
|
202
|
+
* up authorizing nobody yet the add reads as "done" — so we surface the exact
|
|
203
|
+
* remedy on the card instead of a silently gated URL. `resolvedOperatorCount` is
|
|
204
|
+
* `null` when the caller skipped the (gcloud) resolve because the cheap gates
|
|
205
|
+
* already failed; the `!== 0` test covers both null and a positive count. Returns a
|
|
206
|
+
* single actionable reason or [] so the dashboard can spread it into
|
|
207
|
+
* `degradedReasons` with no conditional at the call site. Pure (no I/O): the string
|
|
208
|
+
* lives in exactly one place and the trigger is unit-testable. */
|
|
209
|
+
function noDefaultOperatorDegradedReasons({ ok, action, lbMode, resolvedOperatorCount, vmNumber } = {}) {
|
|
210
|
+
if (!ok || action !== 'add' || !lbMode || resolvedOperatorCount !== 0) return [];
|
|
211
|
+
return [
|
|
212
|
+
'iap-default-operator-missing: IAP default operator not configured — set '
|
|
213
|
+
+ 'VM_ACCESS_DEFAULT or cloud.json vmAccessDefault (the public URL will '
|
|
214
|
+
+ `authorize nobody; run scripts/gcp/iap-access.sh ${vmNumber} grant after configuring).`,
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Union the default-operator principal(s) into an access list, deduped and
|
|
219
|
+
* order-preserving (explicit collaborators first, the operator appended if not
|
|
220
|
+
* already present). Both inputs are normalized. The result is what gets GRANTED;
|
|
221
|
+
* the per-VM sidecar still records only the explicit collaborators, so the
|
|
222
|
+
* always-present operator never pollutes the per-VM whitelist display or a
|
|
223
|
+
* reused number's stale-revoke computation. */
|
|
224
|
+
function withDefaultOperator(list, defaultOperator) {
|
|
225
|
+
const base = normalizeAccessList(list);
|
|
226
|
+
const seen = new Set(base);
|
|
227
|
+
const out = base.slice();
|
|
228
|
+
for (const p of normalizeAccessList(defaultOperator)) {
|
|
229
|
+
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
108
234
|
// Read the access sidecar, returning a `{ [vmNumber]: principal[] }` object, or
|
|
109
235
|
// null if the file is missing or malformed (treated as "no access lists"). Keys
|
|
110
236
|
// are the string form JSON emits; callers look them up by `String(n)`.
|
|
@@ -208,6 +334,44 @@ function parseProjectMembers(stdout) {
|
|
|
208
334
|
return out;
|
|
209
335
|
}
|
|
210
336
|
|
|
337
|
+
/** Reconcile a VM's LOCAL access sidecar against LIVE IAM. PURE — the caller
|
|
338
|
+
* does the two reads (the sidecar file, the gcloud IAP probe) and hands both in,
|
|
339
|
+
* so this decision logic is unit-testable without the fleet dashboard's
|
|
340
|
+
* untestable server shell around it.
|
|
341
|
+
*
|
|
342
|
+
* `sidecar` is the recorded principal list (what someone asked to grant);
|
|
343
|
+
* `iapMembers` is the aggregated live-IAM result from
|
|
344
|
+
* fleet-public-url.js::aggregateIapMembers, or null/undefined when that read has
|
|
345
|
+
* not landed or a surface errored. Returns:
|
|
346
|
+
*
|
|
347
|
+
* live — the principals IAM actually authorizes. The display truth.
|
|
348
|
+
* drift — sidecar principals live IAM does NOT confirm. These are failed
|
|
349
|
+
* grants; a caller renders them flagged, never as access.
|
|
350
|
+
* pending — the live read is unavailable, so NEITHER list is verified.
|
|
351
|
+
* partial — a principal holds the role on some ingress surfaces but not all.
|
|
352
|
+
*
|
|
353
|
+
* The pending distinction is the whole point. Treating an unavailable read as an
|
|
354
|
+
* empty policy would render a genuinely-granted collaborator as missing — the
|
|
355
|
+
* same false report as the bug this replaces, merely inverted. So when
|
|
356
|
+
* `iapMembers` is absent, `live` is empty, every sidecar principal lands in
|
|
357
|
+
* `drift`, and `pending` is true to tell the caller not to assert either way.
|
|
358
|
+
*
|
|
359
|
+
* Comparison is case-insensitive: parseProjectMembers lowercases for
|
|
360
|
+
* dedup-stability while the sidecar holds whatever normalizePrincipal produced. */
|
|
361
|
+
function reconcileVmAccess(sidecar, iapMembers) {
|
|
362
|
+
const recorded = Array.isArray(sidecar) ? sidecar : [];
|
|
363
|
+
if (!iapMembers || !Array.isArray(iapMembers.all)) {
|
|
364
|
+
return { live: [], drift: recorded, pending: true, partial: false };
|
|
365
|
+
}
|
|
366
|
+
const live = new Set(iapMembers.all.map((m) => String(m).toLowerCase()));
|
|
367
|
+
return {
|
|
368
|
+
live: iapMembers.all,
|
|
369
|
+
drift: recorded.filter((p) => !live.has(String(p).toLowerCase())),
|
|
370
|
+
pending: false,
|
|
371
|
+
partial: iapMembers.partial === true,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
211
375
|
/** Drop VM `n`'s entire access list, returning the new map. Clearing an absent
|
|
212
376
|
* entry is a no-op. Called on Destroy so a number freed and later re-used
|
|
213
377
|
* doesn't inherit a stale whitelist. */
|
|
@@ -221,9 +385,15 @@ function clearAccess(n) {
|
|
|
221
385
|
module.exports = {
|
|
222
386
|
VM_ACCESS_FILE,
|
|
223
387
|
PRINCIPAL_TYPES,
|
|
388
|
+
DEFAULT_OPERATOR_ENV,
|
|
224
389
|
normalizePrincipal,
|
|
225
390
|
normalizeAccessList,
|
|
391
|
+
resolveDefaultOperator,
|
|
392
|
+
defaultCloudConfigRead,
|
|
393
|
+
noDefaultOperatorDegradedReasons,
|
|
394
|
+
withDefaultOperator,
|
|
226
395
|
parseProjectMembers,
|
|
396
|
+
reconcileVmAccess,
|
|
227
397
|
readAccess,
|
|
228
398
|
writeAccess,
|
|
229
399
|
accessFor,
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Where the fleet dashboard sends a per-VM editor-API probe, and how to read
|
|
3
|
+
// the answer.
|
|
4
|
+
//
|
|
5
|
+
// The dashboard gets ALL per-VM live data — reachability, feature, step,
|
|
6
|
+
// selectedPlan, credential health, preview — by probing each VM's editor API.
|
|
7
|
+
// On a laptop that probe goes to `127.0.0.1:PORT_BASE+N`, which is fed by an SSH
|
|
8
|
+
// `-L` forward the vm-watchdog creates. On the GCE operator host those forwards
|
|
9
|
+
// cannot exist: the watchdog runs as root, the fleet VMs use metadata SSH keys
|
|
10
|
+
// (OS Login is OFF) and refuse root login, so `gcloud compute ssh` fails
|
|
11
|
+
// `Permission denied (publickey)`. No forwards ⇒ dead local ports ⇒ every card
|
|
12
|
+
// reads "unreachable" with no progress.
|
|
13
|
+
//
|
|
14
|
+
// On GCE we therefore probe each VM through the LB hostname it is already wired
|
|
15
|
+
// to (`https://vm-<N>.editor.<domain>`), authenticated with an IAP identity
|
|
16
|
+
// token minted by the operator service account. Same paths, same downstream
|
|
17
|
+
// parsing — only the transport changes.
|
|
18
|
+
//
|
|
19
|
+
// Pure logic only — no I/O, no env reads, no clock — so it stays unit-testable
|
|
20
|
+
// in isolation (npm/fleet-vm-probe-target.test.js), exactly like vm-urls.js.
|
|
21
|
+
|
|
22
|
+
// Loopback host for the laptop probe. The SSH `-L` forward binds here, so the
|
|
23
|
+
// probe must too — 'localhost' could resolve to ::1 and miss an IPv4-only bind.
|
|
24
|
+
const LOCAL_HOST = '127.0.0.1';
|
|
25
|
+
|
|
26
|
+
/** Resolve where a per-VM editor-API probe should be sent.
|
|
27
|
+
*
|
|
28
|
+
* Returns a descriptor the caller dispatches on:
|
|
29
|
+
* { mode: 'lb', url, headers, authMissing } — GCE: absolute LB URL
|
|
30
|
+
* { mode: 'local', host, port, path, headers } — laptop: forwarded port
|
|
31
|
+
*
|
|
32
|
+
* `authMissing` is true when a GCE probe has no IAP token to attach. The caller
|
|
33
|
+
* must NOT treat the resulting response as reachable — an unauthenticated
|
|
34
|
+
* request to an IAP-fronted backend gets a 302 to the Google login page, which
|
|
35
|
+
* is "auth misconfigured", not "the VM is up". See classifyProbeResponse.
|
|
36
|
+
*
|
|
37
|
+
* `sessionToken` (optional): the fail-closed editor requires each VM's own
|
|
38
|
+
* editor session token in `Authorization` on every gated control-API request.
|
|
39
|
+
* When one is supplied, it rides in `Authorization` and the IAP token moves to
|
|
40
|
+
* `Proxy-Authorization` (IAP honors it there), so both credentials are present
|
|
41
|
+
* without colliding on `Authorization`. When it is absent, header shape is
|
|
42
|
+
* byte-for-byte today's behavior (IAP in `Authorization`), keeping the open
|
|
43
|
+
* `/api/session-info` reachability probe unchanged.
|
|
44
|
+
*/
|
|
45
|
+
function resolveVmProbeTarget({ onGce, url, n, portBase, path, iapToken, sessionToken } = {}) {
|
|
46
|
+
const probePath = path == null ? '/' : String(path);
|
|
47
|
+
if (!probePath.startsWith('/')) {
|
|
48
|
+
throw new Error(`probe path must start with "/", got: ${probePath}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!onGce) {
|
|
52
|
+
const base = Number(portBase);
|
|
53
|
+
const num = Number(n);
|
|
54
|
+
if (!Number.isInteger(base) || !Number.isInteger(num)) {
|
|
55
|
+
throw new Error('local probe requires integer portBase and n');
|
|
56
|
+
}
|
|
57
|
+
return { mode: 'local', host: LOCAL_HOST, port: base + num, path: probePath, headers: {} };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// GCE: the LB URL is the ONLY way in. A VM with no derived URL (legacy
|
|
61
|
+
// quick-tunnel mode, or a roster entry the URL derivation skipped) has no
|
|
62
|
+
// reachable address on this host, so fail loud rather than silently falling
|
|
63
|
+
// back to a local port that is guaranteed dead.
|
|
64
|
+
const origin = url == null ? '' : String(url).trim().replace(/\/+$/, '');
|
|
65
|
+
if (origin === '') {
|
|
66
|
+
throw new Error(`VM-${n} has no LB URL to probe (FLEET_DOMAIN not configured?)`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const token = iapToken == null ? '' : String(iapToken).trim();
|
|
70
|
+
const session = sessionToken == null ? '' : String(sessionToken).trim();
|
|
71
|
+
// With a session token the fail-closed editor's own credential owns
|
|
72
|
+
// `Authorization`; IAP still fronts the LB, so its token moves to
|
|
73
|
+
// `Proxy-Authorization`. Without one, keep exactly today's IAP-in-
|
|
74
|
+
// `Authorization` shape. `authMissing` stays keyed on the IAP token alone —
|
|
75
|
+
// IAP is still required to reach the LB even when a session token is present.
|
|
76
|
+
let headers;
|
|
77
|
+
if (session !== '') {
|
|
78
|
+
headers = { Authorization: `Bearer ${session}` };
|
|
79
|
+
if (token !== '') headers['Proxy-Authorization'] = `Bearer ${token}`;
|
|
80
|
+
} else {
|
|
81
|
+
headers = token === '' ? {} : { Authorization: `Bearer ${token}` };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
mode: 'lb',
|
|
85
|
+
url: origin + probePath,
|
|
86
|
+
headers,
|
|
87
|
+
authMissing: token === '',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The one open control-API endpoint: `/api/session-info` answers WITHOUT the
|
|
92
|
+
// editor's session token — it is the reachability probe. Every other control-API
|
|
93
|
+
// path is gated under fail-closed auth, so the dashboard must attach the VM's
|
|
94
|
+
// session token or get a 401. Kept here (pure, tested) rather than inline in the
|
|
95
|
+
// caller so the open-vs-gated boundary has one authoritative home.
|
|
96
|
+
const OPEN_PROBE_PATHS = new Set(['/api/session-info']);
|
|
97
|
+
|
|
98
|
+
/** True when a probe path needs the VM's editor session token attached.
|
|
99
|
+
*
|
|
100
|
+
* Only the open reachability probe (`/api/session-info`) is exempt; every other
|
|
101
|
+
* control-API path is gated under fail-closed auth. A query string is ignored so
|
|
102
|
+
* `/api/session-info?foo` still reads as the open path. */
|
|
103
|
+
function pathNeedsSessionToken(apiPath) {
|
|
104
|
+
const p = (apiPath == null ? '' : String(apiPath)).split('?')[0];
|
|
105
|
+
return p !== '' && !OPEN_PROBE_PATHS.has(p);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Hostname IAP redirects an unauthenticated request to. Matching this is what
|
|
109
|
+
// separates "the token is missing/expired" from "the VM is genuinely down" —
|
|
110
|
+
// both would otherwise read as a non-200 and blank the card identically.
|
|
111
|
+
const IAP_LOGIN_HOST = 'accounts.google.com';
|
|
112
|
+
|
|
113
|
+
/** Classify a probe response into the three outcomes the dashboard cares about.
|
|
114
|
+
*
|
|
115
|
+
* 'ok' — 200; caller still has to parse the body.
|
|
116
|
+
* 'auth-misconfigured' — IAP rejected or challenged us (302→login, 401, 403).
|
|
117
|
+
* The VM may be perfectly healthy; OUR credential is
|
|
118
|
+
* the problem, so this must never render as reachable
|
|
119
|
+
* and must never feed a reset decision.
|
|
120
|
+
* 'unreachable' — anything else: the VM/LB backend is not serving.
|
|
121
|
+
*/
|
|
122
|
+
function classifyProbeResponse({ status, location } = {}) {
|
|
123
|
+
const code = Number(status);
|
|
124
|
+
if (code === 200) return 'ok';
|
|
125
|
+
if (code === 401 || code === 403) return 'auth-misconfigured';
|
|
126
|
+
if (code >= 300 && code < 400) {
|
|
127
|
+
const to = location == null ? '' : String(location);
|
|
128
|
+
return to.includes(IAP_LOGIN_HOST) ? 'auth-misconfigured' : 'unreachable';
|
|
129
|
+
}
|
|
130
|
+
return 'unreachable';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Name the LIKELY cause behind an 'auth-misconfigured' probe, for logs only.
|
|
134
|
+
*
|
|
135
|
+
* 401 and 403 both mean "our credential is the problem", so they deliberately
|
|
136
|
+
* share one outcome — but they have opposite fixes, and telling them apart
|
|
137
|
+
* previously cost a live `curl` session against the backend (2026-07-20):
|
|
138
|
+
*
|
|
139
|
+
* 401 — the fail-closed editor rejected our session token. IAP now rides in
|
|
140
|
+
* Proxy-Authorization, so a 401 from the backend means the VM's own
|
|
141
|
+
* editor session token in Authorization was missing, stale, or wrong —
|
|
142
|
+
* the dashboard invalidates its cached token and self-heals next poll.
|
|
143
|
+
* 403 — the token is valid but the caller is not authorized. The automation SA
|
|
144
|
+
* is missing roles/iap.httpsResourceAccessor on that backend service.
|
|
145
|
+
*
|
|
146
|
+
* Returns null for any other status, so callers can log unconditionally.
|
|
147
|
+
* Diagnostic only — this never feeds a reachability or reset decision. */
|
|
148
|
+
function describeAuthMisconfig(status) {
|
|
149
|
+
const code = Number(status);
|
|
150
|
+
if (code === 401) {
|
|
151
|
+
return "session token rejected — the fail-closed editor requires this VM's " +
|
|
152
|
+
'own editor session token in Authorization; the dashboard fetch was ' +
|
|
153
|
+
'missing/stale (it invalidates the cached token and re-fetches next poll)';
|
|
154
|
+
}
|
|
155
|
+
if (code === 403) {
|
|
156
|
+
return 'missing accessor — the automation service account lacks ' +
|
|
157
|
+
'roles/iap.httpsResourceAccessor on this backend service ' +
|
|
158
|
+
'(re-grant: bash scripts/gcp/iap-access.sh <N> grant)';
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Map a probe outcome (+ optional transport reason) to the per-VM card shape
|
|
164
|
+
* the dashboard merges into `state.vms[n]`.
|
|
165
|
+
*
|
|
166
|
+
* The rule this encodes: an 'auth-misconfigured' outcome is NOT reachable, and
|
|
167
|
+
* stays distinguishable from a dead VM. Collapsing the two would either render
|
|
168
|
+
* a broken credential as a healthy VM, or let our own expired token look like N
|
|
169
|
+
* dead boxes and feed a reset decision. Anything unrecognized fails closed to
|
|
170
|
+
* unreachable rather than leaking a truthy `reachable`. */
|
|
171
|
+
function probeOutcomeToInfo(outcome, error) {
|
|
172
|
+
if (outcome === 'ok') return { reachable: true };
|
|
173
|
+
if (outcome === 'auth-misconfigured') {
|
|
174
|
+
return { reachable: false, error: 'auth-misconfigured', authMisconfigured: true };
|
|
175
|
+
}
|
|
176
|
+
return error ? { reachable: false, error } : { reachable: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Merge a parsed `/api/session-info` body into the per-VM card shape.
|
|
180
|
+
*
|
|
181
|
+
* The contract, in one place instead of inlined in the dashboard's probe:
|
|
182
|
+
* • an unreachable card never takes a body (a credential fault or a dead VM
|
|
183
|
+
* must not gain fields from whatever the transport happened to return);
|
|
184
|
+
* • a reachable card takes the body ONLY when it is a usable object.
|
|
185
|
+
*
|
|
186
|
+
* That second rule used to be accidental rather than stated. The expression was
|
|
187
|
+
* `info.reachable ? { ...info, ...json } : info`, and spreading `null` (or an
|
|
188
|
+
* array, or a scalar) is a silent no-op — so an unusable body produced a card
|
|
189
|
+
* that read reachable with `clientProject` and `editorIdentity` absent, which is
|
|
190
|
+
* indistinguishable from a VM that genuinely reports neither. The resulting card
|
|
191
|
+
* shape is deliberately UNCHANGED by making the rule explicit; what changes is
|
|
192
|
+
* that the caller can now see the fault (probeBodyFault) and log it instead of
|
|
193
|
+
* discovering it days later through a blank card. Pure. */
|
|
194
|
+
function mergeSessionInfoBody(info, json) {
|
|
195
|
+
if (!info || !info.reachable) return info;
|
|
196
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) return info;
|
|
197
|
+
return { ...info, ...json };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Name the fault when a probe SUCCEEDED at the transport layer but its body is
|
|
201
|
+
* not a usable object, else null.
|
|
202
|
+
*
|
|
203
|
+
* This is the hole that cost a full diagnostic session on 2026-07-29. The card
|
|
204
|
+
* merge is `info.reachable ? { ...info, ...json } : info`, and spreading `null`
|
|
205
|
+
* (or an array, or a JSON scalar) is a silent no-op — so a VM answering 200 with
|
|
206
|
+
* an unusable body produced a card that read REACHABLE with every session-info
|
|
207
|
+
* field absent (`clientProject` and `editorIdentity` null), and emitted no log
|
|
208
|
+
* line at all. `stickyClientProject` then hid it for as long as a previously
|
|
209
|
+
* polled value survived in memory, so the fault only surfaced after a dashboard
|
|
210
|
+
* restart cleared the sticky cache.
|
|
211
|
+
*
|
|
212
|
+
* A 'bad-json' transport error is the sibling case: the body was unparseable, so
|
|
213
|
+
* the probe already fails closed to unreachable — correct, but equally silent.
|
|
214
|
+
* Callers pass that through here too so both paths get one loud warning.
|
|
215
|
+
*
|
|
216
|
+
* Returns a short, log-safe fault name:
|
|
217
|
+
* 'bad-json' — body did not parse (caller passes error === 'bad-json')
|
|
218
|
+
* 'null-body' — parsed to JSON null
|
|
219
|
+
* 'array-body' — parsed to an array
|
|
220
|
+
* '<type>-body' — parsed to a scalar (number/string/boolean)
|
|
221
|
+
* and null when the body is a usable object (the normal case) or the outcome was
|
|
222
|
+
* never 'ok' (already classified by probeOutcomeToInfo). Pure. */
|
|
223
|
+
function probeBodyFault(outcome, json, error) {
|
|
224
|
+
if (error === 'bad-json') return 'bad-json';
|
|
225
|
+
if (outcome !== 'ok') return null;
|
|
226
|
+
if (json === null || json === undefined) return 'null-body';
|
|
227
|
+
if (Array.isArray(json)) return 'array-body';
|
|
228
|
+
if (typeof json !== 'object') return `${typeof json}-body`;
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The log line for a probe-body fault, naming the VM, the fault and the HTTP
|
|
233
|
+
* status — the three things that were missing when this failure mode cost a
|
|
234
|
+
* full diagnostic session with zero log output to work from.
|
|
235
|
+
*
|
|
236
|
+
* A missing status renders as `?` rather than `undefined`: the transport error
|
|
237
|
+
* paths (timeout, socket error) never saw a response, and `HTTP undefined` reads
|
|
238
|
+
* like a bug in the logger instead of an absent status.
|
|
239
|
+
*
|
|
240
|
+
* Diagnostic only — this never feeds a reachability decision, same contract as
|
|
241
|
+
* its sibling describeAuthMisconfig. Pure. */
|
|
242
|
+
function describeProbeBodyFault(n, fault, status) {
|
|
243
|
+
const code = status == null || status === '' ? '?' : String(status);
|
|
244
|
+
return `probe VM-${n} /api/session-info: ${fault} (HTTP ${code}) — ` +
|
|
245
|
+
'card identity (clientProject/editorIdentity) absent this poll';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Per-VM, per-fault throttle for probe-fault warnings.
|
|
249
|
+
*
|
|
250
|
+
* The poll cycle runs every ~3s across the whole fleet, so warning on every
|
|
251
|
+
* faulted probe would bury the log. Throttling per (VM, fault) keeps a
|
|
252
|
+
* persistent fault to one line per interval while still surfacing a NEW fault on
|
|
253
|
+
* a different VM immediately.
|
|
254
|
+
*
|
|
255
|
+
* Deterministic: the caller supplies `now`, so no clock is read here and the
|
|
256
|
+
* behavior is unit-testable. */
|
|
257
|
+
function createProbeFaultThrottle(intervalMs = 300000) {
|
|
258
|
+
const last = new Map();
|
|
259
|
+
return {
|
|
260
|
+
shouldWarn(n, fault, now) {
|
|
261
|
+
const key = `${n}:${fault}`;
|
|
262
|
+
const prev = last.get(key);
|
|
263
|
+
if (prev != null && now - prev < intervalMs) return false;
|
|
264
|
+
last.set(key, now);
|
|
265
|
+
return true;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
LOCAL_HOST,
|
|
272
|
+
IAP_LOGIN_HOST,
|
|
273
|
+
resolveVmProbeTarget,
|
|
274
|
+
pathNeedsSessionToken,
|
|
275
|
+
classifyProbeResponse,
|
|
276
|
+
describeAuthMisconfig,
|
|
277
|
+
probeOutcomeToInfo,
|
|
278
|
+
mergeSessionInfoBody,
|
|
279
|
+
probeBodyFault,
|
|
280
|
+
describeProbeBodyFault,
|
|
281
|
+
createProbeFaultThrottle,
|
|
282
|
+
};
|
|
@@ -36,6 +36,20 @@
|
|
|
36
36
|
// 'half-wired' — LB mode, the editor backend service is ABSENT (never
|
|
37
37
|
// created). The permanent 503 case — the public URL will fail
|
|
38
38
|
// until re-wired. The state that had NO signal before.
|
|
39
|
+
// 'iap-access-missing' — LB mode, the EDITOR backend service is IAP-enabled but
|
|
40
|
+
// has NO accessor binding, so IAP authorizes nobody: the public
|
|
41
|
+
// URL returns "Error code 51" for everyone even when the backend
|
|
42
|
+
// is healthy. Distinct from 'half-wired' (there the backend is
|
|
43
|
+
// absent; here it exists and serves, but nobody's granted) — the
|
|
44
|
+
// exact "wired + IAP-enabled but 51s in the browser" gap.
|
|
45
|
+
// 'preview-iap-access-missing' — LB mode, the editor surface grants access but
|
|
46
|
+
// the dedicated PREVIEW backend has no accessor binding. The
|
|
47
|
+
// editor URL works, but the framed Live Preview (served from the
|
|
48
|
+
// separate preview host on client-routed stacks) is blank for
|
|
49
|
+
// every authorized user because nobody is granted THERE. A
|
|
50
|
+
// softer, non-URL-blocking warning than 'iap-access-missing'
|
|
51
|
+
// (the editor link is live) — the monitoring blind spot the
|
|
52
|
+
// editor-only IAP probe used to hide.
|
|
39
53
|
// 'starting' — LB mode, the editor backend exists but is UNHEALTHY (LB has
|
|
40
54
|
// not yet marked it HEALTHY). The transient 503 case; flips to
|
|
41
55
|
// 'ready' on its own once the health check passes.
|
|
@@ -48,15 +62,84 @@
|
|
|
48
62
|
// supplied): there is no LB to health-check, so the backend-health branch is
|
|
49
63
|
// skipped and readiness degrades to today's reachability/broker behavior — no
|
|
50
64
|
// regression, no gcloud calls.
|
|
65
|
+
// 'ws-dead' — reachable + LB-healthy, but the public WebSocket path is
|
|
66
|
+
// dead: the LB either refused the upgrade outright
|
|
67
|
+
// (publicWsHealth 'unwired') or the backend-service timeout is
|
|
68
|
+
// below the fleet minimum so the LB severs the socket on a
|
|
69
|
+
// fixed interval (the VM5 guillotine). The editor answers HTTP
|
|
70
|
+
// but the Build tab can't hold a terminal — NOT 'ready'.
|
|
71
|
+
// 'cli-missing' — the editor is up but the build-agent CLI isn't installed
|
|
72
|
+
// (`providerAuth.cliInstalled === false`): a provisioning
|
|
73
|
+
// fault, not a usable VM.
|
|
74
|
+
// 'auth-unsettled' — the auth probe is wedged at `unknown` (timing out — the
|
|
75
|
+
// exact VM5 auth shape): we can't even tell if login is
|
|
76
|
+
// needed. A provisioning fault distinct from a clean
|
|
77
|
+
// needs-login.
|
|
78
|
+
// 'needs-login' — CLI installed and auth SETTLED to `needs-login`: a
|
|
79
|
+
// legitimate, expected state (the user must authenticate),
|
|
80
|
+
// surfaced with the Authenticate affordance — NOT a fault and
|
|
81
|
+
// NOT a provisioning fault that holds rostering.
|
|
51
82
|
const READINESS_STATES = [
|
|
52
83
|
'provisioning',
|
|
53
84
|
'unreachable',
|
|
54
85
|
'half-wired',
|
|
86
|
+
'iap-access-missing',
|
|
87
|
+
'preview-iap-access-missing',
|
|
55
88
|
'starting',
|
|
56
89
|
'broker-down',
|
|
90
|
+
'ws-dead',
|
|
91
|
+
'cli-missing',
|
|
92
|
+
'auth-unsettled',
|
|
93
|
+
'needs-login',
|
|
57
94
|
'ready',
|
|
58
95
|
];
|
|
59
96
|
|
|
97
|
+
// The WS-timeout floor classifier lives next to classifyPublicUrlHealth (the
|
|
98
|
+
// public-URL probe is one concern); readiness consumes its verdict.
|
|
99
|
+
const fpub = require('./fleet-public-url');
|
|
100
|
+
|
|
101
|
+
// Classify the build-agent usability axis from the editor's own `providerAuth`
|
|
102
|
+
// subtree (vm: Readiness Verifies Real Usability). control-api emits the SAME
|
|
103
|
+
// `providerAuth` (with `cliInstalled` + settled `state`) on BOTH /api/config and
|
|
104
|
+
// /api/session-info via build_provider_auth_json, so the dashboard reads it
|
|
105
|
+
// straight off the gathered VM record — no re-derivation. Returns one verdict:
|
|
106
|
+
// 'cli-missing' — `cliInstalled === false`: a provisioning fault.
|
|
107
|
+
// 'auth-unsettled' — CLI installed but `state === 'unknown'` (probe wedged /
|
|
108
|
+
// timing out — the VM5 shape): a provisioning fault.
|
|
109
|
+
// 'needs-login' — CLI installed, `state === 'needs-login'`: a LEGITIMATE,
|
|
110
|
+
// actionable state, not a fault.
|
|
111
|
+
// 'ready' — CLI installed, `state === 'authenticated'`: usable.
|
|
112
|
+
// 'unknown' — providerAuth absent (older editor / probe never landed),
|
|
113
|
+
// or any other shape: absence of evidence, never a fault.
|
|
114
|
+
// Only an EXPLICIT `cliInstalled === false` / `state === 'unknown'` is a fault;
|
|
115
|
+
// missing fields stay 'unknown' so an older editor never reads as broken.
|
|
116
|
+
function classifyAgentUsability(providerAuth) {
|
|
117
|
+
if (!providerAuth || typeof providerAuth !== 'object') return 'unknown';
|
|
118
|
+
if (providerAuth.cliInstalled === false) return 'cli-missing';
|
|
119
|
+
if (providerAuth.cliInstalled !== true) return 'unknown';
|
|
120
|
+
if (providerAuth.state === 'unknown') return 'auth-unsettled';
|
|
121
|
+
if (providerAuth.state === 'needs-login') return 'needs-login';
|
|
122
|
+
if (providerAuth.state === 'authenticated') return 'ready';
|
|
123
|
+
return 'unknown';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Map the agent-usability verdict to the tri-state the rostering gate
|
|
127
|
+
// (fleet-cleanup's reapVerdict) consumes, mirroring `projectWired`'s
|
|
128
|
+
// true|false|null contract so a flaky probe can never strand a healthy VM:
|
|
129
|
+
// false — a PROVISIONING FAULT ('cli-missing' / 'auth-unsettled'): the box is
|
|
130
|
+
// not usable, so don't roster it as healthy (the VM5 false-green).
|
|
131
|
+
// true — usable enough to roster: 'ready', or a legitimate 'needs-login'
|
|
132
|
+
// (the user must auth, but that's an expected state, not a
|
|
133
|
+
// half-provisioned box that should be held back).
|
|
134
|
+
// null — 'unknown' (no providerAuth / probe couldn't decide): absence of
|
|
135
|
+
// evidence must NEVER block rostering, exactly like reapVerdict's null
|
|
136
|
+
// tolerance for projectWired.
|
|
137
|
+
function agentRosterReady(verdict) {
|
|
138
|
+
if (verdict === 'cli-missing' || verdict === 'auth-unsettled') return false;
|
|
139
|
+
if (verdict === 'ready' || verdict === 'needs-login') return true;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
60
143
|
// A VM mid-provision: an Add (incl. its recovering re-run) that is queued
|
|
61
144
|
// (number reserved, cloud:up not started) or running. Its public URL has no
|
|
62
145
|
// meaningful readiness yet, so this takes precedence over every other signal —
|
|
@@ -94,11 +177,61 @@ function deriveVmReadiness(v, ctx) {
|
|
|
94
177
|
// 'healthy' falls through to ready; 'unknown' (get-health errored) is
|
|
95
178
|
// absence-of-evidence — never rendered as a problem, falls through too.
|
|
96
179
|
}
|
|
180
|
+
// 3b. IAP access: the backend is IAP-enabled but has NO accessor binding, so
|
|
181
|
+
// IAP authorizes nobody and the public URL returns "Error code 51" for
|
|
182
|
+
// everyone — even when the backend is healthy. A wiring-class fault a plain
|
|
183
|
+
// GET / backend-health probe are both blind to. Only an EXPLICIT
|
|
184
|
+
// 'no-accessor' verdict fires; absence/'unknown' is never a fault (an older
|
|
185
|
+
// dashboard that never gathered it must not read as broken). Gated on
|
|
186
|
+
// lbMode — legacy quick-tunnel mode has no per-BES IAP to check.
|
|
187
|
+
if (c.lbMode && v.iapAccess === 'no-accessor') return 'iap-access-missing';
|
|
97
188
|
// 4. Reachable but the broker daemon is down: the VM can't start a session.
|
|
98
189
|
if (c.brokerStatus === 'down') return 'broker-down';
|
|
99
|
-
// 5.
|
|
100
|
-
//
|
|
190
|
+
// 5. WS-usability (LB mode): the editor answers HTTP and the backend is
|
|
191
|
+
// LB-healthy, but the public WebSocket path is dead — either the LB refused
|
|
192
|
+
// the upgrade outright (publicWsHealth 'unwired') or the backend-service
|
|
193
|
+
// timeout is below the fleet minimum so the LB severs the socket on a fixed
|
|
194
|
+
// interval (the VM5 guillotine). Either way the Build tab can't hold a
|
|
195
|
+
// terminal, so this is NOT 'ready'. Skipped in legacy mode (no LB). An
|
|
196
|
+
// 'unknown'/absent WS verdict is absence-of-evidence — never a fault.
|
|
197
|
+
if (c.lbMode) {
|
|
198
|
+
// Deterministic guillotine first: a BES timeout below the fleet minimum
|
|
199
|
+
// severs the socket on a fixed interval — trustworthy, no probe needed.
|
|
200
|
+
if (fpub.classifyWsTimeout(v.backendTimeoutSec) === 'ws-timeout-too-low') return 'ws-dead';
|
|
201
|
+
// The public WS probe is IAP-blind and false-positives `unwired` on a
|
|
202
|
+
// genuinely-healthy VM (VM-1, 2026-07-01: wired + HEALTHY + IAP-granted,
|
|
203
|
+
// yet `publicWsHealth: unwired` → phantom ws-dead → URL gated "not ready").
|
|
204
|
+
// Only trust an `unwired` verdict when the backend is NOT already HEALTHY —
|
|
205
|
+
// a HEALTHY backend with a valid/unknown timeout is never ws-dead.
|
|
206
|
+
if (v.publicWsHealth === 'unwired' && !(bh && bh.editor === 'healthy')) return 'ws-dead';
|
|
207
|
+
}
|
|
208
|
+
// 6. Agent usability: the box is up and the WS path carries frames, but the
|
|
209
|
+
// build agent itself may not be usable — CLI not installed, or the auth
|
|
210
|
+
// probe wedged at 'unknown'. A legitimate 'needs-login' surfaces as its own
|
|
211
|
+
// actionable state (Authenticate), distinct from a provisioning fault.
|
|
212
|
+
// Absence of providerAuth (older editor / unprobed) falls through to ready.
|
|
213
|
+
const usability = classifyAgentUsability(v.providerAuth);
|
|
214
|
+
if (usability === 'cli-missing') return 'cli-missing';
|
|
215
|
+
if (usability === 'auth-unsettled') return 'auth-unsettled';
|
|
216
|
+
if (usability === 'needs-login') return 'needs-login';
|
|
217
|
+
// 6b. Preview-host IAP gap: the editor surface grants access (the URL routes
|
|
218
|
+
// and the box is otherwise ready) but the dedicated preview backend has no
|
|
219
|
+
// accessor, so the framed Live Preview is blank for authorized users. A
|
|
220
|
+
// softer warning than the editor 'iap-access-missing' — it does NOT block
|
|
221
|
+
// the editor link (see URL_BLOCKED_READINESS) — so it is checked last, just
|
|
222
|
+
// before 'ready'. Only an EXPLICIT 'preview-no-accessor' verdict fires;
|
|
223
|
+
// absence/'unknown' is never a fault. Gated on lbMode like its editor twin.
|
|
224
|
+
if (c.lbMode && v.iapAccess === 'preview-no-accessor') return 'preview-iap-access-missing';
|
|
225
|
+
// 7. Reachable, broker up, WS carries frames, agent usable, and (LB mode) the
|
|
226
|
+
// editor backend is HEALTHY — genuinely ready: green, clickable, the public
|
|
227
|
+
// URL routes AND holds a WebSocket.
|
|
101
228
|
return 'ready';
|
|
102
229
|
}
|
|
103
230
|
|
|
104
|
-
module.exports = {
|
|
231
|
+
module.exports = {
|
|
232
|
+
deriveVmReadiness,
|
|
233
|
+
READINESS_STATES,
|
|
234
|
+
isProvisioning,
|
|
235
|
+
classifyAgentUsability,
|
|
236
|
+
agentRosterReady,
|
|
237
|
+
};
|