clearotron 0.3.0-beta.5 → 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.
- package/.env.example +9 -0
- package/INSTALL.md +11 -0
- package/bin/connect.mjs +3 -1
- package/bin/onboard.mjs +38 -18
- package/bin/start.mjs +26 -2
- package/bin/update.mjs +38 -6
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +1 -0
- package/driver/CHANGELOG.md +17 -0
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/demo-container.mjs +30 -2
- package/driver/engine/openai-agent.mjs +8 -0
- package/driver/enqueue-schema.mjs +3 -1
- package/driver/gateway.mjs +12 -0
- package/driver/package.json +1 -1
- package/driver/portal-local-auth.mjs +35 -3
- package/driver/portal-service.mjs +80 -18
- package/driver/portal-upstream.mjs +11 -1
- package/driver/search-policy.mjs +7 -2
- package/driver/suite-census.json +49 -25
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/CONNECT.md +3 -2
- package/mcp-server/lib/options.mjs +9 -4
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e.mjs +84 -12
- package/scripts/env-classify.mjs +3 -0
- package/shared/connect-clients.mjs +8 -0
- package/shared/names-in-force.mjs +1 -0
- package/shared/permanent-install.mjs +167 -0
- package/shared/stdio-connect.mjs +32 -14
- package/shared/verb-shim.mjs +10 -1
- package/shared/wsl.mjs +23 -0
package/.env.example
CHANGED
|
@@ -399,6 +399,15 @@ CLEAROTRON_DEMO=
|
|
|
399
399
|
# effect: deployment
|
|
400
400
|
# CLEAROTRON_INVOKED_AS=
|
|
401
401
|
|
|
402
|
+
# Marks an install that has already moved itself out of npm's npx cache. `npx clearotron install` first
|
|
403
|
+
# installs the same version under ~/.local and then runs that copy's install, with this set to 1, so the
|
|
404
|
+
# copy does not try to move itself a second time. Unset, an install running from npx's cache moves.
|
|
405
|
+
# Set by bin/onboard.mjs on the one process it starts, never by an operator, so the row is left
|
|
406
|
+
# commented. It is listed for the reason CLEAROTRON_DEMO is: the catalogue is about what an operator can
|
|
407
|
+
# FIND. Read by bin/onboard.mjs.
|
|
408
|
+
# effect: deployment
|
|
409
|
+
# CLEAROTRON_RELOCATED=
|
|
410
|
+
|
|
402
411
|
# How many client what-ifs the runner drains at once (default 1). A what-if re-runs ONE stage of a run
|
|
403
412
|
# that already exists, in a sandbox, at a client's request — the owner opened it to clients on
|
|
404
413
|
# 2026-08-27 and ruled SPEND controls out with it ("ignore the call spend"). This is not one.
|
package/INSTALL.md
CHANGED
|
@@ -64,6 +64,15 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
|
|
|
64
64
|
npx clearotron install
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
Run from `npx`, the install first installs Clearotron under `~/.local`, as `npm install -g --prefix
|
|
68
|
+
~/.local` would, and finishes from there. That way the `clearotron` command and your assistant's connection
|
|
69
|
+
do not point into npm's temporary cache, which npm replaces on an update and deletes when it cleans up.
|
|
70
|
+
Later, the `update` command updates that copy in place.
|
|
71
|
+
|
|
72
|
+
On WSL, a program on the Windows side can hold a port that WSL reports as free, and the browser reaches
|
|
73
|
+
it first. VS Code's Remote-SSH port forwarding is the common case. If the page that opens is not this
|
|
74
|
+
install's sign-in, run the same command again with `--port 28802`, or any free number.
|
|
75
|
+
|
|
67
76
|
A *hosted* deployment needs Linux for one further thing, the systemd outbox trigger —
|
|
68
77
|
[driver/systemd/README.md](driver/systemd/README.md).
|
|
69
78
|
- **A reasoning CLI on your `PATH`, signed in.** This is the prerequisite people miss. Every stage runs
|
|
@@ -754,6 +763,8 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
|
|
|
754
763
|
- Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
|
|
755
764
|
and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
|
|
756
765
|
of the two you ran first, the other finds the same install. Move it with `npx clearotron start --base <dir>`.
|
|
766
|
+
That does not move anything the env file already names: the saved-search lines above, and the data
|
|
767
|
+
directories `npx clearotron install` wrote, keep pointing at the old place until you edit them.
|
|
757
768
|
- Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest in
|
|
758
769
|
`~/trademark/portal-local-credential.json`, nothing can read it back, and no later start reprints it. To
|
|
759
770
|
get a new one, run `clearotron passphrase --reset`. An install that has been signing in with
|
package/bin/connect.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { execFileSync } from "node:child_process";
|
|
|
47
47
|
import { createServer } from "node:net";
|
|
48
48
|
import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor, plainStep, whatItNeeds } from "../shared/connect-clients.mjs";
|
|
49
49
|
import { stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs";
|
|
50
|
+
import { isWsl } from "../shared/wsl.mjs";
|
|
50
51
|
import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
|
|
51
52
|
import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
|
|
52
53
|
import { envFrom } from "../shared/env-aliases.mjs";
|
|
@@ -205,7 +206,8 @@ function deploymentHas(env = process.env) {
|
|
|
205
206
|
return {
|
|
206
207
|
// EVERY SHAPE, RESOLVED ONCE. A row picks its own; nothing here knows a client's name.
|
|
207
208
|
stdioRoutes: Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
|
|
208
|
-
[shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null })])),
|
|
209
|
+
[shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null, reportsDir: env.CLEAROTRON_REPORTS_DIR || null })])),
|
|
210
|
+
wsl: isWsl({ env }),
|
|
209
211
|
// WHERE THE DOOR BINDS — not an address handed to any assistant. It is the loopback address the
|
|
210
212
|
// unit listens on, and `enablePlan` needs it to write the unit. It used to be passed to the
|
|
211
213
|
// resolver as `localAddress` and served to Cowork as somewhere to connect, which is the false
|
package/bin/onboard.mjs
CHANGED
|
@@ -68,6 +68,8 @@ import { nodeFloorVerdict } from "../shared/node-floor.mjs"; // — the floor
|
|
|
68
68
|
import { invocationForm } from "../shared/invocation.mjs"; // — and WHY that form
|
|
69
69
|
import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm replaces?
|
|
70
70
|
import { installShim } from "../shared/verb-shim.mjs"; // — the verb goes on PATH
|
|
71
|
+
import { relocationPlan } from "../shared/permanent-install.mjs"; // — and the program out of npx's cache
|
|
72
|
+
import { isWsl } from "../shared/wsl.mjs"; // — one answer to "is this WSL", shared with the connect lines
|
|
71
73
|
import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight where the meaning is
|
|
72
74
|
import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
|
|
73
75
|
// THE REFUSALS ABOUT THE SIGN-IN ADDRESS ITSELF, shared with `bin/start.mjs`. Two copies would be a
|
|
@@ -703,23 +705,8 @@ export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindows
|
|
|
703
705
|
/** A path on a Windows drive as WSL mounts it. */
|
|
704
706
|
export const ON_A_WINDOWS_DRIVE = /^\/mnt\/[a-z]\//i;
|
|
705
707
|
|
|
706
|
-
/**
|
|
707
|
-
|
|
708
|
-
*
|
|
709
|
-
* BOTH SIGNALS INJECTABLE, for the reason `platformEngineRefusal` gives: the readers this protects
|
|
710
|
-
* are the ones who cannot run this suite to find out, so a Linux runner has to be able to drive both
|
|
711
|
-
* answers rather than read the source and agree with it.
|
|
712
|
-
*
|
|
713
|
-
* A READ THAT FAILS ANSWERS "NOT WSL", and that is the direction that changes nothing: it leaves the
|
|
714
|
-
* resolution exactly as it was before this existed. Claiming WSL on a could-not-read would start
|
|
715
|
-
* refusing candidates under /mnt on an ordinary Linux box with an ordinary mount.
|
|
716
|
-
*/
|
|
717
|
-
export function isWsl({ env = process.env, procVersion = null } = {}) {
|
|
718
|
-
if (String(env.WSL_DISTRO_NAME ?? "").trim()) return true;
|
|
719
|
-
if (String(env.WSL_INTEROP ?? "").trim()) return true;
|
|
720
|
-
const v = procVersion ?? (() => { try { return readFileSync("/proc/version", "utf8"); } catch { return ""; } })();
|
|
721
|
-
return /microsoft|wsl/i.test(v);
|
|
722
|
-
}
|
|
708
|
+
/** Whether this is a Linux running under Windows: the one answer, from shared/wsl.mjs. */
|
|
709
|
+
export { isWsl };
|
|
723
710
|
|
|
724
711
|
/**
|
|
725
712
|
* What to say about candidates passed over because they sit on a Windows drive — or `null` when none
|
|
@@ -1860,7 +1847,7 @@ export async function runCheck() {
|
|
|
1860
1847
|
try { loadRecipes({ dir: recipesDir, force: true }); } catch (e) { unreadable = String(e?.message ?? e).split("\n")[0]; }
|
|
1861
1848
|
if (unreadable) {
|
|
1862
1849
|
warn(`saved searches cannot be read from ${recipesDir}: ${unreadable}. Every company's saved searches fail `
|
|
1863
|
-
+ "to load, in the portal and the connector, until
|
|
1850
|
+
+ "to load, in the portal and the connector, until it is fixed");
|
|
1864
1851
|
} else ok(`saved searches are read from ${recipesDir}${handedBy}, and saves are committed in ${reach.repo}`);
|
|
1865
1852
|
}
|
|
1866
1853
|
}
|
|
@@ -2933,6 +2920,39 @@ if (!input.isTTY) {
|
|
|
2933
2920
|
process.exit(2);
|
|
2934
2921
|
}
|
|
2935
2922
|
|
|
2923
|
+
// ── OUT OF NPX'S CACHE, BEFORE ANYTHING IS WRITTEN ─────────────────────────────────────────────────────
|
|
2924
|
+
//
|
|
2925
|
+
// Run from npx, this program lives in npm's cache, and everything below would be wired to a directory npm
|
|
2926
|
+
// deletes: the launcher, and the connect line an assistant is registered with. So the install first puts
|
|
2927
|
+
// this same version somewhere permanent (shared/permanent-install.mjs) and runs itself from there. Nothing
|
|
2928
|
+
// has been written yet, so a failure here costs nothing, and it stops rather than carrying on: an install
|
|
2929
|
+
// finished from the cache is the defect, not a fallback.
|
|
2930
|
+
const move = relocationPlan();
|
|
2931
|
+
if (move && !move.skip && process.env.CLEAROTRON_RELOCATED !== "1") {
|
|
2932
|
+
say(`\n This is running from npm's temporary npx cache. Installing clearotron ${move.version} to ${move.prefix}`);
|
|
2933
|
+
say(" first, so the launcher and your assistants keep working after npm cleans that cache or you update.\n");
|
|
2934
|
+
// The npm that launched this, when npm says which: no second npm is guessed at.
|
|
2935
|
+
const npmCli = process.env.npm_execpath;
|
|
2936
|
+
const r = npmCli && existsSync(npmCli)
|
|
2937
|
+
? spawnSync(process.execPath, [npmCli, ...move.npmArgs], { stdio: "inherit" })
|
|
2938
|
+
: spawnSync("npm", move.npmArgs, { stdio: "inherit" });
|
|
2939
|
+
if (r.status !== 0 || !existsSync(move.entry)) {
|
|
2940
|
+
console.error(`\n Could not install clearotron to ${move.prefix}${r.error ? ` (${r.error.message})` : ""}. Nothing was installed, and nothing of yours was changed.`);
|
|
2941
|
+
console.error(` Run \`npm install --global --prefix ${move.prefix} clearotron@${move.version}\`, then \`${join(move.prefix, "bin", "clearotron")} install\`.\n`);
|
|
2942
|
+
process.exit(1);
|
|
2943
|
+
}
|
|
2944
|
+
// THE REST OF THE INSTALL RUNS FROM THE PERMANENT COPY, with npm's marks of an npx arrival taken off, so
|
|
2945
|
+
// it prints the commands of the install it now is.
|
|
2946
|
+
const env = { ...process.env, CLEAROTRON_RELOCATED: "1" };
|
|
2947
|
+
for (const k of ["npm_command", "npm_lifecycle_event", "npm_execpath"]) delete env[k];
|
|
2948
|
+
const moved = spawnSync(process.execPath, [move.entry, "install", ...process.argv.slice(2)], { stdio: "inherit", env });
|
|
2949
|
+
process.exit(moved.status ?? 1);
|
|
2950
|
+
}
|
|
2951
|
+
if (move?.skip) {
|
|
2952
|
+
console.error(`\n Note: this is running from npm's temporary npx cache and cannot be moved out of it here (${move.skip}).`);
|
|
2953
|
+
console.error(" It will stop working when npm cleans that cache. `npm install -g clearotron` installs it permanently.\n");
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2936
2956
|
// A credential typed at a prompt is echoed by the terminal and then sits in scrollback, in tmux history,
|
|
2937
2957
|
// in whatever the reader pastes into a bug report. So the echo is muted while a secret is being typed:
|
|
2938
2958
|
// the output stream readline writes through drops everything while `muted` is set.
|
package/bin/start.mjs
CHANGED
|
@@ -300,6 +300,22 @@ export function resolvePorts(env = {}) {
|
|
|
300
300
|
client: one("CLIENT_MCP_HTTP_PORT", clientDoorPort({})) };
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* WHAT TO DO WHEN THE PAGE THAT OPENS IS NOT OURS. Printed under every "Open" line.
|
|
305
|
+
*
|
|
306
|
+
* A port can be free where this runs and taken where the browser runs: on WSL, a Windows-side listener
|
|
307
|
+
* (VS Code's Remote-SSH forwarding is the one measured, 2026-09-11) answers 127.0.0.1 before WSL does. The
|
|
308
|
+
* doors bind cleanly, the in-use detection has nothing to see, and the browser shows somebody else's page
|
|
309
|
+
* with nothing on this screen saying so. `--port` already moves all three doors; the reader has to be told
|
|
310
|
+
* about it at the moment the address is handed over, which is here.
|
|
311
|
+
*/
|
|
312
|
+
export function foreignPageHint(verb) {
|
|
313
|
+
return [
|
|
314
|
+
"If the page that opens is not this install's sign-in, another program on this machine holds that",
|
|
315
|
+
`port from outside this environment. Run \`${invoke(verb)} --port 28802\` (or any free number) instead.`,
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
|
|
303
319
|
/**
|
|
304
320
|
* Apply `--port <n>` to the three doors.
|
|
305
321
|
*
|
|
@@ -1370,8 +1386,12 @@ if (isMain) {
|
|
|
1370
1386
|
// SEEDED FROM A COPY, for the reason the player publishes from one: republishing writes a receipt
|
|
1371
1387
|
// into the run directory it reads, and `demo/` is tracked. This is the path a reader actually takes
|
|
1372
1388
|
// — `clearotron demo` hands over to this — so fixing the player alone left the defect where it was.
|
|
1373
|
-
const { publishSource } = await import("../driver/demo-container.mjs");
|
|
1389
|
+
const { publishSource, seedDemoRuns } = await import("../driver/demo-container.mjs");
|
|
1374
1390
|
const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
|
|
1391
|
+
// AND AS RUNS, so the assistant this demo's connect line wires has them to list, brief and open. Under
|
|
1392
|
+
// the demo's own workspace only: nothing of it reaches an install started afterwards.
|
|
1393
|
+
const runs = seedDemoRuns({ workspace: paths.workspace, examplesDir: join(REPO, "demo") });
|
|
1394
|
+
if (runs.seeded.length) say(` runs ${runs.seeded.length} sample run(s) your assistant can list, brief and open`);
|
|
1375
1395
|
// WHAT WAS ALREADY THERE IS SAID TOO. This branch used to run only when the pool
|
|
1376
1396
|
// was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
|
|
1377
1397
|
// about what was MISSING and says nothing on its own about how many are now listed.
|
|
@@ -1879,6 +1899,7 @@ if (isMain) {
|
|
|
1879
1899
|
}
|
|
1880
1900
|
say("");
|
|
1881
1901
|
say(` Open: ${envs.url}`);
|
|
1902
|
+
for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
|
|
1882
1903
|
say(" This SURVIVES the terminal — close the window, the product keeps running.");
|
|
1883
1904
|
say(` Stop it: ${invoke("stop")} (stops and removes the units; issued connect keys survive — \`${invoke("disconnect")}\` revokes those)`);
|
|
1884
1905
|
say(` Is it up? ${invoke("status")}`);
|
|
@@ -2112,6 +2133,7 @@ if (isMain) {
|
|
|
2112
2133
|
|
|
2113
2134
|
say("");
|
|
2114
2135
|
say(` Open ${envs.url}`);
|
|
2136
|
+
for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
|
|
2115
2137
|
say("");
|
|
2116
2138
|
// ── TWO DOORS, TWO AUDIENCES, BOTH NAMED ( — F26) ─────────────────────────
|
|
2117
2139
|
//
|
|
@@ -2209,7 +2231,9 @@ if (isMain) {
|
|
|
2209
2231
|
//
|
|
2210
2232
|
// The string comes from the ONE composer, not from a literal here: three surfaces state this route
|
|
2211
2233
|
// and a line of instruction with more than one author drifts silently.
|
|
2212
|
-
|
|
2234
|
+
// THE WORKSPACE AND POOL THE SERVICES WERE HANDED, not this process's environment: a demo reads no env
|
|
2235
|
+
// file, so its own line named no workspace and the connector fell back to the real install's.
|
|
2236
|
+
const connect = stdioConnectOffer({ workDir: paths.workspace, reportsDir: paths.pool });
|
|
2213
2237
|
say(" Connect your assistant to this install — one line, no address and no sign-in:");
|
|
2214
2238
|
say("");
|
|
2215
2239
|
say(` ${connect.command}`);
|
package/bin/update.mjs
CHANGED
|
@@ -59,6 +59,8 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-p
|
|
|
59
59
|
import { readEnvFile } from "./onboard.mjs";
|
|
60
60
|
import { invoke, invocationPrefix } from "../shared/invocation.mjs"; // — name a command the reader can actually type
|
|
61
61
|
import { rebuildIfStale } from "../shared/bundle-rebuild.mjs"; // a pull cannot update an untracked bundle
|
|
62
|
+
import { packagedUpdate } from "../shared/permanent-install.mjs"; // — a packaged install updates at its own prefix
|
|
63
|
+
import { installShim, inspectShim, shimPath } from "../shared/verb-shim.mjs"; // — npm's link replaces the launcher
|
|
62
64
|
|
|
63
65
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
64
66
|
const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
|
|
@@ -246,13 +248,22 @@ export async function update(argv = process.argv.slice(2)) {
|
|
|
246
248
|
// not succeed` — true, useless, and pointing at the wrong thing entirely. `update` is the verb a
|
|
247
249
|
// stranger reaches for, so the one install we expect most people to have must not be answered with
|
|
248
250
|
// a git error about a directory that was never a repository.
|
|
251
|
+
//
|
|
252
|
+
// A PACKAGED INSTALL NOW UPDATES ITSELF, the way it was installed: npm, at the prefix it lives under, on
|
|
253
|
+
// the channel it came from (shared/permanent-install.mjs). It runs AFTER the live-run refusal below,
|
|
254
|
+
// because npm replaces the program's files as surely as `npm ci` does. Only a layout that cannot be
|
|
255
|
+
// named is still refused.
|
|
256
|
+
let packaged = null;
|
|
249
257
|
if (!isGitCheckout()) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
packaged = packagedUpdate();
|
|
259
|
+
if (!packaged) {
|
|
260
|
+
console.error("\n This install is not a git checkout, so there is nothing to pull.");
|
|
261
|
+
console.error(" It was installed from a package rather than cloned, which is the ordinary way.");
|
|
262
|
+
console.error("\n Update it the way it was installed:\n");
|
|
263
|
+
console.error(" npm install -g clearotron@latest\n");
|
|
264
|
+
console.error(" Nothing was touched.");
|
|
265
|
+
return 4;
|
|
266
|
+
}
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
// ── AND THE SECOND REFUSAL: NOT OVER A LIVE RUN ──────────────────────────────────────────────
|
|
@@ -289,6 +300,27 @@ export async function update(argv = process.argv.slice(2)) {
|
|
|
289
300
|
return 4;
|
|
290
301
|
}
|
|
291
302
|
|
|
303
|
+
if (packaged) {
|
|
304
|
+
if (packaged.current) {
|
|
305
|
+
say(`\n This install is ${packaged.installed}, and nothing newer is published (${packaged.tag}: ${packaged.version}). Nothing was touched.\n`);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
if (packaged.unread) say(`\n npm did not say which versions are published, so this follows the ${packaged.tag} channel.`);
|
|
309
|
+
say(`\n Updating this install at ${packaged.prefix} from ${packaged.installed ?? "an unreadable version"} to clearotron@${packaged.spec}.`);
|
|
310
|
+
const rc = runInCheckout("npm", packaged.npmArgs);
|
|
311
|
+
if (rc !== 0) return rc;
|
|
312
|
+
// npm puts its own link back at `<prefix>/bin/clearotron` on every install, over the launcher the
|
|
313
|
+
// install wrote, and that link runs whichever `node` is first on PATH. Put the launcher back, but only
|
|
314
|
+
// over npm's link or our own: anything else there was not ours before this update either.
|
|
315
|
+
const kind = inspectShim(shimPath()).kind;
|
|
316
|
+
if (kind === "npm-link" || kind === "ours" || kind === "ours-other-install") {
|
|
317
|
+
const shim = installShim();
|
|
318
|
+
if (!shim.ok) console.error(`\n The update worked, but the launcher at ${shim.path ?? "~/.local/bin/clearotron"} could not be written back: ${shim.detail}.`);
|
|
319
|
+
}
|
|
320
|
+
say("\n Updated. An assistant starts the new version the next time it launches Clearotron; restart the services for the portal.\n");
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
292
324
|
say("\n Configuration store is outside the checkout. Updating the product.");
|
|
293
325
|
for (const e of entries) say(` ${e.name}=${e.value} (${e.from})`);
|
|
294
326
|
if (!entries.length) {
|
package/build-info.json
CHANGED
|
@@ -291,6 +291,7 @@ because the rule is about what PRODUCT CODE reads, not about what a run reads.
|
|
|
291
291
|
| `PORTAL_URL` | `http://127.0.0.1:18802`, or built from `PORTAL_SERVICE_HOST`/`PORTAL_SERVICE_PORT` | Where the deploy tick's live-surface check expects to reach the portal. |
|
|
292
292
|
| `PORTAL_OPS_TOKEN_FILE` | `~/.config/systemd/user/trademark-portal.service.d/secrets.conf` | The systemd drop-in the live-surface check reads `PORTAL_OPS_TOKEN` out of. It reads the FILE rather than the environment so a check run by hand sees the same token the service does. |
|
|
293
293
|
| `CLEAROTRON_INVOKED_AS` | unset (⇒ the verb's own `argv[1]`) | How the reader typed the command, so every command a verb prints for them to type next is spelled the way they type it: `clearotron …` after a global install, `npx clearotron …` otherwise. The dispatcher runs each verb as a process of its own, whose `argv[1]` is always `bin/<verb>.mjs`, so `bin/clearotron.mjs` passes its own `argv[1]` down in this name. **Set by the dispatcher, never by an operator.** Effect class `deployment`; the full contract is its row in `.env.example`. |
|
|
294
|
+
| `CLEAROTRON_RELOCATED` | unset (⇒ an install running from npx's cache moves itself to `~/.local` first) | Marks an install that has already made that move. `npx clearotron install` installs the same version under `~/.local`, then runs that copy's install with this set to `1`, so the copy does not try to move itself again. **Set by the install on the one process it starts, never by an operator.** Effect class `deployment`; the full contract is its row in `.env.example`. |
|
|
294
295
|
| `CLEAROTRON_REQUIRE_EXPLICIT_PORTS` | unset | `1` makes a service that would listen on a built-in default port refuse to start instead of warning. For a box that runs more than one instance, where one instance's default is another's port on the day that other instance is down. Unset, a service on a default port still says so as it starts. Effect class `deployment`. |
|
|
295
296
|
| `CLEAROTRON_UPDATER_STAMP` | `_updater-identity.json` beside the update script, in the directory the updater runs from | The full path of the file in which the updater that deploys this box records which copy of itself ran. The updater writes it and deploy health reads it under this one name, so a box that moves the stamp sets it once for both. On a box with no updater unit, setting it says an updater exists elsewhere and is to be judged. Effect class `deployment`. |
|
|
296
297
|
| `CLEAROTRON_CUT_REF` | `HEAD` | Which ref the cut decision reads the version from. **Read only by the release workflow, never set on a deployment.** The jobs that ask about `main` set it to `origin/main` explicitly, because their checkout is pinned to the run's own ref and `HEAD` there is that ref rather than the branch they are deciding about. A job that asks the wrong subject gets a confident wrong answer. |
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.3.0-beta.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2ca849d: Fixed: When codex's sign-in can no longer be refreshed, the search stops and says to run `codex login`. It used to retry with a bare exit code.
|
|
8
|
+
- a81279f: Fixed: When the portal address opens someone else's page, for example a port forwarded from outside WSL, Clearotron now says so and points you to `--port`.
|
|
9
|
+
- a81279f: Fixed: The sign-in page clears a session left by another Clearotron on the same address and says so. A refusal that is not about the passphrase now says what it is about.
|
|
10
|
+
- f133f7d: Fixed: A passphrase pasted with a space or line break at either end now signs in, instead of being refused as wrong.
|
|
11
|
+
- 2ca849d: Fixed: A manager who does not run the installation no longer sees server file paths when a new company cannot be recorded or filed.
|
|
12
|
+
- a81279f: Fixed: The local sign-in page no longer invites the browser to fill in a saved password from another install.
|
|
13
|
+
- a81279f: Fixed: `npx clearotron install` now installs Clearotron permanently under `~/.local` before setting up. The `clearotron` command and your assistants' connections keep working after npm cleans its cache.
|
|
14
|
+
- 2ca849d: Fixed: When the saved-search store exists but cannot be read, the connector and `clearotron doctor` now say so, instead of reporting no saved searches.
|
|
15
|
+
- a81279f: Fixed: On WSL, the "on this computer" connect steps now say to run them inside WSL. The Claude Code line registers Clearotron for every project and works in Windows PowerShell.
|
|
16
|
+
- a81279f: New: An assistant connected to `clearotron demo` can now list, brief and open the demo's four sample runs. They stay inside the demo's own folder.
|
|
17
|
+
- a81279f: Fixed: The sign-in page's reset line now runs for a demo started with npx, and resets that demo's own passphrase.
|
|
18
|
+
- a81279f: New: `clearotron update` now updates an npm-installed copy itself, and a beta install moves on to the release once it is published.
|
|
19
|
+
|
|
3
20
|
## 0.3.0-beta.5
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -412,6 +412,10 @@ export function readServedModel(codexHome, sinceMs = 0) {
|
|
|
412
412
|
// that cannot recur. This alternation is still a list of prose a vendor may change without notice,
|
|
413
413
|
// which is exactly why `rateLimitBasis: "text-match"` stays on the record.
|
|
414
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;
|
|
415
419
|
|
|
416
420
|
// A retry hint, WHEN THE MESSAGE HAPPENS TO CARRY ONE. Two anchored shapes and nothing clever: an
|
|
417
421
|
// explicit timestamp, or a relative delay with a unit. Anything else yields undefined and the driver
|
|
@@ -484,6 +488,7 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
484
488
|
// backoff (CLEAROTRON_RATE_LIMIT_DEFAULT_BACKOFF_MS), exactly as the anthropic no-reset 429 path does.
|
|
485
489
|
const rateLimitText = `${ev.turnFailed || ""}\n${ev.streamError || ""}\n${r.stderr || ""}`;
|
|
486
490
|
const rateLimited = !killed && RATE_LIMIT_RE.test(rateLimitText);
|
|
491
|
+
const signedOut = !killed && REFRESH_FAILED_RE.test(rateLimitText);
|
|
487
492
|
const resetsAt = rateLimited ? parseResetHint(rateLimitText) : undefined;
|
|
488
493
|
return {
|
|
489
494
|
code: killed ? 137 : (failed ? (r.rawCode || 1) : 0),
|
|
@@ -521,6 +526,9 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
521
526
|
// receives and is not this adapter's call — but nothing downstream can any longer fail to know.
|
|
522
527
|
mcpRefused: mcpToolGauge(ev).mcpToolCallsRefused > 0 || undefined,
|
|
523
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,
|
|
524
532
|
rateLimitBasis: rateLimited ? "text-match" : undefined,
|
|
525
533
|
// resetsAtBasis (2026-08-20): same honesty as rateLimitBasis one line up, for the reset
|
|
526
534
|
// CLOCK rather than the classification. codex states its reset as human prose with NO timezone
|
|
@@ -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
|
-
|
|
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 : [];
|
package/driver/gateway.mjs
CHANGED
|
@@ -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
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0-beta.
|
|
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
|
-
|
|
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"
|
|
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; }
|