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.
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +83 -0
- package/driver/citation-census.json +3 -3
- package/driver/common-law-receipts.mjs +11 -1
- package/driver/connotation-search.mjs +56 -5
- package/driver/contract-e3-backlog.mjs +31 -31
- package/driver/contract-vocabulary.mjs +59 -58
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/gateway.mjs +29 -2
- package/driver/matter-frame-record.mjs +54 -1
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +43 -4
- package/driver/stages.mjs +17 -3
- package/driver/suite-census.json +44 -8
- package/driver/verify.mjs +83 -7
- package/mcp-server/CHANGELOG.md +19 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-C-QysgZM.js → index-ChIQsMYp.js} +209 -103
- package/portal-ui/dist/assets/{index-CaSZbEMb.css → index-DBIs21e4.css} +22 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/citation-line-check.mjs +53 -2
- package/scripts/deprecate-below.mjs +146 -0
- package/scripts/release-approve-parked.mjs +151 -0
- package/scripts/test-run.mjs +24 -1
- package/shared/connect-clients.mjs +52 -16
- package/shared/driver-dir.mjs +1 -1
|
@@ -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); });
|
package/scripts/test-run.mjs
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 ?
|
|
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 ?
|
|
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:
|
|
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 ?
|
|
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 ?
|
|
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: (
|
|
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
|
}
|
package/shared/driver-dir.mjs
CHANGED
|
@@ -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:
|
|
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 ────────────────────────────────────────────────────────────
|