clearotron 0.3.1-beta.3 → 0.3.1

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.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // deprecate-below.mjs — put one warning on every published version below a named stable.
6
+ //
7
+ // node scripts/deprecate-below.mjs --below 0.3.1 [--dry-run]
8
+ //
9
+ // ── WHY THIS IS A SCRIPT IN CI AND NOT SOMETHING A PERSON RUNS ──────────────────────────────────────
10
+ //
11
+ // Nobody on a development box can do it: the publish credential lives only in CI, `npm whoami` is 401
12
+ // there by design, and that design is not something to work around. So the work is a script, and the way
13
+ // a person asks for it is a workflow dispatch.
14
+ //
15
+ // ── WHAT IT REFUSES, AND WHY EACH REFUSAL IS CHEAPER THAN THE MISTAKE ───────────────────────────────
16
+ //
17
+ // `npm deprecate` takes a RANGE, and a range is the dangerous part: `npm deprecate pkg "<1.0.0"` is one
18
+ // command that can warn every version a project ever shipped, and the undo is another command per
19
+ // version. So no range reaches npm from here. The versions are enumerated, compared one at a time, and
20
+ // deprecated one at a time, which is slower and is the point.
21
+ //
22
+ // THE NAMED STABLE IS NEVER TOUCHED, nor is anything above it. A message telling a reader to upgrade to
23
+ // the version they are already on is worse than no message: it reads as a defect in the version they
24
+ // just chose.
25
+ //
26
+ // ALREADY-DEPRECATED VERSIONS ARE LEFT ALONE. Re-deprecating replaces one message with another, and the
27
+ // existing one may be more specific than this one — a security note, say.
28
+ //
29
+ // ── THE READ-BACK IS PER VERSION, AND THAT IS NOT A STYLE CHOICE ────────────────────────────────────
30
+ //
31
+ // `npm view <pkg> deprecated --json` over a range returns a value whose SHAPE depends on how many
32
+ // versions matched: a string for one, an object keyed by version for several, and nothing at all for
33
+ // none. Reading that as a map is how a verification passes over versions it never checked. So each
34
+ // version is read back on its own, by its own exact spec, and the answer is a string or it is absent.
35
+ import { execFileSync } from "node:child_process";
36
+
37
+ const PKG = "clearotron";
38
+
39
+ // NOTHING HAPPENS AT IMPORT. The argument parsing and its refusal used to sit here at the top, so
40
+ // importing this module to test its comparison ran the refusal and exited 2 before a single arm ran. A
41
+ // CLI module that does work when required is the same defect this repository has met with a top-level
42
+ // await, and the same fix: the body belongs in `main`, and `main` runs only from the entry check.
43
+
44
+ /** Compare two semver-ish versions. A pre-release sorts BELOW its own release, which is what npm means. */
45
+ export function compareVersions(a, b) {
46
+ const split = (v) => {
47
+ const [core, pre = ""] = String(v).split("-");
48
+ return { nums: core.split(".").map((n) => Number(n) || 0), pre };
49
+ };
50
+ const A = split(a), B = split(b);
51
+ for (let i = 0; i < 3; i++) {
52
+ if ((A.nums[i] ?? 0) !== (B.nums[i] ?? 0)) return (A.nums[i] ?? 0) < (B.nums[i] ?? 0) ? -1 : 1;
53
+ }
54
+ // Same core. No pre-release outranks any pre-release; two pre-releases compare as text, which is
55
+ // right for `beta.2` against `beta.10` only up to ten — so the identifiers are compared numerically
56
+ // where they are numbers.
57
+ if (A.pre === B.pre) return 0;
58
+ if (!A.pre) return 1;
59
+ if (!B.pre) return -1;
60
+ const ap = A.pre.split("."), bp = B.pre.split(".");
61
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
62
+ const x = ap[i], y = bp[i];
63
+ if (x === y) continue;
64
+ if (x === undefined) return -1;
65
+ if (y === undefined) return 1;
66
+ const nx = Number(x), ny = Number(y);
67
+ if (Number.isInteger(nx) && Number.isInteger(ny)) return nx < ny ? -1 : 1;
68
+ return x < y ? -1 : 1;
69
+ }
70
+ return 0;
71
+ }
72
+
73
+ const npm = (...args) => execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
74
+
75
+ /** Every published version, and the deprecation message each carries. Read once, as a list. */
76
+ function published() {
77
+ const versions = JSON.parse(npm("view", PKG, "versions", "--json"));
78
+ return Array.isArray(versions) ? versions : [versions];
79
+ }
80
+
81
+ /** One version's deprecation message, read by its own exact spec. Null when it carries none. */
82
+ function deprecationOf(version) {
83
+ // `--json` on a field that is absent prints nothing at all, which JSON.parse refuses. An empty read is
84
+ // "this version carries no message", and it is a different fact from a read that failed — so a failure
85
+ // throws and a caller decides, rather than being folded into "not deprecated".
86
+ const out = npm("view", `${PKG}@${version}`, "deprecated", "--json").trim();
87
+ if (!out) return null;
88
+ const v = JSON.parse(out);
89
+ return typeof v === "string" && v.trim() ? v : null;
90
+ }
91
+
92
+ function main() {
93
+ const argv = process.argv.slice(2);
94
+ const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
95
+ const DRY = argv.includes("--dry-run");
96
+ const below = at("--below");
97
+ if (!below) {
98
+ console.error("deprecate-below: --below <version> is required — the stable everything under it points at.");
99
+ return 2;
100
+ }
101
+ const MESSAGE = `This version is superseded. Please upgrade to ${below} or later: npm install ${PKG}@${below}`;
102
+
103
+ const all = published();
104
+ if (!all.length) {
105
+ console.error("deprecate-below: the registry listed no versions — refusing rather than reporting zero to deprecate.");
106
+ return 2;
107
+ }
108
+ if (!all.includes(below)) {
109
+ console.error(`deprecate-below: ${below} is not a published version of ${PKG}. `
110
+ + "Refusing: pointing readers at a version that does not exist is worse than leaving them unwarned.");
111
+ return 2;
112
+ }
113
+
114
+ const candidates = all.filter((v) => compareVersions(v, below) < 0);
115
+ console.log(`deprecate-below: ${all.length} published version(s); ${candidates.length} below ${below}.`);
116
+
117
+ let done = 0, skipped = 0, failed = 0;
118
+ for (const v of candidates) {
119
+ let existing;
120
+ try { existing = deprecationOf(v); }
121
+ catch (e) {
122
+ console.error(` ${v}: could NOT be read (${String(e.message).split("\n")[0]}) — left alone.`);
123
+ failed += 1;
124
+ continue;
125
+ }
126
+ if (existing) { console.log(` ${v}: already deprecated — left alone ("${existing.slice(0, 60)}")`); skipped += 1; continue; }
127
+ if (DRY) { console.log(` ${v}: would deprecate`); done += 1; continue; }
128
+ try {
129
+ npm("deprecate", `${PKG}@${v}`, MESSAGE);
130
+ } catch (e) {
131
+ console.error(` ${v}: deprecate FAILED — ${String(e.stderr ?? e.message).split("\n")[0]}`);
132
+ failed += 1;
133
+ continue;
134
+ }
135
+ // READ BACK, PER VERSION, and report what the registry says rather than that the command exited 0.
136
+ let now = null;
137
+ try { now = deprecationOf(v); } catch { now = null; }
138
+ if (now) { console.log(` ${v}: deprecated — registry reads back "${now.slice(0, 60)}"`); done += 1; }
139
+ else { console.error(` ${v}: deprecate reported success and the registry reads back NOTHING.`); failed += 1; }
140
+ }
141
+
142
+ console.log(`deprecate-below: ${done} deprecated, ${skipped} already carried a message, ${failed} failed.`);
143
+ return failed ? 1 : 0;
144
+ }
145
+
146
+ if (process.argv[1] && process.argv[1].endsWith("deprecate-below.mjs")) process.exit(main());
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // release-approve-parked.mjs — approve the version pull request's parked CI run, so a cut does not wait
6
+ // on a person.
7
+ //
8
+ // WHY A PERSON HAS BEEN CLICKING. The version pull request is authored by the repository's own Actions
9
+ // bot, and a run triggered by a bot-authored pull request arrives `action_required`. Measured across the
10
+ // last forty `pull_request` CI runs: every parked one is on the version branch and no human-authored
11
+ // branch parks, so the thing that distinguishes them is the author, not a repository setting. The
12
+ // approval endpoint works on such a run — proved against a live parked run, which moved from
13
+ // `action_required` to `queued` — even though its documentation names fork pull requests only.
14
+ //
15
+ // AND WHY IT NEEDS A TOKEN RATHER THAN THE BUILT-IN ONE. `GITHUB_TOKEN` cannot approve a workflow run;
16
+ // GitHub blocks self-approval deliberately. So this reads a separate token with Actions read and write.
17
+ //
18
+ // WHAT THAT PERMISSION ACTUALLY BUYS, stated because the next person deciding whether to reuse this
19
+ // secret will read this sentence and not the permission page. Actions write is not "approve runs": it
20
+ // also creates workflow_dispatch events, cancels any run in the repository, and deletes run logs. This
21
+ // workflow carries `workflow_dispatch`, so the token can reach a PUBLISH by the same door a person
22
+ // uses. It cannot push code, open or merge a pull request, or authenticate to the registry — the
23
+ // publish credential is minted per run from this workflow's OIDC token and stored nowhere. So the
24
+ // boundary is real but it is not "it can only do what this script does".
25
+ //
26
+ // IT NEVER FAILS THE CUT. No token, no parked run, an API that refuses — each prints what happened and
27
+ // exits 0, because the cut's own wait still ends the way it always did: a person clicks, or the wait
28
+ // expires. A step that could turn a green cut red in order to save a click would be a worse trade than
29
+ // the click.
30
+ //
31
+ // BY SHA, AND ONLY THE CURRENT ONE. Approving a STALE parked run cancels the live one — CI's concurrency
32
+ // is workflow plus ref with cancel-in-progress on non-main refs — so this resolves the version branch's
33
+ // head at the moment it runs and approves only a run whose `head_sha` equals it.
34
+ //
35
+ // THE INTERVAL THAT MATTERS IS BETWEEN THE READ AND THE POST. Filtering the run list by head_sha and
36
+ // re-checking head_sha per row guards a case the API already guarantees; no row can disagree with the
37
+ // value it was queried by. The way this goes wrong is the HEAD going stale: the version step
38
+ // force-pushes that branch whenever it runs, so a push landing between the list and the approval leaves
39
+ // this approving the run on the superseded head — the exact act that cancels the live cut. The head is
40
+ // therefore re-read immediately before each approval, and a move aborts the whole pass rather than
41
+ // skipping one row, because if the branch moved then every id in the list is stale.
42
+ import { argv, env, exit } from "node:process";
43
+ import { pathToFileURL } from "node:url";
44
+
45
+ const REPO = env.GITHUB_REPOSITORY || "CordilleraSarl/clearotron";
46
+ const BRANCH = arg("--branch") || "changeset-release/main";
47
+ const TOKEN = env.ACTIONS_APPROVE_TOKEN || "";
48
+
49
+ function arg(name) { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1]; }
50
+ const say = (s) => process.stdout.write(`release-approve-parked: ${s}\n`);
51
+
52
+ async function api(path, init = {}) {
53
+ const r = await fetch(`https://api.github.com${path}`, {
54
+ ...init,
55
+ headers: {
56
+ accept: "application/vnd.github+json",
57
+ authorization: `Bearer ${TOKEN}`,
58
+ "x-github-api-version": "2022-11-28",
59
+ ...(init.headers ?? {}),
60
+ },
61
+ });
62
+ return r;
63
+ }
64
+
65
+ /**
66
+ * THE DECISION, SEPARATED FROM THE NETWORK so it can be driven against a table rather than matched as
67
+ * source text. Given the runs on a head and that head, which run ids should be approved?
68
+ *
69
+ * `event` AND `conclusion` both: the dispatched CI run on the same head is not the one the pull
70
+ * request's rollup reads, and approving it would do nothing while reading as success. PURE.
71
+ */
72
+ export function runsToApprove(runs, head) {
73
+ return (Array.isArray(runs) ? runs : [])
74
+ .filter((r) => r?.event === "pull_request" && r?.conclusion === "action_required" && r?.head_sha === head)
75
+ .map((r) => r.id);
76
+ }
77
+
78
+ // The run list is PAGED, and a silent truncation here reads as "nothing parked". 100 is the API maximum;
79
+ // the loop stops when a page comes back short, and says so if it ever hits the cap, because a cut that
80
+ // was not approved because the list was cut off should not look like a cut with nothing to approve.
81
+ async function allRunsOn(head) {
82
+ const out = [];
83
+ for (let page = 1; page <= 5; page++) {
84
+ const r = await api(`/repos/${REPO}/actions/runs?head_sha=${head}&per_page=100&page=${page}`);
85
+ if (!r.ok) return { ok: false, status: r.status, runs: out };
86
+ const batch = (await r.json())?.workflow_runs ?? [];
87
+ out.push(...batch);
88
+ if (batch.length < 100) return { ok: true, runs: out };
89
+ }
90
+ say(`more than 500 runs on this head — reading the first 500 only.`);
91
+ return { ok: true, runs: out };
92
+ }
93
+
94
+ // One reading of "where is the version branch now", used twice: once to choose, once immediately before
95
+ // the approval. Null means the question could not be answered, which is never treated as "unchanged".
96
+ async function branchHead() {
97
+ const br = await api(`/repos/${REPO}/branches/${encodeURIComponent(BRANCH)}`);
98
+ if (!br.ok) return null;
99
+ return (await br.json())?.commit?.sha ?? null;
100
+ }
101
+
102
+ async function main() {
103
+ // THE ABSENT SECRET IS THE ORDINARY CASE UNTIL THE TOKEN IS MINTED, and it must read as ordinary.
104
+ if (!TOKEN) {
105
+ say("no ACTIONS_APPROVE_TOKEN — nothing approved; the version run waits for a person, as before.");
106
+ return 0;
107
+ }
108
+
109
+ const head = await branchHead();
110
+ if (!head) { say(`could not read ${BRANCH} — nothing approved.`); return 0; }
111
+ say(`${BRANCH} is at ${head.slice(0, 7)}`);
112
+
113
+ const rr = await allRunsOn(head);
114
+ if (!rr.ok) { say(`could not list runs for ${head.slice(0, 7)} (${rr.status}) — nothing approved.`); return 0; }
115
+ const ids = runsToApprove(rr.runs, head);
116
+ if (!ids.length) {
117
+ const others = rr.runs.filter((r) => r?.conclusion === "action_required").length;
118
+ say(`no parked pull_request run on ${head.slice(0, 7)}${others ? ` (${others} parked on another event or head, left alone)` : ""} — nothing to approve.`);
119
+ return 0;
120
+ }
121
+
122
+ for (const id of ids) {
123
+ // ── THE INTERVAL THAT MATTERS IS BETWEEN THE READ AND THE POST, NOT INSIDE THE LIST ──────────────
124
+ //
125
+ // Filtering the list by head_sha and then re-checking head_sha on each row guards a case the API
126
+ // already guarantees: no row can disagree with the value it was queried by. What can actually go
127
+ // wrong is `head` itself going stale — the version step force-pushes this branch whenever it runs,
128
+ // so a push landing between the list and this POST leaves us approving the run on the SUPERSEDED
129
+ // head. That is precisely the act that cancels the live cut, because CI's concurrency is workflow
130
+ // plus ref with cancel-in-progress on non-main refs.
131
+ //
132
+ // So the head is re-read immediately before each approval, and a move aborts rather than skips: if
133
+ // the branch has moved, every id in this list is stale, not just this one.
134
+ const now = await branchHead();
135
+ if (now == null) { say(`could not re-read ${BRANCH} before approving — nothing approved.`); return 0; }
136
+ if (now !== head) {
137
+ say(`${BRANCH} moved ${head.slice(0, 7)} -> ${now.slice(0, 7)} since the run list was taken — ` +
138
+ `NOTHING APPROVED. Approving a run on the superseded head would cancel the live one; the cut ` +
139
+ `waits for a person, or for the next dispatch.`);
140
+ return 0;
141
+ }
142
+ const a = await api(`/repos/${REPO}/actions/runs/${id}/approve`, { method: "POST" });
143
+ say(a.ok ? `approved run ${id} on ${head.slice(0, 7)}.` : `run ${id} refused approval (${a.status}) — the cut still waits for a person.`);
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ // RUN WHEN INVOKED, whatever the file is called. The old guard compared argv[1] to this file's NAME, so
149
+ // a rename made the step print nothing and exit 0 — a script that never ran, wearing the face of a
150
+ // successful no-op. Comparing the resolved URLs asks the real question instead.
151
+ if (import.meta.url === pathToFileURL(argv[1] ?? "").href) main().then((c) => exit(c)).catch((e) => { say(`could not run (${e?.message ?? e}) — nothing approved.`); exit(0); });
@@ -93,6 +93,24 @@ const PREFIX = "ct-testrun-";
93
93
  // inside its parent's root, or the parent's cleanup removes a live child's fixtures.
94
94
  const REAL_TMP = process.env.CT_TEST_TMP_BASE || tmpdir();
95
95
 
96
+ // ── THE MACHINE'S OWN TEMP ROOT, WHICH IS NOT THE SAME QUESTION AS `REAL_TMP` ──────────────────────
97
+ //
98
+ // `REAL_TMP` answers "where does THIS run put its root", and a caller may redirect it — the runner's own
99
+ // tests do, giving each case a sandbox base so they never read or write the real temp directory. That is
100
+ // correct for rooting and wrong for CONTAINMENT: redirecting it also removed the machine's temp
101
+ // directory from the containment list, and a nested run was then refused for being pointed at a sibling
102
+ // temp root that is perfectly contained.
103
+ //
104
+ // IT ONLY EVER WORKED BY WAY OF THE LITERAL `/tmp`. In CI `TMPDIR` is unset, every root is under `/tmp`,
105
+ // and the literal in `PLATFORM_TMP` below covered the sibling. On a box whose temp directory is anywhere
106
+ // else — this one exports `TMPDIR=/mnt/datadisk1/tmp` — nothing in the list covered it, so ten arms that
107
+ // drive the runner recursively failed here and passed in CI on the same commit. The guard was not
108
+ // protecting a differently-rooted box at all; it was refusing it.
109
+ //
110
+ // So this is read ONCE, at the outermost run, before `TMPDIR` is rewritten, and passed down untouched.
111
+ // A caller's sandbox base does not displace it, because it is not answering that caller's question.
112
+ const MACHINE_TMP = String(process.env.CT_TEST_MACHINE_TMP ?? "").trim() || tmpdir();
113
+
96
114
  // A run killed with SIGKILL (or a machine that reboots) never reaches its own cleanup and leaves one
97
115
  // directory behind. One per killed run is a tractable number, but it should not accumulate forever, so
98
116
  // each run clears the abandoned roots of previous ones. Bounded three ways: the exact prefix we own, an
@@ -372,7 +390,7 @@ const DATA_PLANE_VARS = Object.freeze([
372
390
  // or /srv, never a temp filesystem.
373
391
  const PLATFORM_TMP = process.platform === "win32" ? [] : ["/tmp"];
374
392
  const CONTAINMENT_ROOTS = Object.freeze([...new Set(
375
- [REAL_TMP, tmpdir(), ...PLATFORM_TMP].map((p) => resolve(p)),
393
+ [REAL_TMP, MACHINE_TMP, tmpdir(), ...PLATFORM_TMP].map((p) => resolve(p)),
376
394
  )]);
377
395
  const isContained = (value) => {
378
396
  const p = resolve(value);
@@ -689,6 +707,11 @@ child = spawn(argv[0], argv.slice(1), {
689
707
  // the one the top of this file reads before TMPDIR is rewritten, so the chain stays anchored to the
690
708
  // real temp directory however deep the nesting goes. A caller's own value wins, as everywhere else.
691
709
  CT_TEST_TMP_BASE: String(process.env.CT_TEST_TMP_BASE ?? "").trim() || REAL_TMP,
710
+ // AND THE MACHINE'S TEMP ROOT, carried down unchanged however deep the nesting goes. Unlike the line
711
+ // above it this one is NOT a caller's to redirect: a caller redirecting where a run roots itself is
712
+ // saying nothing about which filesystem locations count as temporary, and conflating the two is what
713
+ // made a correctly contained sibling read as somebody's live estate.
714
+ CT_TEST_MACHINE_TMP: MACHINE_TMP,
692
715
  TRADEMARK_MCP_AUDIT_LOG: String(process.env.TRADEMARK_MCP_AUDIT_LOG ?? "").trim()
693
716
  || join(root, "mcp-access.jsonl"),
694
717
  // AND THE PORTAL'S AUDIT LOG, for the same reason and by the same rule as the line above it.
@@ -105,9 +105,11 @@ const BRIEF = "ask it to brief you on your clearances.";
105
105
  export const DOOR_KINDS = Object.freeze(["sign-in", "key"]);
106
106
  const SIGNIN_HINT = "No key: this connector signs you in through your browser, and the sign-in is the "
107
107
  + "authentication your assistant is asking about.";
108
- const UNKNOWN_DOOR_HINT = "This deployment's connector could not be read just now, so both ways are "
109
- + "shown: sign-in is what a hosted connector answers, a key is what a self-hosted one takes. Try the "
110
- + "sign-in first — an assistant that needs a key will say so.";
108
+ // THE UNKNOWN DOOR IS THE PAGE'S SENTENCE NOW, NOT A STEP HINT. This hint said "both ways are shown"
109
+ // while one set was drawn — a promise the page could not keep — and it said it in the words that screen
110
+ // is not allowed to show a reader. The panel states it once, above both lists, and the lists make it
111
+ // true. A hint under step one repeating it would be the same sentence twice, the second time in
112
+ // vocabulary the reader did not ask for.
111
113
 
112
114
  /**
113
115
  * Every app we can speak to, and the steps for each route. Adding one is a row.
@@ -159,7 +161,7 @@ export const CONNECT_CLIENTS = Object.freeze([
159
161
  ] : [
160
162
  // THE SIGN-IN DOOR. No key is minted and no header is set: the warning the old steps told the
161
163
  // reader to ignore IS the sign-in, and following it is the whole of the connection.
162
- { text: "Copy the address.", copy: "address", hint: door === null ? UNKNOWN_DOOR_HINT : SIGNIN_HINT },
164
+ { text: "Copy the address.", copy: "address", hint: door === null ? undefined : SIGNIN_HINT },
163
165
  { text: "In Claude, open **Settings → Connectors → Add custom connector**." },
164
166
  { text: "Paste the address and press **Add**." },
165
167
  { text: `Sign in when the browser opens — use ${operator ?? "your work email"}.` },
@@ -185,7 +187,7 @@ export const CONNECT_CLIENTS = Object.freeze([
185
187
  { text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
186
188
  ] : [
187
189
  // The command carries no header, because a door that signs its reader in never honours one.
188
- { text: "Copy this command.", copy: "claude-cli-http-signin", hint: door === null ? UNKNOWN_DOOR_HINT : SIGNIN_HINT },
190
+ { text: "Copy this command.", copy: "claude-cli-http-signin", hint: door === null ? undefined : SIGNIN_HINT },
189
191
  { text: "Paste it into a terminal and press Enter." },
190
192
  { text: "Sign in when the browser opens." },
191
193
  { text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
@@ -220,7 +222,7 @@ export const CONNECT_CLIENTS = Object.freeze([
220
222
  { text: "Add a custom connector and paste the address." },
221
223
  { text: "Give the key as the connector's bearer token — the second line." },
222
224
  ] : [
223
- { text: "Copy the address.", copy: "address", hint: door === null ? UNKNOWN_DOOR_HINT : undefined },
225
+ { text: "Copy the address.", copy: "address", hint: undefined },
224
226
  { text: "In ChatGPT on the web, turn on **Settings → Security and login → Developer mode**.",
225
227
  hint: "Needs a Plus, Pro, Business, Enterprise or Edu plan. On a company plan, your admin may have to allow it." },
226
228
  { text: "Add a custom connector and paste the address." },
@@ -252,7 +254,7 @@ export const CONNECT_CLIENTS = Object.freeze([
252
254
  hint: "Codex reads the key from there, so it never sits in the settings file." },
253
255
  { text: `Restart Codex and ${BRIEF}` },
254
256
  ] : [
255
- { text: "Copy this.", copy: "codex-toml-http-signin", hint: door === null ? UNKNOWN_DOOR_HINT : SIGNIN_HINT },
257
+ { text: "Copy this.", copy: "codex-toml-http-signin", hint: door === null ? undefined : SIGNIN_HINT },
256
258
  { text: "Open `~/.codex/config.toml` and paste it at the end." },
257
259
  // ITS OWN STEP, not a hint on the one before it. The sign-in IS the connection here, and a
258
260
  // reader skimming numbered steps does not read the small print under one of them.
@@ -282,7 +284,7 @@ export const CONNECT_CLIENTS = Object.freeze([
282
284
  { text: "Paste the address and the key wherever your app adds a custom MCP server.",
283
285
  hint: "It may call them “server URL” and “bearer token”." },
284
286
  ] : [
285
- { text: "Copy the address.", copy: "address", hint: door === null ? UNKNOWN_DOOR_HINT : SIGNIN_HINT },
287
+ { text: "Copy the address.", copy: "address", hint: door === null ? undefined : SIGNIN_HINT },
286
288
  { text: "Paste it wherever your app adds a custom MCP server, and sign in when the browser opens.",
287
289
  hint: "It may call the address the “server URL”. There is no token to give it." },
288
290
  ]),
@@ -311,14 +313,24 @@ export const offersForWire = (offers) =>
311
313
  id: client.id,
312
314
  name: client.name,
313
315
  ...(client.sub ? { sub: client.sub } : {}),
314
- steps: (Array.isArray(steps) ? steps : []).map((s) => ({
315
- text: s.text,
316
- ...(s.hint ? { hint: s.hint } : {}),
317
- ...(s.copy ? { copy: s.copy.kind === "secret"
318
- ? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
319
- : { kind: "block", text: s.copy.text } } : {}),
320
- })),
316
+ steps: stepsForWire(steps),
321
317
  ...rest,
318
+ // THE ALTERNATIVE TRAVELS, AND SO DOES THE FACT THAT THE DOOR IS UNKNOWN. `...rest` carried neither:
319
+ // `altSteps` needs the same copy-shape mapping the primary set gets, and `door` was dropped on the
320
+ // floor, so the page had nothing to branch on and drew one set of steps as though the door had been
321
+ // read. Both are stated after the spread so a row cannot pass its own raw shape through.
322
+ ...(Array.isArray(rest.altSteps) ? { altSteps: stepsForWire(rest.altSteps) } : {}),
323
+ ...(Object.hasOwn(rest, "door") ? { door: rest.door ?? null } : {}),
324
+ }));
325
+
326
+ /** One route's steps, in the shape the browser reads. The alternative set gets the same mapping. */
327
+ const stepsForWire = (steps) =>
328
+ (Array.isArray(steps) ? steps : []).map((s) => ({
329
+ text: s.text,
330
+ ...(s.hint ? { hint: s.hint } : {}),
331
+ ...(s.copy ? { copy: s.copy.kind === "secret"
332
+ ? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
333
+ : { kind: "block", text: s.copy.text } } : {}),
322
334
  }));
323
335
 
324
336
  const ALIAS_ROUTE = new Map(CONNECT_CLIENTS.flatMap((c) =>
@@ -395,6 +407,30 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
395
407
  const asked = author.steps({ operator, door: route === "public-http" ? door : null });
396
408
  const steps = asked.map((s) => (s.copy ? { ...s, copy: resolve(s.copy) } : { ...s }));
397
409
  const resolved = steps.every((s, i) => !asked[i].copy || s.copy);
410
+
411
+ // ── WHEN THE DOOR COULD NOT BE READ, BOTH WAYS ARE ACTUALLY SHOWN ────────────────────────────────
412
+ //
413
+ // `doorKind` returns null for "not known" and its own contract says the caller offers both rather than
414
+ // guessing. The steps above already carry the sentence that says so — and it reads "both ways are
415
+ // shown" while one set was rendered. A page that promises the reader the alternative and then does not
416
+ // draw it is worse than one that never mentioned it: the reader goes looking for what they were told
417
+ // is there.
418
+ //
419
+ // So the OTHER door's steps are composed here and ride beside them. The sign-in set leads, because
420
+ // that is what every hosted connector answers and what the hint tells the reader to try first; the key
421
+ // set is the alternative rather than a second equal choice. Composed by asking the same author with
422
+ // the other answer, never by a second copy of the steps — one table, one author, as everything else in
423
+ // this file.
424
+ const altAsked = route === "public-http" && door === null
425
+ ? author.steps({ operator, door: "key" })
426
+ : null;
427
+ const altSteps = altAsked
428
+ ? altAsked.map((s) => (s.copy ? { ...s, copy: resolve(s.copy) } : { ...s }))
429
+ : null;
430
+ // A step whose copy this deployment cannot produce is not offered at all — the same rule the primary
431
+ // set follows two lines up. Half an alternative is a reader following steps that stop.
432
+ const altResolved = !altAsked || altSteps.every((s, i) => !altAsked[i].copy || s.copy);
433
+ const alt = altResolved && altSteps?.length ? { door: null, altSteps } : { ...(route === "public-http" ? { door } : {}) };
398
434
  const evidence = { ...(author.verifiedOn ? { verifiedOn: author.verifiedOn } : {}), ...(author.by ? { by: author.by } : {}) };
399
435
 
400
436
  if (route === "disk") {
@@ -431,7 +467,7 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
431
467
  fix: "whoever installed it can put it online — it takes about a minute and needs no account",
432
468
  operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" };
433
469
  }
434
- return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
470
+ return { client, served: true, route, steps, ...alt, launch: client.launch ?? null, enables: null, ...evidence,
435
471
  command: null, stdio: null, address: publicAddress, key: "issued",
436
472
  note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." };
437
473
  }
@@ -35,7 +35,7 @@
35
35
  // its own population needs an exception list — which would rebuild this issue's defect inside its fix:
36
36
  //
37
37
  // · `driver/stage-freshness.mjs` creates a CHILD, `join(runDir, "_driver", STAMP_DIR)`.
38
- // · `driver/pipeline.mjs:15053` passes a shadow dispatch sandbox under `_experiments/`, not a run
38
+ // · `driver/pipeline.mjs:15755 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
39
39
  // directory. It is a run-dir-SHAPED base, which is why the parameter is `base` and not `runDir`.
40
40
  //
41
41
  // ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────