clearotron 0.3.0-beta.4 → 0.3.0-beta.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.
Files changed (39) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +13 -0
  3. package/bin/connect.mjs +3 -1
  4. package/bin/onboard.mjs +80 -28
  5. package/bin/start.mjs +82 -18
  6. package/bin/update.mjs +38 -6
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +1 -0
  9. package/driver/CHANGELOG.md +36 -10
  10. package/driver/contract-e3-backlog.mjs +1 -1
  11. package/driver/demo-container.mjs +30 -2
  12. package/driver/engine/openai-agent.mjs +60 -2
  13. package/driver/enqueue-schema.mjs +3 -1
  14. package/driver/gateway.mjs +12 -0
  15. package/driver/package.json +1 -1
  16. package/driver/portal-local-auth.mjs +35 -3
  17. package/driver/portal-service.mjs +80 -18
  18. package/driver/portal-upstream.mjs +20 -1
  19. package/driver/search-policy.mjs +7 -2
  20. package/driver/suite-census.json +71 -23
  21. package/mcp-server/CHANGELOG.md +12 -2
  22. package/mcp-server/CONNECT.md +3 -2
  23. package/mcp-server/lib/options.mjs +13 -5
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-DWYCsOCJ.js → index-DBUVdQT-.js} +6 -0
  27. package/portal-ui/dist/index.html +1 -1
  28. package/portal-ui/package.json +1 -1
  29. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  30. package/providers/oauth-mcp-bridge/package.json +1 -1
  31. package/scripts/e2e.mjs +84 -12
  32. package/scripts/engine-probe.mjs +2 -2
  33. package/scripts/env-classify.mjs +3 -0
  34. package/shared/connect-clients.mjs +8 -0
  35. package/shared/names-in-force.mjs +1 -0
  36. package/shared/permanent-install.mjs +167 -0
  37. package/shared/stdio-connect.mjs +32 -14
  38. package/shared/verb-shim.mjs +10 -1
  39. package/shared/wsl.mjs +23 -0
@@ -221,7 +221,7 @@ export const E3_BACKLOG = [
221
221
  where: "driver/skills/prelim-common-law/SKILL.md:192 (restated at driver/skills/prelim-search/synthesis-rules.md:394)",
222
222
  surface: "skill-file",
223
223
  evidence: "A clean PR/connotation row MUST cite its search — add a `**Connotation-search source:** <URL | \"perplexity_research — no result\">` line.",
224
- reparsedBy: "driver/connotation-search.mjs — validators.commonLaw rejects a clean claim with no such line (connotation_search_missing); the hint is re-dictated at driver/gateway.mjs:1824",
224
+ reparsedBy: "driver/connotation-search.mjs — validators.commonLaw rejects a clean claim with no such line (connotation_search_missing); the hint is re-dictated by correctionHint() in gateway.mjs",
225
225
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
226
226
  },
227
227
  {
@@ -23,9 +23,9 @@
23
23
  // stage apart. This module is the single answer. `cut/` cannot import it (that directory does not travel
24
24
  // and this one does), so the pack gate restates the disjunction and its own test pins the two together.
25
25
 
26
- import { cpSync, existsSync, mkdtempSync, readdirSync } from "node:fs";
26
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync } from "node:fs";
27
27
  import { tmpdir } from "node:os";
28
- import { join, resolve, sep } from "node:path";
28
+ import { dirname, join, resolve, sep } from "node:path";
29
29
 
30
30
  /** The entry file each lane's publisher reads as its source, in the order a child is probed for one. */
31
31
  export const ENTRY_FILES = Object.freeze(["report.md", "knockout-findings.json"]);
@@ -78,3 +78,31 @@ export function publishSource(dir, { repoRoot, tmp = tmpdir() } = {}) {
78
78
  cpSync(here, copy, { recursive: true });
79
79
  return copy;
80
80
  }
81
+
82
+ /**
83
+ * THE DEMO'S SAMPLE RUNS, WHERE AN ASSISTANT LOOKS FOR RUNS.
84
+ *
85
+ * The demo published its samples as reports and made no run directory, so the connector its own connect
86
+ * line wires listed nothing: an assistant, one of the demo's three faces, had nothing to explore. Each
87
+ * sample's finished `run/` is copied to the layout the connector walks, under the DEMO'S OWN workspace:
88
+ * `<workspace>/workspace-<agent>/studio/prelim-search/<slug>/<date>-<codename>/`. Nothing is written
89
+ * anywhere else, so a real install started afterwards sees none of it (the demo is its own install).
90
+ *
91
+ * Copied, never linked: a connector reading a run may write beside it, and `demo/` is tracked. A run
92
+ * already in place is left alone, so a visitor's second start changes nothing.
93
+ */
94
+ export function seedDemoRuns({ workspace, examplesDir }) {
95
+ const seeded = [], already = [];
96
+ for (const name of demoChildren(examplesDir)) {
97
+ const run = join(examplesDir, name, "run");
98
+ let s;
99
+ try { s = JSON.parse(readFileSync(join(run, "status.json"), "utf8")); } catch { continue; }
100
+ if (!s?.slug || !s?.codename || !s?.date) continue;
101
+ const dir = join(workspace, `workspace-${s.agent || "clawdi"}`, "studio", "prelim-search", s.slug, `${s.date}-${s.codename}`);
102
+ if (existsSync(join(dir, "status.json"))) { already.push(s.runId); continue; }
103
+ mkdirSync(dirname(dir), { recursive: true });
104
+ cpSync(run, dir, { recursive: true });
105
+ seeded.push(s.runId);
106
+ }
107
+ return { seeded, already };
108
+ }
@@ -22,7 +22,8 @@
22
22
  // CLEAROTRON_CODEX_SANDBOX_BYPASS=1 swaps that `--sandbox workspace-write` for
23
23
  // `--dangerously-bypass-approvals-and-sandbox` — see buildCodexArgs below for why.
24
24
 
25
- import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
26
+ import { writeSecretFile } from "../../shared/secret-file.mjs"; // the rotated login goes back the way every credential is written
26
27
  import { tmpdir, homedir } from "node:os";
27
28
  import { join } from "node:path";
28
29
  import { runStreamingChild, absolutizeSkillRefs, WRITE_DISCIPLINE, buildEnvelope, resolveSpawnCwd } from "./common.mjs";
@@ -174,6 +175,49 @@ export function spawnEnv(base = process.env, codexHome) {
174
175
 
175
176
  const authFilePath = (env = process.env) => env.CLEAROTRON_OPENAI_AUTH_FILE || join(homedir(), ".codex", "auth.json");
176
177
 
178
+ // ── THE LOGIN IS THE MASTER'S, NOT A COPY'S ─────────────────────────────────────────────────────────
179
+ //
180
+ // codex refreshes its access token inside a turn and rotates the refresh token as it does, and the
181
+ // provider accepts each refresh token once. The stage home used to be seeded with a COPY of the master
182
+ // login and deleted when its ladder settled, so the rotated token was deleted with it. The master kept a
183
+ // token the provider had already consumed, and every later stage on every run failed its refresh with a
184
+ // 401 until a person signed in again. The engine's only report was an exit code.
185
+ //
186
+ // So the stage home LINKS to the master: a refresh codex writes in place lands where the next stage
187
+ // reads, including a ladder running beside this one. If codex replaces the link with a file of its own,
188
+ // `returnAuth` writes that file back.
189
+
190
+ /** Point `<codexHome>/auth.json` at the master login. Returns what the master held, for `returnAuth`. */
191
+ function seedAuth(masterPath, codexHome) {
192
+ const seat = join(codexHome, "auth.json");
193
+ // A LINK LEFT BY THIS LADDER'S PREVIOUS TURN is removed first: copying the master onto a link to
194
+ // itself would truncate the one file the login lives in.
195
+ rmSync(seat, { force: true });
196
+ symlinkSync(masterPath, seat);
197
+ return readFileSync(masterPath, "utf8");
198
+ }
199
+
200
+ /**
201
+ * After a turn: a login codex rewrote as its own file goes back to the master.
202
+ *
203
+ * ONLY WHILE THE MASTER STILL HOLDS WHAT THIS TURN WAS SEEDED WITH. A master that moved meanwhile was
204
+ * rotated by another ladder, and the provider accepts one refresh per token, so of two rotations from
205
+ * one seed only one can be live. Writing ours over theirs could put back the spent one.
206
+ */
207
+ export function returnAuth(masterPath, codexHome, seeded) { // @internal
208
+ const seat = join(codexHome, "auth.json");
209
+ let st;
210
+ try { st = lstatSync(seat); } catch { return false; }
211
+ if (st.isSymbolicLink() || !st.isFile()) return false; // written through the link, or not there
212
+ const now = readFileSync(seat, "utf8");
213
+ if (now === seeded) return false;
214
+ let master;
215
+ try { master = readFileSync(masterPath, "utf8"); } catch { return false; }
216
+ if (master !== seeded) return false;
217
+ writeSecretFile(masterPath, now);
218
+ return true;
219
+ }
220
+
177
221
  // Build the codex argv. Prompt rides STDIN via the `-` placeholder (no MAX_ARG_STRLEN ceiling on a 150KB
178
222
  // register prompt). Global flags precede the `resume` subcommand (flag-corpus ordering). config.toml
179
223
  // (mcp_servers + developer_instructions) rides CODEX_HOME, not argv.
@@ -368,6 +412,10 @@ export function readServedModel(codexHome, sinceMs = 0) {
368
412
  // that cannot recur. This alternation is still a list of prose a vendor may change without notice,
369
413
  // which is exactly why `rateLimitBasis: "text-match"` stays on the record.
370
414
  const RATE_LIMIT_RE = /\b429\b|rate.?limit|usage limit|quota|too many requests|insufficient_quota/i;
415
+ // A SIGN-IN THAT CAN NO LONGER BE REFRESHED, in codex's own words (measured on a live install, 2026-09-11).
416
+ // Narrow on purpose: a register provider's 401 or 403 can land in a stage's stderr too, and reading that as
417
+ // the operator's codex login having expired would send them to sign in again for nothing.
418
+ const REFRESH_FAILED_RE = /refresh token (?:has|was) already been used|access token could not be refreshed|failed to refresh token/i;
371
419
 
372
420
  // A retry hint, WHEN THE MESSAGE HAPPENS TO CARRY ONE. Two anchored shapes and nothing clever: an
373
421
  // explicit timestamp, or a relative delay with a unit. Anything else yields undefined and the driver
@@ -440,6 +488,7 @@ function settleTuple({ r, ev, resumeRef }) {
440
488
  // backoff (CLEAROTRON_RATE_LIMIT_DEFAULT_BACKOFF_MS), exactly as the anthropic no-reset 429 path does.
441
489
  const rateLimitText = `${ev.turnFailed || ""}\n${ev.streamError || ""}\n${r.stderr || ""}`;
442
490
  const rateLimited = !killed && RATE_LIMIT_RE.test(rateLimitText);
491
+ const signedOut = !killed && REFRESH_FAILED_RE.test(rateLimitText);
443
492
  const resetsAt = rateLimited ? parseResetHint(rateLimitText) : undefined;
444
493
  return {
445
494
  code: killed ? 137 : (failed ? (r.rawCode || 1) : 0),
@@ -477,6 +526,9 @@ function settleTuple({ r, ev, resumeRef }) {
477
526
  // receives and is not this adapter's call — but nothing downstream can any longer fail to know.
478
527
  mcpRefused: mcpToolGauge(ev).mcpToolCallsRefused > 0 || undefined,
479
528
  rateLimited: rateLimited || undefined,
529
+ // The engine's sign-in could not be refreshed: what the operator runs to fix it. The gateway names the
530
+ // stage's failure with it.
531
+ signedOut: signedOut ? "codex sign-in expired — run `codex login`, then start the search again" : undefined,
480
532
  rateLimitBasis: rateLimited ? "text-match" : undefined,
481
533
  // resetsAtBasis (2026-08-20): same honesty as rateLimitBasis one line up, for the reset
482
534
  // CLOCK rather than the classification. codex states its reset as human prose with NO timezone
@@ -530,6 +582,7 @@ export const openaiAgentEngine = {
530
582
  const ownHome = !providedHome;
531
583
  try { codexHome = providedHome ?? mkdtempSync(join(tmpdir(), "codex-home-")); }
532
584
  catch (e) { return errResult(t0, e, resumeRef); }
585
+ let authMaster = null, seeded = null;
533
586
  try {
534
587
  // Auth toggle (may throw loud on api-key-without-key — a config error, not a retryable failure).
535
588
  const { env, mode } = spawnEnv(process.env, codexHome);
@@ -537,7 +590,9 @@ export const openaiAgentEngine = {
537
590
  const af = authFilePath(process.env);
538
591
  if (!existsSync(af))
539
592
  throw new Error(`CLEAROTRON_AI_BILLING=subscription but no auth.json at ${af} — run \`codex login\` (or set CLEAROTRON_OPENAI_AUTH_FILE — NOT an aliased name, and deliberately left as it is), or use CLEAROTRON_AI_BILLING=api-key + CODEX_API_KEY.`);
540
- copyFileSync(af, join(codexHome, "auth.json"));
593
+ // The REAL path, so a master that is itself a link is written where its login actually lives.
594
+ authMaster = realpathSync(af);
595
+ seeded = seedAuth(authMaster, codexHome);
541
596
  }
542
597
  // Per-run config.toml: the gather MCP servers (translated from the claude-shaped mcpConfig, so
543
598
  // gather-config.mjs is untouched) + developer_instructions = WRITE_DISCIPLINE (codex's
@@ -574,6 +629,9 @@ export const openaiAgentEngine = {
574
629
  if (!ev.model) ev.model = readServedModel(codexHome, spawnedAtMs);
575
630
  return settleTuple({ r, ev, resumeRef });
576
631
  } finally {
632
+ // BEFORE the home can be deleted, here or by the ladder that owns it. A failed write-back leaves the
633
+ // turn's result as it was; the next turn is seeded from the master either way.
634
+ try { if (authMaster) returnAuth(authMaster, codexHome, seeded); } catch { /* the master is unchanged */ }
577
635
  try { if (ownHome && codexHome) rmSync(codexHome, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
578
636
  }
579
637
  },
@@ -456,7 +456,9 @@ export function validateJob(job, { atClaim = false } = {}) {
456
456
  if (projectKey) consulted.push(`the project ${JSON.stringify(projectKey)}`);
457
457
  // The saved search is a rung above the project. `clarify` means the selector itself is wrong, which
458
458
  // the recipeKey/product checks further down report properly — here it just means no scope to read.
459
- const resolved = resolveSearchPolicy(job, { profile, recipes: loadRecipes({ force: true, proseGuard: recipeProseGuard }) });
459
+ // The store is read only for a job that names a saved search, as every other door reads it: one that
460
+ // cannot be read must not refuse a job that never asked for anything in it.
461
+ const resolved = resolveSearchPolicy(job, { profile, recipes: job.recipeKey ? loadRecipes({ force: true, proseGuard: recipeProseGuard }) : null });
460
462
  if (job.recipeKey) consulted.push(`the saved search ${JSON.stringify(String(job.recipeKey))}`);
461
463
  const scope = resolveEffectiveScope(job, profile, resolved?.clarify ? null : resolved);
462
464
  inherited = Array.isArray(scope?.classes) ? scope.classes : [];
@@ -1511,6 +1511,12 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1511
1511
  // Guarded on `fail`: a turn whose finished, validated artifact was just accepted (the exit-1 rescue above)
1512
1512
  // stays accepted — the NEXT stage's first turn hits the same cap at 0 tokens spent and postpones cleanly.
1513
1513
  if (turn.signals?.rateLimited && fail) fail = "rate_limited";
1514
+ // AN ENGINE WHOSE SIGN-IN EXPIRED IS NAMED, not left as `nonzero_exit_1`. codex fails its refresh in
1515
+ // seconds with a bare exit code while `codex login status` still reports the user signed in, so the
1516
+ // one thing that fixes it has to be in the failure itself. AFTER the rate limit, which wins when both
1517
+ // appear: a postponed run resumes on its own, and a signed-out one cannot. The sentence rides after
1518
+ // the colon, as `model_mismatch:` carries its detail, so no run-level classifier needs a new token.
1519
+ else if (turn.signals?.signedOut && fail) fail = `engine_signed_out: ${turn.signals.signedOut}`;
1514
1520
  // A6 (addendum 2026-07-30): stop_reason max_tokens with ZERO usable output is a DETECTED FAULT with a
1515
1521
  // name — never a silent paid retry. The turn ran to its output-token ceiling and the artifact never
1516
1522
  // landed (a content fail on a "successful" turn), or the turn itself died at the ceiling (transport
@@ -2002,6 +2008,12 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
2002
2008
  note(`[${name}] model mismatch is deterministic — breaking the ladder (a retry re-buys the same wrong model)`);
2003
2009
  break;
2004
2010
  }
2011
+ // A sign-in that could not be refreshed refuses every attempt the same way: the next one sends the
2012
+ // same credential and gets the same 401, seconds apart. Break, and let the stage fail with it named.
2013
+ if (fail.startsWith("engine_signed_out:")) {
2014
+ note(`[${name}] the engine's sign-in could not be refreshed — breaking the ladder (a retry re-sends the same credential)`);
2015
+ break;
2016
+ }
2005
2017
  // D3: overload (529 / status_overloaded) — stop the ladder here: an in-ladder re-attempt hammers an
2006
2018
  // API that just said it is overloaded, seconds apart, on the SAME model. Breaking hands the failure
2007
2019
  // to the existing machinery: the chain cascades models (FALLBACK_ELIGIBLE) and an exhausted chain
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.4",
5
+ "version": "0.3.0-beta.6",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -45,7 +45,7 @@
45
45
  // need the prefix — one side of a pair is enough to separate the pair.
46
46
  import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypto";
47
47
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
48
- import { basename, dirname, join } from "node:path";
48
+ import { basename, dirname, join, relative, isAbsolute, sep } from "node:path";
49
49
  import { homedir } from "node:os";
50
50
  import { envPrefix } from "../shared/os-advice.mjs";
51
51
 
@@ -187,6 +187,30 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
187
187
  return `${prefix.slice(0, at)}${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${prefix.slice(at)}clearotron passphrase --reset`;
188
188
  }
189
189
 
190
+ /**
191
+ * The recovery line the SIGN-IN PAGE shows: one that resets the credential this portal reads, and that
192
+ * names no path on this machine.
193
+ *
194
+ * The page is read by somebody who is not signed in, so `passphraseResetCommand`'s line, with its
195
+ * `cd <install> && ` and its absolute `--base`, would show the server's home directory and account to
196
+ * anyone who loads it. So the install is named from `$HOME`, which POSIX shells and PowerShell both
197
+ * expand, and a demo run from npx's cache is reached as `npx clearotron@<version>`, which runs from any
198
+ * directory. The bare `clearotron passphrase --reset` this replaced reset the shared default, not a
199
+ * demo's own credential, and did not run at all where `clearotron` is not on the PATH (measured
200
+ * 2026-09-11 on a published beta).
201
+ */
202
+ export function signInResetCommand({ credentialPath = null, home = null, npxVersion = null } = {}) {
203
+ const verb = `${npxVersion ? `npx clearotron@${npxVersion}` : "clearotron"} passphrase --reset`;
204
+ if (!credentialPath || basename(credentialPath) !== INSTALL_CREDENTIAL_FILE) return verb;
205
+ const h = home ?? homedir();
206
+ const dir = dirname(credentialPath);
207
+ if (dir === defaultInstallBase({ home: h })) return verb;
208
+ const rel = relative(h, dir);
209
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return `${verb} --base <this install's folder>`;
210
+ const at = `$HOME/${rel.split(sep).join("/")}`;
211
+ return `${verb} --base ${/\s/.test(at) ? `"${at}"` : at}`;
212
+ }
213
+
190
214
  /**
191
215
  * The name an install's own credential has inside the install's directory: the shared default's name,
192
216
  * so the two are one kind of file in two places.
@@ -287,7 +311,8 @@ export function establishCredential({ path, email, passphrase = null }) {
287
311
  // 18 bytes → 24 base64url characters, no padding and no ambiguous punctuation: readable off a
288
312
  // terminal, typeable into a browser, and 144 bits of entropy so a generated one is never the weak
289
313
  // half of this design.
290
- const secret = generated ? newPassphrase() : String(passphrase);
314
+ // Trimmed for the reason checkPassphrase trims: surrounding whitespace is never part of a passphrase.
315
+ const secret = generated ? newPassphrase() : String(passphrase).trim();
291
316
  if (!secret) throw new Error("establishCredential: an empty passphrase is not a credential");
292
317
  const salt = randomBytes(16).toString("base64url");
293
318
  const rec = {
@@ -319,7 +344,14 @@ export function establishCredential({ path, email, passphrase = null }) {
319
344
  */
320
345
  export function checkPassphrase(record, passphrase) {
321
346
  if (!record || typeof record.salt !== "string" || typeof record.hash !== "string") return false;
322
- if (typeof passphrase !== "string" || !passphrase) return false;
347
+ if (typeof passphrase !== "string") return false;
348
+ // SURROUNDING WHITESPACE IS NOT PART OF A PASSPHRASE. A value copied from a terminal or a chat carries a
349
+ // trailing space or line break, and the digest of `pass ` is not the digest of `pass`, so a correct
350
+ // passphrase was refused as wrong (measured 2026-09-11 on a published beta). Every passphrase is minted
351
+ // trimmed (establishCredential, below), and a generated one has no whitespace at all, so trimming here
352
+ // refuses nothing that matched before. Whitespace INSIDE a passphrase is kept.
353
+ passphrase = passphrase.trim();
354
+ if (!passphrase) return false;
323
355
  if (record.algo !== ALGO) return false;
324
356
  let got;
325
357
  try { got = derive(passphrase, record.salt); } catch { return false; }
@@ -40,6 +40,8 @@ import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mj
40
40
  import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
41
41
  import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
42
42
  import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
43
+ import { isWsl } from "../shared/wsl.mjs"; // — on WSL, the connect lines say where they run
44
+ import { inNpxCache, ownVersion } from "../shared/permanent-install.mjs"; // — a demo from npx is reset through npx
43
45
  import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
44
46
 
45
47
  /**
@@ -112,7 +114,7 @@ import { makePrincipal, assertPrincipal, genericOrgOf, mayReadRun, reachCovers,
112
114
  // makePrincipal and assertPrincipal exactly as a Cloudflare-verified address does.
113
115
  import { readLocalCredential, establishCredential, checkPassphrase, mintSession, verifySession,
114
116
  makeAttemptLimiter, credentialPathFor,
115
- localCredentialHandoff, firstRunCredentialLines, passphraseResetCommand } from "./portal-local-auth.mjs";
117
+ localCredentialHandoff, firstRunCredentialLines, passphraseResetCommand, signInResetCommand } from "./portal-local-auth.mjs";
116
118
  import { appendFlag, feedbackDir, MAX_WHY, VERDICTS } from "./feedback-store.mjs";
117
119
  import { loadRecipes, PRODUCT_POLICIES, policyFor, countJobMarks, kebabCollisions,
118
120
  gateResolvedPolicy, productAvailability, UNAVAILABLE_NOTE, coverageDisclosure, reportIdentityFor } from "./search-policy.mjs";
@@ -1197,8 +1199,16 @@ export function makePortalService({
1197
1199
  const usedJtis = new Map();
1198
1200
  const sweepJtis = (now = Date.now()) => { for (const [k, exp] of usedJtis) if (exp < now) usedJtis.delete(k); };
1199
1201
 
1200
- const searchesFor = (account) => {
1201
- const recipes = loadRecipesImpl({});
1202
+ const searchesFor = (account, principal) => {
1203
+ // A SAVED-SEARCH STORE THAT CANNOT BE READ COSTS THE SAVED SEARCHES, NOT THE MENU. Every product is
1204
+ // still offered; the list comes back empty with a sentence saying why, and only a person who sees
1205
+ // everything is shown the store's own error, which names a server path.
1206
+ let recipes = new Map(), recipesNote = null;
1207
+ try { recipes = loadRecipesImpl({}); }
1208
+ catch (e) {
1209
+ const cause = seesEverything(principal) ? ` (${String(e?.message ?? e).split("\n")[0].slice(0, 300)})` : "";
1210
+ recipesNote = `Saved searches could not be read on this installation, so none are listed${cause}.`;
1211
+ }
1202
1212
  const built = readBuilt();
1203
1213
  const canCount = readCanCount();
1204
1214
  const territories = readTerritories();
@@ -1264,6 +1274,7 @@ export function makePortalService({
1264
1274
  recipes: [...recipes.entries()]
1265
1275
  .filter(([k, r]) => k.startsWith(`${account}/`) && !r.archived)
1266
1276
  .map(([k, r]) => ({ slug: k.split("/")[1], label: r.label, base: r.base, version: r.version ?? null, nativeLanguage: r.nativeLanguage === true })),
1277
+ recipesNote,
1267
1278
  // ── — THE MARK-NAME BUDGET, SENT RATHER THAN DUPLICATED ──────────────
1268
1279
  //
1269
1280
  // The owner typed a product description into the mark-name field and the product accepted it,
@@ -1547,7 +1558,7 @@ export function makePortalService({
1547
1558
  if (parts[1] === "api" && parts[2] === "searches" && method === "GET") {
1548
1559
  const account = assertPrincipal(principal, { account: query.account ?? null });
1549
1560
  if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
1550
- return { status: 200, json: { account, ...searchesFor(account) } };
1561
+ return { status: 200, json: { account, ...searchesFor(account, principal) } };
1551
1562
  }
1552
1563
  // /portal/api/compose/read — turn a pasted brief into a filled-in composer.
1553
1564
  //
@@ -2305,7 +2316,7 @@ export function makePortalService({
2305
2316
  // COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
2306
2317
  // path, so the three surfaces stating this route cannot drift apart even if someone tries.
2307
2318
  const stdio = seesEverything(principal)
2308
- ? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null })
2319
+ ? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null })
2309
2320
  : null;
2310
2321
 
2311
2322
  // ── THE PAGE IS HANDED ANSWERS, NOT FACTS TO REASON FROM ─────────────
@@ -2330,8 +2341,9 @@ export function makePortalService({
2330
2341
  const offers = connectOffers({
2331
2342
  stdioRoutes: stdio
2332
2343
  ? Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
2333
- [shape, stdioConnectFor(shape, { workDir: process.env.CLEAROTRON_WORK_DIR || null })]))
2344
+ [shape, stdioConnectFor(shape, { workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null })]))
2334
2345
  : {},
2346
+ wsl: isWsl(),
2335
2347
  // ONE ADDRESS, and it is the publicly reachable one ( §5). The
2336
2348
  // `localAddress` and `clientDoorStanding` inputs are gone with the axis that read them: no
2337
2349
  // surface serves a loopback address to anybody, and the door's running-or-not stopped being
@@ -3364,6 +3376,21 @@ async function readJsonBody(req, limitBytes = 131072) {
3364
3376
  }
3365
3377
 
3366
3378
 
3379
+ /**
3380
+ * The instance a refusal page names: its organisation when one is configured, otherwise the host of the
3381
+ * sign-in service it sends people to (the OIDC issuer, or the Access team's own domain, derived as the
3382
+ * verifier derives it). `null` when neither is set. Read from the environment at call time.
3383
+ */
3384
+ function instanceIdentity(env = process.env) {
3385
+ if (ORGANISATION_NAME) return `This is ${ORGANISATION_NAME}'s ${BRAND.name} portal`;
3386
+ const issuer = String(env.PORTAL_OIDC_ISSUER ?? "").trim();
3387
+ const team = String(env.CF_ACCESS_TEAM ?? "").trim();
3388
+ let host = null;
3389
+ try { host = issuer ? new URL(issuer).host : null; } catch { host = null; }
3390
+ if (!host && team) host = `${team}.cloudflareaccess.com`;
3391
+ return host ? `This ${BRAND.name} portal signs people in through ${host}` : null;
3392
+ }
3393
+
3367
3394
  /**
3368
3395
  * The sign-in refusal page.
3369
3396
  *
@@ -3384,6 +3411,12 @@ async function readJsonBody(req, limitBytes = 131072) {
3384
3411
  */
3385
3412
  export function denialPage(status, message) {
3386
3413
  const esc = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
3414
+ // WHICH INSTANCE THIS IS. Reached through a forwarded port, this page read as a local demo refusing its own
3415
+ // user (measured 2026-09-11, a VS Code Remote-SSH forward on WSL); named, a person can tell it is not
3416
+ // theirs. Named by the organisation when one is set, otherwise by the sign-in service the browser is sent
3417
+ // to, which is public by nature: never by this server's own hostname. No advice about ports: the people
3418
+ // who normally see this page are a hosted instance's own clients, with nothing local to move.
3419
+ const who = instanceIdentity();
3387
3420
  // The upstream message names the address and the reason. That is useful to the person reading it and
3388
3421
  // to whoever they forward it to — but it is a DIAGNOSTIC, so it sits below the fold of the sentence
3389
3422
  // that actually tells them what to do.
@@ -3424,6 +3457,7 @@ ${DOOR_THEME_INIT}
3424
3457
  <a class="btn primary" href="/portal/sign-out">Sign in as someone else</a>
3425
3458
  <a class="btn" href="/portal">Try again</a>
3426
3459
  </div>
3460
+ ${who ? `<p style="margin-top:20px;font-size:14px">${esc(who)}.</p>` : ""}
3427
3461
  <code>${esc(message)}</code>
3428
3462
  </div></body></html>`;
3429
3463
  }
@@ -3473,7 +3507,8 @@ const escHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<"
3473
3507
  //
3474
3508
  // The dark ground moved with that change: the block this page used to carry had guessed #17150f/#ece5d8,
3475
3509
  // and brand pack §01 fixes dark at #0f0e0c near-black + #f0e8d8 parchment. The pack wins.
3476
- export function loginPage({ email, error = null, signedIn = false }) {
3510
+ export function loginPage({ email, error = null, signedIn = false, discarded = false, resetCommand = null }) {
3511
+ const reset = resetCommand || `${bareInvocation("passphrase")} --reset`;
3477
3512
  const title = signedIn ? "Signed in" : "Sign in";
3478
3513
  const lockup = `<span class="lockup"><span class="mark">${bracketMark(20, "currentColor", PALETTE.crimson)}</span>`
3479
3514
  + `<span class="word">${escHtml(BRAND.name.toLowerCase())}</span></span>`;
@@ -3517,16 +3552,24 @@ ${signedIn
3517
3552
  <div><a href="/portal">Go to the portal</a></div>
3518
3553
  <form method="post" action="/portal/logout"><button type="submit">Sign out</button></form>`
3519
3554
  : `<p>${escHtml(BRAND.name)} portal, as <span class="who">${escHtml(email)}</span>.</p>
3555
+ ${discarded ? `<p class="hint">A session this portal did not start, from another ${escHtml(BRAND.name)} on this address or an expired one, was set aside. Sign in below.</p>` : ""}
3520
3556
  ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
3521
3557
  <form method="post" action="/portal/login">
3522
3558
  <label for="passphrase">Passphrase</label>
3523
- <input id="passphrase" name="passphrase" type="password" autocomplete="current-password" autofocus>
3559
+ <input id="passphrase" name="passphrase" type="password" autocomplete="new-password" autofocus>
3524
3560
  <button type="submit">Sign in</button>
3525
3561
  </form>
3526
- <p class="hint">Lost the passphrase? Run <code>${escHtml(bareInvocation("passphrase"))} --reset</code> on the machine
3562
+ <p class="hint">Lost the passphrase? Run <code>${escHtml(reset)}</code> on the machine
3527
3563
  running this portal. It mints a new one and prints it once.</p>`}
3528
3564
  </div></body></html>`;
3529
3565
  }
3566
+ // THE FIELD DOES NOT INVITE THE BROWSER'S SAVED PASSWORDS. Every local install and demo answers on
3567
+ // 127.0.0.1, so a browser that saved one install's passphrase fills it into the next install's page, and
3568
+ // the person is told a passphrase they never typed is wrong (read from the handler, 2026-09-11: the POST
3569
+ // compares only what the browser sent). `new-password` is the value browsers honour on a password field;
3570
+ // `off` is ignored there. The refusal says to check the field, for a browser that fills it regardless.
3571
+ const WRONG_PASSPHRASE = "That passphrase is not correct. If your browser filled the field in, clear it and type the passphrase yourself.";
3572
+
3530
3573
  // The cookie the local session rides in. HttpOnly (no script needs it and nothing in the SPA reads it),
3531
3574
  // SameSite=Strict (there is no cross-site flow to preserve — every portal navigation is same-origin),
3532
3575
  // Path=/ so a report under /portal/report/… carries it too.
@@ -3608,6 +3651,9 @@ class LocalAuthError extends Error {
3608
3651
  * cases the right answer is "sign in again", not "resolve this name through the roster and see what it
3609
3652
  * gets" — which is what accepting the token's own `sub` would do.
3610
3653
  */
3654
+ /** Whether this request carries a session cookie at all, readable or not. */
3655
+ const hasSessionCookie = (req) => !!parseCookies(req.headers?.cookie)[SESSION_COOKIE];
3656
+
3611
3657
  function localSessionIdentity(req, localAuth) {
3612
3658
  const token = parseCookies(req.headers?.cookie)[SESSION_COOKIE];
3613
3659
  if (!token) return null;
@@ -3788,17 +3834,25 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
3788
3834
  res.writeHead(302, { location: to, "cache-control": "no-store", "content-length": 0, ...extra });
3789
3835
  return res.end();
3790
3836
  };
3837
+ // A SESSION COOKIE THIS PORTAL CANNOT READ IS CLEARED, AND SAID. Cookies ignore ports, so a browser
3838
+ // that visited another Clearotron on this address (an earlier demo, another instance) brings that
3839
+ // cookie here, where it verifies as nothing. Cleared, the next sign-in starts clean; and the page
3840
+ // says an old session was set aside, rather than leave a person to guess what changed.
3841
+ const who = localSessionIdentity(req, localAuth);
3842
+ const stale = !who && hasSessionCookie(req);
3843
+ const setAside = stale ? { "set-cookie": clearSessionCookie({ secure }) } : {};
3791
3844
  if (url.pathname === "/portal/login" && req.method === "GET")
3792
- return page(200, loginPage({ email: localAuth.email, signedIn: !!localSessionIdentity(req, localAuth) }));
3845
+ return page(200, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, signedIn: !!who, discarded: stale }), setAside);
3793
3846
  if (url.pathname === "/portal/login" && req.method === "POST") {
3794
3847
  // Keyed on the peer address, counted by localAuth.attempts (portal-local-auth.mjs:
3795
3848
  // makeAttemptLimiter — a fixed window, NOT the 120/min token bucket the API routes share).
3796
3849
  // On a loopback install every attempt keys to 127.0.0.1, so the window is global; that is the
3797
3850
  // right answer for one user on one machine and is written down in the limiter's own comment.
3798
3851
  if (!localAuth.attempts.take(req.socket?.remoteAddress ?? "unknown"))
3799
- return page(429, loginPage({ email: localAuth.email, error: "Too many attempts. Wait a few minutes and try again." }));
3852
+ return page(429, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "Too many attempts. Wait a few minutes and try again." }));
3800
3853
  let form;
3801
- try { form = await readFormBody(req); } catch { return page(400, loginPage({ email: localAuth.email, error: "That passphrase is not correct." })); }
3854
+ // A form that could not be read says nothing about the passphrase, so it is not answered as one.
3855
+ try { form = await readFormBody(req); } catch { return page(400, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "The sign-in form could not be read. Reload this page and try again." }), setAside); }
3802
3856
  let record;
3803
3857
  try {
3804
3858
  record = localAuth.credential();
@@ -3806,14 +3860,19 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
3806
3860
  // A credential file that has gone corrupt since boot. The operator gets the reason on
3807
3861
  // stderr; the browser gets a sentence with no filesystem in it.
3808
3862
  log(`local sign-in unavailable: ${String(e?.message ?? e)}`);
3809
- return page(500, loginPage({ email: localAuth.email, error: "Sign-in is not available. The terminal that started the portal says why." }));
3863
+ return page(500, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "Sign-in is not available. The terminal that started the portal says why." }));
3864
+ }
3865
+ // A REFUSAL FOR ANY REASON BUT THE PASSPHRASE SAYS ITS REASON. "That passphrase is not correct."
3866
+ // sent a person to reset a passphrase that was never wrong. An install with no credential has no
3867
+ // passphrase to be right or wrong about, so saying so gives a caller nothing to guess against.
3868
+ if (!record) {
3869
+ log("local sign-in attempted but no credential is configured — stop the portal and start it again to mint one");
3870
+ return page(401, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: "No passphrase is set for this install yet. Stop the portal and start it again to create one." }), setAside);
3810
3871
  }
3811
- if (!record) log("local sign-in attempted but no credential is configured — stop the portal and start it again to mint one");
3812
- // ONE SENTENCE FOR EVERY FAILURE. A wrong passphrase, an absent credential and an
3813
- // unparseable form all answer "That passphrase is not correct." at 401: anything more
3814
- // specific tells an unauthenticated caller which half of the credential they got right.
3872
+ // ONE SENTENCE FOR A WRONG PASSPHRASE, and nothing more specific: which half of a credential was
3873
+ // right is exactly what an unauthenticated caller must not learn.
3815
3874
  if (!checkPassphrase(record, form.get("passphrase") ?? ""))
3816
- return page(401, loginPage({ email: localAuth.email, error: "That passphrase is not correct." }));
3875
+ return page(401, loginPage({ email: localAuth.email, resetCommand: localAuth.resetCommand, error: WRONG_PASSPHRASE }), setAside);
3817
3876
  const token = mintSession({ email: localAuth.email, secret: localAuth.secret, ttlSec: localAuth.ttlSec });
3818
3877
  return redirect("/portal", { "set-cookie": setSessionCookie(token, { secure, maxAgeSec: localAuth.ttlSec }) });
3819
3878
  }
@@ -4777,6 +4836,9 @@ const PORT = PORT_CHOICE.port;
4777
4836
  // than a stale copy held in memory.
4778
4837
  credential: () => readLocalCredential(credentialPath),
4779
4838
  attempts: makeAttemptLimiter({ max: 10, windowMs: 5 * 60 * 1000 }),
4839
+ // The sign-in page's recovery line. It resets the file this portal reads, and names no path on this
4840
+ // machine, because the page is read before anyone has signed in (signInResetCommand says how).
4841
+ resetCommand: signInResetCommand({ credentialPath, npxVersion: inNpxCache() ? ownVersion() : null }),
4780
4842
  };
4781
4843
  })();
4782
4844
 
@@ -420,17 +420,36 @@ export function makeUpstream({ callUpstream, callRecipes = null, recipesOff = nu
420
420
  const draft = {};
421
421
  for (const k of CREATABLE_FIELDS) if (body[k] !== undefined) draft[k] = body[k];
422
422
  const r = await call("POST", "/profiles", draft, principal);
423
+ // A STORE THAT CANNOT RECORD THE COMPANY is answered with its path and the git commands that fix it.
424
+ // That sentence is for whoever runs the installation. A manager who holds an organisation cannot act
425
+ // on it and must not be shown the server's paths, so they are told what to do instead.
426
+ if (r.status === 409 && String(r.json?.code ?? "").startsWith("store_") && !seesEverything(principal))
427
+ return { status: 409, json: { code: r.json.code, error: STORE_REFUSAL_FOR_MANAGERS } };
428
+ // AND A CREATE THE STORE COULD NOT UNDO answers 500 with git's last word and the file it left behind,
429
+ // either of which can name a server path. Same reader, same rule.
430
+ if (r.status === 500 && !seesEverything(principal))
431
+ return { status: 500, json: { ...(r.json?.key ? { key: r.json.key } : {}), error: STORE_FAULT_FOR_MANAGERS } };
423
432
  if (r.status !== 201 || org == null || !fileCompany) return r;
424
433
  const key = r.json?.key ?? r.json?.profile?.key ?? draft.key;
425
434
  try { await fileCompany({ tenant: org, account: key }); }
426
435
  catch (e) {
427
- return { status: 500, json: { error: `The company was created, but it could not be filed under its organisation (${String(e?.message ?? e).slice(0, 200)}). A person with access to everything can see it and file it.`, key } };
436
+ // The cause is the grants file's own error, which names its path: said to staff only.
437
+ const cause = seesEverything(principal) ? ` (${String(e?.message ?? e).slice(0, 200)})` : "";
438
+ return { status: 500, json: { error: `The company was created, but it could not be filed under its organisation${cause}. A person with access to everything can see it and file it.`, key } };
428
439
  }
429
440
  return { ...r, json: { ...r.json, tenant: org } };
430
441
  },
431
442
  };
432
443
  }
433
444
 
445
+ /** The store refusal as a manager who does not run the installation reads it: no path, no command. */
446
+ export const STORE_REFUSAL_FOR_MANAGERS = "No company was created: this installation cannot save new companies yet, "
447
+ + "and nothing was changed. Ask whoever runs it to fix the company store.";
448
+
449
+ /** A create the store could not undo, as that manager reads it: what happened and who acts, no path. */
450
+ export const STORE_FAULT_FOR_MANAGERS = "The company could not be recorded, and part of it may still be on the server. "
451
+ + "Tell whoever runs this installation before trying again.";
452
+
434
453
  /**
435
454
  * What a browser may state when creating a company.
436
455
  *
@@ -1078,11 +1078,16 @@ export function loadRecipes({ dir = process.env.CLEAROTRON_RECIPES_DIR || null,
1078
1078
  const recipes = new Map();
1079
1079
  retiredBaseSkips.length = 0;
1080
1080
  let customers = [];
1081
+ // ABSENT IS EMPTY; UNREADABLE IS AN ERROR. Only a store that is not there is a store with nothing in it.
1082
+ // One that is there and refuses to be read (permissions, a failing disk) used to come back empty too, and
1083
+ // every door then told its reader the company had no saved searches, while the doors that say "could not
1084
+ // be read" never fired, because they fire on a throw.
1085
+ const absent = (e) => e?.code === "ENOENT" || e?.code === "ENOTDIR";
1081
1086
  try { customers = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); }
1082
- catch { recipeCache = { dir, recipes }; return recipes; } // no recipe store ⇒ empty (not an error)
1087
+ catch (e) { if (!absent(e)) throw e; recipeCache = { dir, recipes }; return recipes; } // no recipe store ⇒ empty (not an error)
1083
1088
  for (const customer of customers) {
1084
1089
  let files = [];
1085
- try { files = readdirSync(join(dir, customer)).filter((f) => f.endsWith(".json")); } catch { continue; }
1090
+ try { files = readdirSync(join(dir, customer)).filter((f) => f.endsWith(".json")); } catch (e) { if (!absent(e)) throw e; continue; }
1086
1091
  for (const f of files) {
1087
1092
  const slug = f.replace(/\.json$/, "");
1088
1093
  const r = JSON.parse(readFileSync(join(dir, customer, f), "utf8")); // parse error = loud (config bug)