vikunja-setup: #225 comment 52, diagnosis and one-copy plan (Mos sage-0094)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 13:45:34 -05:00
co-authored by Claude Opus 5.5
parent ed8819eec1
commit 34aa2028b3
2 changed files with 60 additions and 0 deletions
@@ -0,0 +1,11 @@
{
"at": "2026-10-12T18:44:52.524Z",
"task": 225,
"problems": [],
"title": "mosaic talk refuses Jason with human-required: the CLI wrapper and the bus host run different checkouts",
"status": 201,
"id": 52,
"readBack": 200,
"found": true,
"author": "bot-mosaic-stack-sage"
}
@@ -0,0 +1,49 @@
// Sage, 2026-10-12: Mos sage-0094. Comments on #225 (talk pm refused, human-required) with the diagnosis and plan.
// Sage's own key (bot user 13). The sync key reads the task title first and the comment back. No state
// change to the task. Prints no secret.
// Usage: node comment-225-plan.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const TASK = 225;
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const p = (...xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(SIG)}</p>`;
const TEXT = [
"Diagnosis: Mos's reading is right. The bus checks that the proof process runs exactly its own human-cli.mjs (packages/bus/src/human.mjs:70, the path from runtime.mjs:68). mosaic-bus@mosaic-stack runs from /mnt/storage/src/mosaic-stack, and the mosaic wrapper runs the Q81 pin at ~/.local/share/mosaic-cli/stack (d3ca2c22), so its human-cli has a different path and the bus refuses it. The pin also lags: six bus or cli commits landed after d3ca2c22.",
"Stopgap for S7 today, nothing touched: Jason runs /mnt/storage/src/mosaic-stack/scripts/mosaic talk pm \"...\" by that exact path. That is the same human-cli the Console uses now. Not through ~/src/mosaic-stack-dev-test: the check compares the literal path and doesn't follow symlinks. The Console works too, and shows replies to a web send once row 81 lands. Agent seats are refused, so I can't try it from mine; the first try is Jason's.",
"Fix, Mos's preference: one copy. A new queue row puts the bus, the Console and the mosaic command on one read-only copy at a landed commit. bus-service.sh gets --copy ID like row 77's webui-service.sh, and the copy holds what the bus and the CLI load. Tests: a CLI from a copy authenticates to a bus from the same copy, and a bus refuses a CLI from another copy or from the checkout (today's check unchanged). One update step: copy at the landed commit, stop the Console and the bus, install both with --copy, repoint the wrapper, start.",
"Nothing touches the wrapper or either unit until that row lands and Mos says go. The reinstalls and the wrapper repoint are Mos's or Jason's (decisions 102 and 103, Q81), not mine.",
"Rejected: making the bus also accept the pinned install's path. It adds a second path the human check trusts and keeps the CLI and the bus on different code.",
"Found on the way: row 77 as built breaks the Console the same way the first time it is installed with --copy, because the Console would spawn the copy's human-cli while the bus runs from the checkout. Its service test uses a stand-in socket and spawns no human-cli, so nothing catches it. Row 77 round 2 adds a refusal: install --copy refuses unless the bus runs from the same copy. Until the new row lands, the Console stays on the checkout.",
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), task: TASK, problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
const task = await api("GET", `/api/v2/tasks/${TASK}`, undefined, sync);
rec.title = task.json?.title ?? null;
if (task.status !== 200 || task.json?.project_id !== 19) throw new Error(`task ${TASK}: ${task.status}, project ${task.json?.project_id}`);
writeFileSync(OUT, "", { flag: "wx" });
const r = await api("PUT", `/api/v1/tasks/${TASK}/comments`, { comment: p(...TEXT) }, key);
Object.assign(rec, { status: r.status, id: r.json?.id ?? null });
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`comment: ${r.status} ${r.json?.message ?? ""}`);
else {
const back = await api("GET", `/api/v2/tasks/${TASK}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((c) => c.id === r.json.id);
Object.assign(rec, { readBack: back.status, found: Boolean(found), author: found?.author?.username ?? null });
if (back.status !== 200 || !found) rec.problems.push(`read-back: ${back.status}`);
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);