|
|
|
@@ -0,0 +1,986 @@
|
|
|
|
|
diff --git a/docs/prd/mosaic-stack.md b/docs/prd/mosaic-stack.md
|
|
|
|
|
index 794a39bbb..cf6e9f774 100644
|
|
|
|
|
--- a/docs/prd/mosaic-stack.md
|
|
|
|
|
+++ b/docs/prd/mosaic-stack.md
|
|
|
|
|
@@ -819,9 +819,9 @@ only hard waits in his inbox.
|
|
|
|
|
in `packages/queue/src/review.mjs`, the ledger's repository paths,
|
|
|
|
|
and the `refactor` branch in `scripts/land-work.sh`.
|
|
|
|
|
`gitea.baseUrl` exists in `vars.mjs` and the scripts don't read it.
|
|
|
|
|
- - Channels: the human channel is `via: 'cli'` in the broker, the
|
|
|
|
|
- WebUI reaches the bus by running the human CLI, and Discord isn't
|
|
|
|
|
- connected to the bus.
|
|
|
|
|
+ - Channels: `cli` and `webui` are channel plug-in modules, selected by
|
|
|
|
|
+ the organization's `plugins.channel` list. Both retain the core human
|
|
|
|
|
+ proof transport (`via: 'cli'`); Discord isn't connected to the bus.
|
|
|
|
|
- Version 1.4:
|
|
|
|
|
- Jason approved REQ-WEB-3 in decision 021af0c8 (prd.approve, choice
|
|
|
|
|
yes; the approval message was request 2e031716, from request
|
|
|
|
|
diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..a1e6ad3b2
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/bus/tests/channel-proof.test.mjs
|
|
|
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
+// Row 164 explicitly freezes proof bytes and its literal entry-path rule.
|
|
|
|
|
+import test from "node:test";
|
|
|
|
|
+import assert from "node:assert/strict";
|
|
|
|
|
+import { createHash } from "node:crypto";
|
|
|
|
|
+import { readFileSync, writeFileSync } from "node:fs";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
|
|
|
|
|
+import { Client } from "../src/client.mjs";
|
|
|
|
|
+
|
|
|
|
|
+const baselines = {
|
|
|
|
|
+ "packages/bus/src/human.mjs": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573",
|
|
|
|
|
+ "packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
|
|
|
|
|
+ "packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
|
|
|
|
+ "packages/webui/src/bus.mjs": "dbad731ca8be19619064f050e766aeca856cf614783b9eca887caff3c2451f24",
|
|
|
|
|
+};
|
|
|
|
|
+const repoFile = path => new URL("../../../" + path, import.meta.url);
|
|
|
|
|
+
|
|
|
|
|
+test("channel conversion preserves the four proof/transport byte baselines and modules cannot import a proof caller", () => {
|
|
|
|
|
+ for (const [file, hash] of Object.entries(baselines))
|
|
|
|
|
+ assert.equal(createHash("sha256").update(readFileSync(repoFile(file))).digest("hex"), hash, file);
|
|
|
|
|
+ for (const name of ["cli", "webui"]) {
|
|
|
|
|
+ const source = readFileSync(repoFile(`packages/plugins/channel/${name}/index.mjs`), "utf8");
|
|
|
|
|
+ assert.doesNotMatch(source, /human-cli|humanTransport|humanCall|verifyHuman|readProcess|child_process|spawn/);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel recovery still requires the same shim path and rejects an absent or forged proof", { timeout: 20000 }, async t => {
|
|
|
|
|
+ const s = await provedRuntime(t);
|
|
|
|
|
+ const direct = new Client({ path: s.socket });
|
|
|
|
|
+ await assert.rejects(direct.call("plugins"), e => ["invalid-envelope", "unauthenticated", "human-required"].includes(e.code));
|
|
|
|
|
+ const forged = new Client({ path: s.socket, human: { business: "demo", pid: process.pid, startTime: "0", nonce: "a".repeat(64) } });
|
|
|
|
|
+ await assert.rejects(forged.call("plugins"), e => e.code === "human-required");
|
|
|
|
|
+ const genuine = await s.shimCall(s.shim, { business: "demo", verb: "plugins" });
|
|
|
|
|
+ assert.equal(genuine.status, 0, genuine.stderr);
|
|
|
|
|
+ assert.deepEqual(JSON.parse(genuine.stdout).plugins.channel, []);
|
|
|
|
|
+ let other = readFileSync(s.shim, "utf8");
|
|
|
|
|
+ other = other.replace("'./client.mjs'", JSON.stringify(new URL("../src/client.mjs", import.meta.url).href));
|
|
|
|
|
+ other = other.replace("'./human.mjs'", JSON.stringify(new URL("../src/human.mjs", import.meta.url).href));
|
|
|
|
|
+ const differentPath = join(s.root, "other-human-cli.mjs");
|
|
|
|
|
+ writeFileSync(differentPath, other);
|
|
|
|
|
+ const refused = await s.shimCall(differentPath, { business: "demo", verb: "plugins" });
|
|
|
|
|
+ assert.notEqual(refused.status, 0);
|
|
|
|
|
+ assert.equal(refused.stderr.trim(), "human-required");
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
|
|
|
|
index 7b903e506..a56a1b118 100644
|
|
|
|
|
--- a/packages/cli/src/cli.mjs
|
|
|
|
|
+++ b/packages/cli/src/cli.mjs
|
|
|
|
|
@@ -17,6 +17,8 @@ import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } f
|
|
|
|
|
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
|
|
|
|
|
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
|
|
|
|
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
|
|
|
|
+import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
|
|
|
|
+import { PluginError } from "../../plugins/src/kinds.mjs";
|
|
|
|
|
import { CliError } from "./errors.mjs";
|
|
|
|
|
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
|
|
|
|
|
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
|
|
|
|
|
@@ -186,6 +188,28 @@ function callFor(parsed, io, deps) {
|
|
|
|
|
return deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+// Only the converted project/thread surface uses a channel. Management stays on callFor.
|
|
|
|
|
+function channelCallFor(parsed, io, deps) {
|
|
|
|
|
+ const call = callFor(parsed, io, deps);
|
|
|
|
|
+ const channel = createChannelCaller({ name: "cli", call,
|
|
|
|
|
+ log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
|
|
|
|
|
+ });
|
|
|
|
|
+ return async (verb, args = {}) => {
|
|
|
|
|
+ try { return await channel(verb, args); }
|
|
|
|
|
+ catch (error) {
|
|
|
|
|
+ if (!(error instanceof PluginError)) throw error;
|
|
|
|
|
+ let message = error.message;
|
|
|
|
|
+ if (error.code === "channel-disabled" && parsed.values["--business"]) {
|
|
|
|
|
+ message += ` Pass --business ${JSON.stringify(parsed.values["--business"])} to both commands.`;
|
|
|
|
|
+ }
|
|
|
|
|
+ const refused = new CliError(message, ["channel-disabled", "channel-config"].includes(error.code) ? 3 : 2);
|
|
|
|
|
+ refused.code = error.code;
|
|
|
|
|
+ throw refused;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
// The human's unread messages, newest first, from every page of `messages`.
|
|
|
|
|
async function unreadMessages(call) {
|
|
|
|
|
const messages = [];
|
|
|
|
|
@@ -342,7 +366,7 @@ async function talk(rest, io, deps, receiveOnly = false) {
|
|
|
|
|
if (threadRef !== undefined) [to, body] = [threadRef, to];
|
|
|
|
|
// The broker refuses it too (tester-prefix); this is the clear early message.
|
|
|
|
|
if (!receiveOnly && parsed.flags.has("--tester") && !body.startsWith(TESTER_PREFIX)) throw new CliError(`a tester message starts with "${TESTER_PREFIX}"`, 2);
|
|
|
|
|
- const call = callFor(parsed, io, deps);
|
|
|
|
|
+ const call = threadRef === undefined ? callFor(parsed, io, deps) : channelCallFor(parsed, io, deps);
|
|
|
|
|
let sent;
|
|
|
|
|
let thread = null;
|
|
|
|
|
if (threadRef !== undefined) {
|
|
|
|
|
@@ -538,9 +562,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
|
|
|
|
if (argv.length === 0) return app(io, deps);
|
|
|
|
|
const [verb, ...rest] = argv;
|
|
|
|
|
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
|
|
|
|
- if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => callFor(p, io, deps)) : projectCommand(rest, io, deps);
|
|
|
|
|
- if (verb === "threads") return threadsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
|
|
|
- if (verb === "thread") return threadCommand(rest, io, (p) => callFor(p, io, deps), formatMessage);
|
|
|
|
|
+ if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps);
|
|
|
|
|
+ if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
|
|
|
|
+ if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
|
|
|
|
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
|
|
|
|
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
|
|
|
|
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
|
|
|
diff --git a/packages/cli/tests/channel-cli.test.mjs b/packages/cli/tests/channel-cli.test.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..8e6f1ee58
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/cli/tests/channel-cli.test.mjs
|
|
|
|
|
@@ -0,0 +1,42 @@
|
|
|
|
|
+// Row 164: switching CLI chat off never gates the core management verbs.
|
|
|
|
|
+import test from "node:test";
|
|
|
|
|
+import assert from "node:assert/strict";
|
|
|
|
|
+import { run } from "../src/cli.mjs";
|
|
|
|
|
+import { io, tmp } from "./helpers.mjs";
|
|
|
|
|
+
|
|
|
|
|
+function setup(t, selected) {
|
|
|
|
|
+ const calls = [];
|
|
|
|
|
+ const deps = { system: { dataRoot: tmp(t, "channel-cli-") }, transport: () => async (verb, args = {}) => {
|
|
|
|
|
+ calls.push([verb, args]);
|
|
|
|
|
+ if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } };
|
|
|
|
|
+ if (["project.list", "thread.list"].includes(verb)) return [];
|
|
|
|
|
+ assert.fail("unexpected operation " + verb);
|
|
|
|
|
+ } };
|
|
|
|
|
+ return { calls, async cli(argv) { const x = io(); return { code: await run([...argv, "--business", "demo"], x, deps), out: x.out.text, err: x.err.text }; } };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+test("CLI off, absent and other-only channel selections refuse every project/thread flow before any effect", async t => {
|
|
|
|
|
+ for (const selected of [[], undefined, ["webui"]]) {
|
|
|
|
|
+ const s = setup(t, selected);
|
|
|
|
|
+ for (const argv of [["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "stack/chat"], ["thread", "open", "stack/chat"], ["thread", "create", "stack", "chat"], ["talk", "--thread", "stack/chat", "hello", "--wait", "0"], ["follow", "--thread", "stack/chat"]]) {
|
|
|
|
|
+ s.calls.length = 0;
|
|
|
|
|
+ const result = await s.cli(argv);
|
|
|
|
|
+ assert.equal(result.code, 3, argv.join(" "));
|
|
|
|
|
+ assert.match(result.err, /mosaic plugins.*mosaic plugins set/s);
|
|
|
|
|
+ assert.match(result.err, /plugins\.channel/);
|
|
|
|
|
+ assert.ok(result.err.includes('Pass --business "demo" to both commands.'));
|
|
|
|
|
+ assert.match(result.err, /cli.*webui/);
|
|
|
|
|
+ assert.equal(result.out, "");
|
|
|
|
|
+ assert.deepEqual(s.calls, [["plugins", {}]]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("CLI enabled project/thread lists dispatch through selection without changing output", async t => {
|
|
|
|
|
+ const s = setup(t, ["cli"]);
|
|
|
|
|
+ assert.deepEqual(await s.cli(["project", "list"]), { code: 0, out: "no projects\n", err: "" });
|
|
|
|
|
+ assert.deepEqual(s.calls, [["plugins", {}], ["project.list", {}]]);
|
|
|
|
|
+ s.calls.length = 0;
|
|
|
|
|
+ assert.deepEqual(await s.cli(["threads"]), { code: 0, out: "no threads\n", err: "" });
|
|
|
|
|
+ assert.deepEqual(s.calls, [["plugins", {}], ["thread.list", {}]]);
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/packages/cli/tests/channel-fixture.mjs b/packages/cli/tests/channel-fixture.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..b3a04d9b3
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/cli/tests/channel-fixture.mjs
|
|
|
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
+// Private fixture runtime only; no live host, unit, store or organization file.
|
|
|
|
|
+import { spawn } from "node:child_process";
|
|
|
|
|
+import { once } from "node:events";
|
|
|
|
|
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
|
|
+import { tmpdir } from "node:os";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { fileURLToPath } from "node:url";
|
|
|
|
|
+import { fixture, BUSINESSES } from "./helpers.mjs";
|
|
|
|
|
+import { systemFor } from "../../business/tests/helpers.mjs";
|
|
|
|
|
+
|
|
|
|
|
+export async function childResult(file, args, env, input) {
|
|
|
|
|
+ const child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
|
|
|
+ let stdout = "", stderr = "";
|
|
|
|
|
+ child.stdout.on("data", b => stdout += b);
|
|
|
|
|
+ child.stderr.on("data", b => stderr += b);
|
|
|
|
|
+ child.stdin.on("error", () => {});
|
|
|
|
|
+ child.stdin.end(input);
|
|
|
|
|
+ const [status, signal] = await once(child, "close");
|
|
|
|
|
+ return { status, signal, stdout, stderr };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function provedRuntime(t) {
|
|
|
|
|
+ const root = mkdtempSync(join(tmpdir(), "channel-recovery-"));
|
|
|
|
|
+ let host;
|
|
|
|
|
+ t.after(async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (host && host.exitCode === null && host.signalCode === null) {
|
|
|
|
|
+ const done = once(host, "close");
|
|
|
|
|
+ const timer = setTimeout(() => host.kill("SIGKILL"), 3000);
|
|
|
|
|
+ host.kill("SIGTERM");
|
|
|
|
|
+ try { await done; } finally { clearTimeout(timer); }
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally { rmSync(root, { recursive: true, force: true }); }
|
|
|
|
|
+ });
|
|
|
|
|
+ const f = fixture(root, "demo", doc => ({ ...doc, plugins: { channel: [] } }));
|
|
|
|
|
+ mkdirSync(f.dataRoot, { mode: 0o700 });
|
|
|
|
|
+ const url = rel => new URL(rel, import.meta.url).href;
|
|
|
|
|
+ const humanURL = url("../../bus/src/human.mjs");
|
|
|
|
|
+ const script = join(root, "fixture-runtime.mjs");
|
|
|
|
|
+ // As in stack-copy.test.mjs, isolate the fixture's ancestry from the seat.
|
|
|
|
|
+ // The real PID, UID, argv path, start time, nonce and verifier still run.
|
|
|
|
|
+ writeFileSync(script, `
|
|
|
|
|
+import { mock } from 'node:test';
|
|
|
|
|
+const real = await import(${JSON.stringify(humanURL)});
|
|
|
|
|
+mock.module(${JSON.stringify(humanURL)}, { namedExports: {
|
|
|
|
|
+ readProcess: real.readProcess,
|
|
|
|
|
+ verifyHuman: (proof, options) => real.verifyHuman(proof, { ...options,
|
|
|
|
|
+ readProcess: pid => ({ ...real.readProcess(pid), ppid: 1 }) }),
|
|
|
|
|
+} });
|
|
|
|
|
+const { startBroker } = await import(${JSON.stringify(url("../../bus/src/runtime.mjs"))});
|
|
|
|
|
+const runtime = await startBroker({ dataRoot: ${JSON.stringify(f.dataRoot)},
|
|
|
|
|
+ businesses: ${JSON.stringify(BUSINESSES)},
|
|
|
|
|
+ organization: { dir: ${JSON.stringify(join(root, "config"))}, rolesDir: ${JSON.stringify(f.roles)}, system: ${JSON.stringify(systemFor(root))} } });
|
|
|
|
|
+process.on('SIGTERM', async () => { await runtime.close(); process.exit(0); });
|
|
|
|
|
+console.log('fixture ready');
|
|
|
|
|
+`);
|
|
|
|
|
+ const env = { PATH: process.env.PATH, HOME: root, TMPDIR: process.env.TMPDIR,
|
|
|
|
|
+ MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles };
|
|
|
|
|
+ host = spawn(process.execPath, ["--experimental-test-module-mocks", script], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
|
|
|
+ let output = "", errors = "";
|
|
|
|
|
+ host.stderr.on("data", b => errors += b);
|
|
|
|
|
+ await new Promise((resolve, reject) => {
|
|
|
|
|
+ host.stdout.on("data", b => { output += b; if (output.includes("fixture ready")) resolve(); });
|
|
|
|
|
+ host.once("exit", code => reject(new Error(`fixture failed ${code}: ${errors}`)));
|
|
|
|
|
+ host.once("error", reject);
|
|
|
|
|
+ });
|
|
|
|
|
+ const cli = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
|
|
|
|
|
+ const shim = fileURLToPath(new URL("../../bus/src/human-cli.mjs", import.meta.url));
|
|
|
|
|
+ return { root, f, env, shim, socket: join(f.dataRoot, "bus/broker.sock"),
|
|
|
|
|
+ cli: argv => childResult(process.execPath, [cli, ...argv, "--business", "demo"], env),
|
|
|
|
|
+ shimCall: (file, request) => childResult(process.execPath, [file, join(f.dataRoot, "bus/broker.sock")], env, JSON.stringify(request) + "\n"),
|
|
|
|
|
+ file: join(root, "config/businesses/demo.json"),
|
|
|
|
|
+ history: () => readFileSync(join(root, "config/businesses/.history/demo/changes.jsonl"), "utf8").trim().split("\n").map(JSON.parse),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
diff --git a/packages/cli/tests/channel-recovery.test.mjs b/packages/cli/tests/channel-recovery.test.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..e0998413b
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/cli/tests/channel-recovery.test.mjs
|
|
|
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
+// Removing or disabling every channel must leave the proved management writer usable.
|
|
|
|
|
+import test from "node:test";
|
|
|
|
|
+import assert from "node:assert/strict";
|
|
|
|
|
+import { writeFileSync, readFileSync } from "node:fs";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { provedRuntime } from "./channel-fixture.mjs";
|
|
|
|
|
+
|
|
|
|
|
+test("both channels off recover through the existing human CLI, socket and organization writer", { timeout: 20000 }, async t => {
|
|
|
|
|
+ const s = await provedRuntime(t);
|
|
|
|
|
+ const off = await s.cli(["threads"]);
|
|
|
|
|
+ assert.equal(off.status, 3, off.stderr);
|
|
|
|
|
+ assert.match(off.stderr, /mosaic plugins.*mosaic plugins set/s);
|
|
|
|
|
+ const view = await s.cli(["plugins", "--json"]);
|
|
|
|
|
+ assert.equal(view.status, 0, view.stderr);
|
|
|
|
|
+ const state = JSON.parse(view.stdout);
|
|
|
|
|
+ assert.deepEqual(state.plugins.channel, []);
|
|
|
|
|
+ const patch = join(s.root, "enable.json");
|
|
|
|
|
+ writeFileSync(patch, JSON.stringify({ revision: state.revision, plugins: { channel: ["cli", "webui"] } }));
|
|
|
|
|
+ const done = await s.cli(["plugins", "set", "--file", patch]);
|
|
|
|
|
+ assert.equal(done.status, 0, done.stderr);
|
|
|
|
|
+ assert.deepEqual(JSON.parse(done.stdout).plugins.channel, ["cli", "webui"]);
|
|
|
|
|
+ assert.deepEqual(JSON.parse(readFileSync(s.file, "utf8")).plugins.channel, ["cli", "webui"]);
|
|
|
|
|
+ const receipt = s.history().at(-1);
|
|
|
|
|
+ assert.equal(receipt.actor, "jason");
|
|
|
|
|
+ assert.equal(receipt.state, "committed");
|
|
|
|
|
+ const threads = await s.cli(["threads"]);
|
|
|
|
|
+ assert.deepEqual([threads.status, threads.stdout, threads.stderr], [0, "no threads\n", ""]);
|
|
|
|
|
+ const before = readFileSync(s.file, "utf8");
|
|
|
|
|
+ const stale = await s.cli(["plugins", "set", "--file", patch]);
|
|
|
|
|
+ assert.notEqual(stale.status, 0);
|
|
|
|
|
+ assert.match(stale.stderr, /revision-conflict/);
|
|
|
|
|
+ assert.equal(readFileSync(s.file, "utf8"), before);
|
|
|
|
|
+ // Removing the key is also off, and does not remove the recovery read path.
|
|
|
|
|
+ const now = JSON.parse((await s.cli(["plugins"])).stdout);
|
|
|
|
|
+ writeFileSync(patch, JSON.stringify({ revision: now.revision, plugins: { channel: null } }));
|
|
|
|
|
+ assert.equal((await s.cli(["plugins", "set", "--file", patch])).status, 0);
|
|
|
|
|
+ assert.equal((await s.cli(["threads"])).status, 3);
|
|
|
|
|
+ assert.equal((await s.cli(["plugins"])).status, 0);
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/packages/cli/tests/project.test.mjs b/packages/cli/tests/project.test.mjs
|
|
|
|
|
index c101ba5e7..5e067247b 100644
|
|
|
|
|
--- a/packages/cli/tests/project.test.mjs
|
|
|
|
|
+++ b/packages/cli/tests/project.test.mjs
|
|
|
|
|
@@ -44,11 +44,12 @@ test('project create and list validate their arguments before any bus call', asy
|
|
|
|
|
assert.match(x.err.text, /mosaic project create <name>/);
|
|
|
|
|
}
|
|
|
|
|
const listed = [];
|
|
|
|
|
- const transport = () => async verb => (listed.push(verb), []);
|
|
|
|
|
+ const managementReads = [], transport = () => async verb => verb === 'plugins' ? (managementReads.push(verb), { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }) : (listed.push(verb), []);
|
|
|
|
|
const x = io(humanEnv(f.env));
|
|
|
|
|
assert.equal(await run(['project', 'list', '--business', 'acme'], x, { transport }), 0);
|
|
|
|
|
assert.deepEqual(listed, ['project.list']);
|
|
|
|
|
assert.equal(x.out.text, 'no projects\n');
|
|
|
|
|
+ assert.deepEqual(managementReads, ['plugins']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('project dispatcher reaches the human CLI', async t => {
|
|
|
|
|
diff --git a/packages/cli/tests/thread-cli.test.mjs b/packages/cli/tests/thread-cli.test.mjs
|
|
|
|
|
index 47e557eaf..13e32bc86 100644
|
|
|
|
|
--- a/packages/cli/tests/thread-cli.test.mjs
|
|
|
|
|
+++ b/packages/cli/tests/thread-cli.test.mjs
|
|
|
|
|
@@ -32,7 +32,13 @@ function setup(t, { businessRoles = roles } = {}) {
|
|
|
|
|
store.close();
|
|
|
|
|
rmSync(root, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
+ const managementReads = [];
|
|
|
|
|
const b = new Broker({
|
|
|
|
|
+ organization: (business, verb) => {
|
|
|
|
|
+ assert.equal(verb, "plugins");
|
|
|
|
|
+ managementReads.push([business, verb]);
|
|
|
|
|
+ return { business, revision: "a".repeat(64), plugins: { channel: ["cli", "webui"] } };
|
|
|
|
|
+ },
|
|
|
|
|
store,
|
|
|
|
|
businesses: { demo: { id: "demo", human: "jason", arbiters: { technical: "chief", delivery: "chief" }, roles: businessRoles, definitions, projects: { stack: { root: "/nonexistent" } } } },
|
|
|
|
|
});
|
|
|
|
|
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
|
|
|
|
|
index 97703951f..9f8b86d62 100644
|
|
|
|
|
--- a/packages/plugins/README.md
|
|
|
|
|
+++ b/packages/plugins/README.md
|
|
|
|
|
@@ -19,6 +19,7 @@ loader.
|
|
|
|
|
| `usage-source` | 1 | list | `interfaceVersion`, `readTurn` |
|
|
|
|
|
| `limit-check` | 1 | list | `interfaceVersion`, `mayLaunch`, `mustStop` |
|
|
|
|
|
| `search-source` | 1 | one | `interfaceVersion`, `search` |
|
|
|
|
|
+| `channel` | 1 | list | `interfaceVersion`, `dispatch` |
|
|
|
|
|
|
|
|
|
|
Every module exports `interfaceVersion`, equal to its kind's interface
|
|
|
|
|
version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds
|
|
|
|
|
@@ -131,3 +132,40 @@ and appends baselines and outcomes. See that module's README for JSONL formats,
|
|
|
|
|
security/resource bounds and the best-reading limitation. No limit check ships
|
|
|
|
|
with this row. Enable transcript through the human plug-in settings writer;
|
|
|
|
|
a restart/re-pin remains a separate authorized window.
|
|
|
|
|
+
|
|
|
|
|
+## Human project/thread channels
|
|
|
|
|
+
|
|
|
|
|
+`channel` modules carry project/thread operations between a human surface and
|
|
|
|
|
+its existing core bus caller. The shipped `cli` and `webui` modules implement
|
|
|
|
|
+`dispatch({ verb, args }, { call })`, returning the bus result or refusal
|
|
|
|
|
+unchanged. Only the fixed project/thread operations are supported; CLI also
|
|
|
|
|
+uses message.receive/read for thread talk/follow. Modules do not create a human
|
|
|
|
|
+proof, choose a business/owner, spawn the shim or write organization settings.
|
|
|
|
|
+The broker still sees the proved transport as `via: 'cli'` for both surfaces.
|
|
|
|
|
+Discord is not connected and no Discord module ships here.
|
|
|
|
|
+
|
|
|
|
|
+The setting is `"plugins": { "channel": ["cli", "webui"] }`. An absent key or
|
|
|
|
|
+an empty list disables both converted chat surfaces. No silent default enables
|
|
|
|
|
+them. CLI off refuses with exit 3; web chat off returns 503, both naming recovery.
|
|
|
|
|
+Other CLI administration and Console views are independent. CLI project
|
|
|
|
|
+init/check and non-thread talk/inbox are core operations, outside this conversion.
|
|
|
|
|
+
|
|
|
|
|
+Each chat operation reads the existing plugins setting, loads only its channel
|
|
|
|
|
+selection through the in-tree loader, captures a frozen `snapshot('channel')`
|
|
|
|
|
+array of `{ name, module }`, then dispatches without a lock. A slow send does
|
|
|
|
|
+not block a list. A captured array never changes after a reload; loaded() and
|
|
|
|
|
+modules() keep their existing interfaces. Invalid module settings retain the
|
|
|
|
|
+loader's last good selection; the first refused load has nothing enabled.
|
|
|
|
|
+
|
|
|
|
|
+The recovery commands never need a channel: from a human shell, run
|
|
|
|
|
+`mosaic plugins --business BUSINESS --json`, make a patch with its revision and
|
|
|
|
|
+`{"channel":["cli","webui"]}` as the plugins value, then run
|
|
|
|
|
+`mosaic plugins set --business BUSINESS --file PATCH`. This uses the existing
|
|
|
|
|
+proved human transport and organization writer, with revision checks and actor
|
|
|
|
|
+receipts. The next chat operation reads the restored selection. Disabling a
|
|
|
|
|
+channel cannot disable this write path. It is not a repair bypass for an invalid
|
|
|
|
|
+organization file or unavailable bus.
|
|
|
|
|
+
|
|
|
|
|
+For the re-pin introducing this kind, Jason enables both channels through
|
|
|
|
|
+plugins.set immediately after activate in Mos's window, then smokes CLI thread
|
|
|
|
|
+listing and Console chat. No development seat performs that live step.
|
|
|
|
|
diff --git a/packages/plugins/channel/cli/index.mjs b/packages/plugins/channel/cli/index.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..d74347edb
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/plugins/channel/cli/index.mjs
|
|
|
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
+// Surface adapter only. The caller and its human proof belong to core.
|
|
|
|
|
+import { PluginError } from "../../src/kinds.mjs";
|
|
|
|
|
+export const interfaceVersion = 1;
|
|
|
|
|
+const VERBS = new Set([
|
|
|
|
|
+ "project.list",
|
|
|
|
|
+ "project.create",
|
|
|
|
|
+ "thread.list",
|
|
|
|
|
+ "thread.create",
|
|
|
|
|
+ "thread.messages",
|
|
|
|
|
+ "thread.send",
|
|
|
|
|
+ "thread.open",
|
|
|
|
|
+ "message.receive",
|
|
|
|
|
+ "message.read",
|
|
|
|
|
+]);
|
|
|
|
|
+
|
|
|
|
|
+export async function dispatch({ verb, args }, { call }) {
|
|
|
|
|
+ if (!VERBS.has(verb)) throw new PluginError("channel-operation", "unsupported channel operation");
|
|
|
|
|
+ return call(verb, args);
|
|
|
|
|
+}
|
|
|
|
|
diff --git a/packages/plugins/channel/webui/index.mjs b/packages/plugins/channel/webui/index.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..e9cb53f15
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/plugins/channel/webui/index.mjs
|
|
|
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
+// Surface adapter only. The caller and its human proof belong to core.
|
|
|
|
|
+import { PluginError } from "../../src/kinds.mjs";
|
|
|
|
|
+export const interfaceVersion = 1;
|
|
|
|
|
+const VERBS = new Set([
|
|
|
|
|
+ "project.list",
|
|
|
|
|
+ "project.create",
|
|
|
|
|
+ "thread.list",
|
|
|
|
|
+ "thread.create",
|
|
|
|
|
+ "thread.messages",
|
|
|
|
|
+ "thread.send",
|
|
|
|
|
+ "thread.open",
|
|
|
|
|
+]);
|
|
|
|
|
+
|
|
|
|
|
+export async function dispatch({ verb, args }, { call }) {
|
|
|
|
|
+ if (!VERBS.has(verb)) throw new PluginError("channel-operation", "unsupported channel operation");
|
|
|
|
|
+ return call(verb, args);
|
|
|
|
|
+}
|
|
|
|
|
diff --git a/packages/plugins/src/channel.mjs b/packages/plugins/src/channel.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..84c54c091
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/plugins/src/channel.mjs
|
|
|
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
+// Core selection only: modules receive a business-bound call, never a proof or writer.
|
|
|
|
|
+import { createLoader } from "./loader.mjs";
|
|
|
|
|
+import { NAME, PluginError } from "./kinds.mjs";
|
|
|
|
|
+
|
|
|
|
|
+export const CHANNEL_RECOVERY = 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
|
|
|
|
|
+const record = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
|
+const report = entry => process.stderr.write(`channel: ${JSON.stringify({
|
|
|
|
|
+ event: entry.event, kind: entry.kind, name: entry.name, code: entry.code, kept: entry.kept,
|
|
|
|
|
+})}\n`);
|
|
|
|
|
+
|
|
|
|
|
+export function createChannelCaller({ name, call, log = report }) {
|
|
|
|
|
+ if (typeof name !== "string" || !NAME.test(name)) throw new PluginError("bad-name", "invalid channel name");
|
|
|
|
|
+ if (typeof call !== "function") throw new TypeError("channel needs a core caller");
|
|
|
|
|
+ const loader = createLoader({ log });
|
|
|
|
|
+ return async (verb, args = {}) => {
|
|
|
|
|
+ const settings = await call("plugins", {});
|
|
|
|
|
+ if (!record(settings) || !record(settings.plugins)) {
|
|
|
|
|
+ throw new PluginError("channel-config", "invalid channel settings response");
|
|
|
|
|
+ }
|
|
|
|
|
+ const selection = Object.hasOwn(settings.plugins, "channel") ? { channel: settings.plugins.channel } : {};
|
|
|
|
|
+ await loader.load(selection);
|
|
|
|
|
+ const snapshot = loader.snapshot("channel");
|
|
|
|
|
+ const entry = snapshot.find(item => item.name === name);
|
|
|
|
|
+ if (!entry) throw new PluginError("channel-disabled", CHANNEL_RECOVERY, name);
|
|
|
|
|
+ // No lock spans dispatch. Concurrent reads can proceed while a send awaits.
|
|
|
|
|
+ return entry.module.dispatch({ verb, args }, { call });
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs
|
|
|
|
|
index f5679ad3f..a29681573 100644
|
|
|
|
|
--- a/packages/plugins/src/kinds.mjs
|
|
|
|
|
+++ b/packages/plugins/src/kinds.mjs
|
|
|
|
|
@@ -22,6 +22,10 @@ export const KINDS = Object.freeze({
|
|
|
|
|
"search-source": Object.freeze({
|
|
|
|
|
kind: "search-source", interfaceVersion: 1, exports: Object.freeze(["search"]), switch: "one",
|
|
|
|
|
}),
|
|
|
|
|
+ // Human project/thread surfaces; proof and the recovery writer remain in core.
|
|
|
|
|
+ channel: Object.freeze({
|
|
|
|
|
+ kind: "channel", interfaceVersion: 1, exports: Object.freeze(["dispatch"]), switch: "list",
|
|
|
|
|
+ }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export class PluginError extends Error {
|
|
|
|
|
diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs
|
|
|
|
|
index 5a8aa8b5e..04731ee91 100644
|
|
|
|
|
--- a/packages/plugins/src/loader.mjs
|
|
|
|
|
+++ b/packages/plugins/src/loader.mjs
|
|
|
|
|
@@ -154,5 +154,11 @@ export function createLoader({ log, barrier = () => {} }) {
|
|
|
|
|
return current.get(kind).map(m => m.module);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
- return { load, loaded, modules };
|
|
|
|
|
+ // Capture names and modules together, without exposing mutable state or a dispatch lock.
|
|
|
|
|
+ function snapshot(kind) {
|
|
|
|
|
+ if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
|
|
|
|
|
+ return current.get(kind);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { load, loaded, modules, snapshot };
|
|
|
|
|
}
|
|
|
|
|
diff --git a/packages/plugins/tests/channel.test.mjs b/packages/plugins/tests/channel.test.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..b44856580
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/plugins/tests/channel.test.mjs
|
|
|
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
+// Row 164: channel selection stays independent of human proof and administration.
|
|
|
|
|
+import test from "node:test";
|
|
|
|
|
+import assert from "node:assert/strict";
|
|
|
|
|
+import { mkdirSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { createLoader } from "../src/loader.mjs";
|
|
|
|
|
+import { KINDS } from "../src/kinds.mjs";
|
|
|
|
|
+import { tree } from "./helpers.mjs";
|
|
|
|
|
+
|
|
|
|
|
+const code = expected => e => e.code === expected;
|
|
|
|
|
+const fixtureModule = tag => `export const interfaceVersion = 1;
|
|
|
|
|
+export async function dispatch(request, {call}) { return call(request.verb, {...request.args, fixture:${JSON.stringify(tag)}}); }
|
|
|
|
|
+`;
|
|
|
|
|
+const settings = channel => ({ business: "demo", revision: "a".repeat(64), plugins: channel === undefined ? {} : { channel } });
|
|
|
|
|
+const helper = () => import("../src/channel.mjs");
|
|
|
|
|
+
|
|
|
|
|
+test("channel registry requires dispatch at interface 1 and switches a list", () => {
|
|
|
|
|
+ assert.deepEqual(KINDS.channel, { kind: "channel", interfaceVersion: 1, exports: ["dispatch"], switch: "list" });
|
|
|
|
|
+ assert.ok(Object.isFrozen(KINDS.channel));
|
|
|
|
|
+ assert.ok(Object.isFrozen(KINDS.channel.exports));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel shipped modules load and preserve every supported operation and bus refusal", async () => {
|
|
|
|
|
+ const loader = createLoader({ log: () => {} });
|
|
|
|
|
+ assert.deepEqual((await loader.load({ channel: ["cli", "webui"] })).refused, []);
|
|
|
|
|
+ assert.deepEqual(loader.loaded().channel, ["cli", "webui"]);
|
|
|
|
|
+ const modules = loader.modules("channel");
|
|
|
|
|
+ for (const [index, verbs] of [
|
|
|
|
|
+ [0, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "message.receive", "message.read"]],
|
|
|
|
|
+ [1, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open"]],
|
|
|
|
|
+ ]) {
|
|
|
|
|
+ for (const verb of verbs) {
|
|
|
|
|
+ const seen = [];
|
|
|
|
|
+ const result = await modules[index].dispatch({ verb, args: { thread: "thread-one", body: "literal", class: "REQUEST" } }, {
|
|
|
|
|
+ call: async (v, args) => { seen.push([v, args]); return { receipt: "preserved" }; },
|
|
|
|
|
+ });
|
|
|
|
|
+ assert.deepEqual(result, { receipt: "preserved" });
|
|
|
|
|
+ assert.deepEqual(seen, [[verb, { thread: "thread-one", body: "literal", class: "REQUEST" }]]);
|
|
|
|
|
+ }
|
|
|
|
|
+ const refusal = Object.assign(new Error("thread-not-owned"), { code: "thread-not-owned" });
|
|
|
|
|
+ await assert.rejects(modules[index].dispatch({ verb: "thread.list", args: {} }, { call: async () => { throw refusal; } }), e => e === refusal);
|
|
|
|
|
+ let calls = 0;
|
|
|
|
|
+ await assert.rejects(modules[index].dispatch({ verb: "plugins.set", args: {} }, { call: async () => calls++ }), code("channel-operation"));
|
|
|
|
|
+ assert.equal(calls, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ await assert.rejects(modules[1].dispatch({ verb: "message.receive", args: {} }, { call: async () => assert.fail("not a web chat operation") }), code("channel-operation"));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel snapshot is a frozen matched pair and a captured array survives reload", async t => {
|
|
|
|
|
+ const s = tree(t);
|
|
|
|
|
+ s.write("channel", "one", fixtureModule("one"));
|
|
|
|
|
+ s.write("channel", "two", fixtureModule("two"));
|
|
|
|
|
+ const loader = await s.loader();
|
|
|
|
|
+ await loader.load({ channel: ["two", "one"] });
|
|
|
|
|
+ assert.equal(typeof loader.snapshot, "function");
|
|
|
|
|
+ const captured = loader.snapshot("channel");
|
|
|
|
|
+ assert.deepEqual(captured.map(x => x.name), ["two", "one"]);
|
|
|
|
|
+ assert.deepEqual(captured.map(x => x.name), loader.loaded().channel);
|
|
|
|
|
+ assert.deepEqual(captured.map(x => x.module), loader.modules("channel"));
|
|
|
|
|
+ assert.ok(Object.isFrozen(captured));
|
|
|
|
|
+ assert.ok(captured.every(Object.isFrozen));
|
|
|
|
|
+ assert.throws(() => captured.push(captured[0]), TypeError);
|
|
|
|
|
+ await loader.load({ channel: ["one"] });
|
|
|
|
|
+ assert.deepEqual(loader.snapshot("channel").map(x => x.name), ["one"]);
|
|
|
|
|
+ assert.deepEqual(captured.map(x => x.name), ["two", "one"]);
|
|
|
|
|
+ assert.deepEqual(await captured[0].module.dispatch({ verb: "thread.list", args: {} }, { call: async (verb, args) => args }), { fixture: "two" });
|
|
|
|
|
+ assert.throws(() => loader.snapshot("no-such-kind"), code("unknown-kind"));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel guards refuse bad names, missing paths, exports and versions while retaining last good", async t => {
|
|
|
|
|
+ const s = tree(t);
|
|
|
|
|
+ s.write("channel", "good", fixtureModule("good"));
|
|
|
|
|
+ mkdirSync(join(s.pkg, "channel", "empty"));
|
|
|
|
|
+ mkdirSync(join(s.pkg, "channel", "dir-index", "index.mjs"), { recursive: true });
|
|
|
|
|
+ s.write("channel", "missing-export", "export const interfaceVersion = 1;\n");
|
|
|
|
|
+ s.write("channel", "missing-version", "export function dispatch() {}\n");
|
|
|
|
|
+ s.write("channel", "bad-type", "export const interfaceVersion = 1; export const dispatch = 1;\n");
|
|
|
|
|
+ s.write("channel", "wrong-version", "export const interfaceVersion = 2; export function dispatch() {}\n");
|
|
|
|
|
+ const loader = await s.loader();
|
|
|
|
|
+ assert.deepEqual((await loader.load({ channel: ["good"] })).refused, []);
|
|
|
|
|
+ for (const [names, expected] of [
|
|
|
|
|
+ [["../good"], "bad-name"], [["absent"], "missing"], [["empty"], "missing"], [["dir-index"], "missing"],
|
|
|
|
|
+ [["missing-export"], "missing-export"], [["missing-version"], "missing-export"], [["bad-type"], "missing-export"], [["wrong-version"], "interface-version"],
|
|
|
|
|
+ ]) {
|
|
|
|
|
+ const result = await loader.load({ channel: names });
|
|
|
|
|
+ assert.equal(result.refused.length, 1);
|
|
|
|
|
+ assert.equal(result.refused[0].code, expected);
|
|
|
|
|
+ assert.deepEqual(result.loaded.channel, ["good"]);
|
|
|
|
|
+ assert.deepEqual(result.refused[0].kept, ["good"]);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel guards refuse symlinked module, index and kind directories", async t => {
|
|
|
|
|
+ const s = tree(t);
|
|
|
|
|
+ const good = s.write("channel", "good", fixtureModule("good"));
|
|
|
|
|
+ const loader = await s.loader();
|
|
|
|
|
+ await loader.load({ channel: ["good"] });
|
|
|
|
|
+ symlinkSync(good, join(s.pkg, "channel", "linked"));
|
|
|
|
|
+ const index = s.write("channel", "linked-index", fixtureModule("unused"));
|
|
|
|
|
+ unlinkSync(join(index, "index.mjs"));
|
|
|
|
|
+ symlinkSync(join(good, "index.mjs"), join(index, "index.mjs"));
|
|
|
|
|
+ for (const name of ["linked", "linked-index"]) {
|
|
|
|
|
+ const result = await loader.load({ channel: [name] });
|
|
|
|
|
+ assert.equal(result.refused[0].code, "symlink");
|
|
|
|
|
+ const refusedPath = `/channel/${name}${name === "linked-index" ? "/index.mjs" : ""} is a symlink`;
|
|
|
|
|
+ assert.ok(result.refused[0].message.endsWith(refusedPath));
|
|
|
|
|
+ assert.deepEqual(result.loaded.channel, ["good"]);
|
|
|
|
|
+ }
|
|
|
|
|
+ renameSync(join(s.pkg, "channel"), join(s.pkg, "channel-real"));
|
|
|
|
|
+ symlinkSync(join(s.pkg, "channel-real"), join(s.pkg, "channel"));
|
|
|
|
|
+ assert.equal((await loader.load({ channel: ["good"] })).refused[0].code, "symlink");
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel caller re-reads settings and keeps off switches independent by module name", async () => {
|
|
|
|
|
+ const { createChannelCaller } = await helper();
|
|
|
|
|
+ let selected = ["webui"];
|
|
|
|
|
+ const calls = [], logged = [];
|
|
|
|
|
+ const call = async (verb, args) => {
|
|
|
|
|
+ calls.push([verb, args]);
|
|
|
|
|
+ return verb === "plugins" ? settings(selected) : { ok: true };
|
|
|
|
|
+ };
|
|
|
|
|
+ const cli = createChannelCaller({ name: "cli", call, log: entry => logged.push(entry) });
|
|
|
|
|
+ const webui = createChannelCaller({ name: "webui", call, log: entry => logged.push(entry) });
|
|
|
|
|
+ await assert.rejects(cli("thread.list", {}), e => e.code === "channel-disabled" && /mosaic plugins.*mosaic plugins set/s.test(e.message));
|
|
|
|
|
+ assert.deepEqual(calls.splice(0), [["plugins", {}]]);
|
|
|
|
|
+ assert.deepEqual(await webui("thread.list", {}), { ok: true });
|
|
|
|
|
+ assert.deepEqual(calls.splice(0), [["plugins", {}], ["thread.list", {}]]);
|
|
|
|
|
+ selected = ["cli"];
|
|
|
|
|
+ assert.deepEqual(await cli("project.list", {}), { ok: true });
|
|
|
|
|
+ calls.length = 0;
|
|
|
|
|
+ await assert.rejects(webui("thread.send", { body: "private" }), code("channel-disabled"));
|
|
|
|
|
+ assert.deepEqual(calls, [["plugins", {}]]);
|
|
|
|
|
+ for (selected of [[], undefined]) {
|
|
|
|
|
+ calls.length = 0;
|
|
|
|
|
+ await assert.rejects(cli("message.receive", {}), code("channel-disabled"));
|
|
|
|
|
+ assert.deepEqual(calls, [["plugins", {}]]);
|
|
|
|
|
+ }
|
|
|
|
|
+ assert.deepEqual(logged, []);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel caller malformed settings refuse without a chat operation and no unrelated kind loads", async () => {
|
|
|
|
|
+ const { createChannelCaller } = await helper();
|
|
|
|
|
+ let answer = null;
|
|
|
|
|
+ let ops = 0;
|
|
|
|
|
+ const logged = [];
|
|
|
|
|
+ const call = async verb => verb === "plugins" ? answer : ++ops;
|
|
|
|
|
+ const caller = createChannelCaller({ name: "cli", call, log: e => logged.push(e) });
|
|
|
|
|
+ for (answer of [null, [], {}, { plugins: null }, { plugins: [] }]) {
|
|
|
|
|
+ await assert.rejects(caller("project.list", {}), code("channel-config"));
|
|
|
|
|
+ assert.equal(ops, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ answer = { ...settings(["cli"]), plugins: { channel: ["cli"], "usage-source": ["missing-unrelated"] } };
|
|
|
|
|
+ assert.equal(await caller("project.list", {}), 1);
|
|
|
|
|
+ assert.deepEqual(logged, []);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel caller uses last good after loader refusal and fails closed on first load", async () => {
|
|
|
|
|
+ const { createChannelCaller } = await helper();
|
|
|
|
|
+ let selected = ["cli"];
|
|
|
|
|
+ const logged = [];
|
|
|
|
|
+ const call = async verb => verb === "plugins" ? settings(selected) : "kept";
|
|
|
|
|
+ const caller = createChannelCaller({ name: "cli", call, log: e => logged.push(e) });
|
|
|
|
|
+ assert.equal(await caller("project.list", {}), "kept");
|
|
|
|
|
+ selected = ["missing-module"];
|
|
|
|
|
+ assert.equal(await caller("project.list", {}), "kept");
|
|
|
|
|
+ assert.equal(logged[0].code, "missing");
|
|
|
|
|
+ assert.deepEqual(logged[0].kept, ["cli"]);
|
|
|
|
|
+ const fresh = createChannelCaller({ name: "cli", call, log: () => {} });
|
|
|
|
|
+ await assert.rejects(fresh("project.list", {}), code("channel-disabled"));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel dispatch holds no selection lock while a slow send is awaiting", async () => {
|
|
|
|
|
+ const { createChannelCaller } = await helper();
|
|
|
|
|
+ let release, started;
|
|
|
|
|
+ const entered = new Promise(r => started = r);
|
|
|
|
|
+ const held = new Promise(r => release = r);
|
|
|
|
|
+ const caller = createChannelCaller({ name: "cli", log: () => {}, call: async verb => {
|
|
|
|
|
+ if (verb === "plugins") return settings(["cli"]);
|
|
|
|
|
+ if (verb === "thread.send") { started(); await held; return "sent"; }
|
|
|
|
|
+ return "listed";
|
|
|
|
|
+ } });
|
|
|
|
|
+ const send = caller("thread.send", {});
|
|
|
|
|
+ await entered;
|
|
|
|
|
+ let timer;
|
|
|
|
|
+ try {
|
|
|
|
|
+ assert.equal(await Promise.race([caller("project.list", {}), new Promise((_, reject) => timer = setTimeout(() => reject(new Error("dispatch blocked list")), 1000))]), "listed");
|
|
|
|
|
+ } finally { clearTimeout(timer); release(); }
|
|
|
|
|
+ assert.equal(await send, "sent");
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel last realpath check refuses an index swapped after component checks", async t => {
|
|
|
|
|
+ const s = tree(t);
|
|
|
|
|
+ const good = s.write("channel", "good", fixtureModule("good"));
|
|
|
|
|
+ const swapped = s.write("channel", "swapped", fixtureModule("old"));
|
|
|
|
|
+ let swap = false;
|
|
|
|
|
+ const loader = await s.loader({ barrier: (point, { name }) => {
|
|
|
|
|
+ if (swap && point === "checked" && name === "swapped") {
|
|
|
|
|
+ unlinkSync(join(swapped, "index.mjs"));
|
|
|
|
|
+ symlinkSync(join(good, "index.mjs"), join(swapped, "index.mjs"));
|
|
|
|
|
+ }
|
|
|
|
|
+ } });
|
|
|
|
|
+ await loader.load({ channel: ["good"] });
|
|
|
|
|
+ swap = true;
|
|
|
|
|
+ const result = await loader.load({ channel: ["swapped"] });
|
|
|
|
|
+ assert.equal(result.refused[0].code, "symlink");
|
|
|
|
|
+ assert.deepEqual(result.loaded.channel, ["good"]);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel caller refuses invalid core construction", async () => {
|
|
|
|
|
+ const { createChannelCaller } = await helper();
|
|
|
|
|
+ for (const name of ["../cli", "Cli", null])
|
|
|
|
|
+ assert.throws(() => createChannelCaller({ name, call: async () => ({}) }), code("bad-name"));
|
|
|
|
|
+ assert.throws(() => createChannelCaller({ name: "cli", call: null }), TypeError);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel caller invokes the selected loaded module instead of bypassing dispatch", async t => {
|
|
|
|
|
+ const s = tree(t);
|
|
|
|
|
+ s.write("channel", "cli", fixtureModule("cli-fixture"));
|
|
|
|
|
+ s.write("channel", "webui", fixtureModule("webui-fixture"));
|
|
|
|
|
+ const { createChannelCaller } = await import(new URL("./channel.mjs", s.loaderUrl));
|
|
|
|
|
+ const caller = createChannelCaller({ name: "webui", log: () => {}, call: async (verb, args) =>
|
|
|
|
|
+ verb === "plugins" ? settings(["cli", "webui"]) : args });
|
|
|
|
|
+ assert.deepEqual(await caller("thread.list", { project: "one" }), { project: "one", fixture: "webui-fixture" });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("channel caller keeps the selected module operation filter in front of the core transport", async () => {
|
|
|
|
|
+ const { createChannelCaller } = await helper();
|
|
|
|
|
+ let writes = 0;
|
|
|
|
|
+ const caller = createChannelCaller({ name: "cli", log: () => {}, call: async verb =>
|
|
|
|
|
+ verb === "plugins" ? settings(["cli"]) : ++writes });
|
|
|
|
|
+ await assert.rejects(caller("plugins.set", {}), code("channel-operation"));
|
|
|
|
|
+ assert.equal(writes, 0);
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs
|
|
|
|
|
index 9bed0dee8..14c72e311 100644
|
|
|
|
|
--- a/packages/plugins/tests/kinds.test.mjs
|
|
|
|
|
+++ b/packages/plugins/tests/kinds.test.mjs
|
|
|
|
|
@@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
|
|
|
|
|
const code = expected => e => e instanceof PluginError && e.code === expected;
|
|
|
|
|
|
|
|
|
|
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source and limit-check at interface version 1, each a list", () => {
|
|
|
|
|
- assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source"]);
|
|
|
|
|
+ assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel"]);
|
|
|
|
|
assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] },
|
|
|
|
|
{ kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" });
|
|
|
|
|
assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] },
|
|
|
|
|
diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs
|
|
|
|
|
index 2cd0068ed..b4b02eb44 100644
|
|
|
|
|
--- a/packages/plugins/tests/loader.test.mjs
|
|
|
|
|
+++ b/packages/plugins/tests/loader.test.mjs
|
|
|
|
|
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../business/src/business.mjs";
|
|
|
|
|
|
|
|
|
|
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
|
|
|
|
|
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
|
|
|
|
|
-const GOOD_LOADED = { ...GOOD, "search-source": [] };
|
|
|
|
|
+const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [] };
|
|
|
|
|
|
|
|
|
|
// A tree with one good module of each kind, already loaded.
|
|
|
|
|
async function loadedTree(t, options) {
|
|
|
|
|
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
|
|
|
|
|
const loader = await s.loader();
|
|
|
|
|
const result = await loader.load(business.plugins);
|
|
|
|
|
assert.deepEqual(result.refused, []);
|
|
|
|
|
- assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [] });
|
|
|
|
|
+ assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [] });
|
|
|
|
|
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
|
|
|
|
|
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
|
|
|
|
|
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
|
|
|
|
|
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
|
|
|
|
|
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
|
|
|
|
|
assert.deepEqual(result.refused[0].kept, ["meter"]);
|
|
|
|
|
assert.deepEqual(s.loader.logged.slice(before), result.refused);
|
|
|
|
|
- assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [] });
|
|
|
|
|
+ assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [] });
|
|
|
|
|
} finally {
|
|
|
|
|
if (lock) chmodSync(lock, 0o755);
|
|
|
|
|
}
|
|
|
|
|
@@ -214,7 +214,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
|
|
|
|
|
const loader = await s.loader();
|
|
|
|
|
// meter loads, absent refuses: the whole kind stays off, not half on.
|
|
|
|
|
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
|
|
|
|
|
- assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [] });
|
|
|
|
|
+ assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
|
|
|
|
|
assert.deepEqual(loader.loaded(), result.loaded);
|
|
|
|
|
assert.deepEqual(loader.modules("usage-source"), []);
|
|
|
|
|
assert.equal(loader.logged.length, 1);
|
|
|
|
|
@@ -242,7 +242,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
|
|
|
|
|
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
|
|
|
|
|
// An absent key switches the kind off.
|
|
|
|
|
result = await s.loader.load({ "limit-check": ["cap"] });
|
|
|
|
|
- assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [] });
|
|
|
|
|
+ assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
|
|
|
|
|
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
|
|
|
|
|
index 94f765482..5a92dcff8 100644
|
|
|
|
|
--- a/packages/webui/src/chat-http.mjs
|
|
|
|
|
+++ b/packages/webui/src/chat-http.mjs
|
|
|
|
|
@@ -10,6 +10,7 @@ const json = (res, status, value) => {
|
|
|
|
|
res.end(JSON.stringify(value));
|
|
|
|
|
};
|
|
|
|
|
const STATUS = {
|
|
|
|
|
+ 'channel-disabled': 503,
|
|
|
|
|
'invalid-request': 400, 'unknown-repo': 400, 'message-not-found': 400,
|
|
|
|
|
'project-not-found': 404, 'thread-not-found': 404, 'thread-not-owned': 403,
|
|
|
|
|
'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
|
|
|
|
|
@@ -17,6 +18,7 @@ const STATUS = {
|
|
|
|
|
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
|
|
|
|
|
};
|
|
|
|
|
function safeMessage(code) {
|
|
|
|
|
+ if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
|
|
|
|
|
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
|
|
|
|
|
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
|
|
|
|
|
return `Chat operation refused (${code}).`;
|
|
|
|
|
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
|
|
|
|
|
index 85d3cda92..2cf33ee78 100644
|
|
|
|
|
--- a/packages/webui/src/chat.mjs
|
|
|
|
|
+++ b/packages/webui/src/chat.mjs
|
|
|
|
|
@@ -1,14 +1,26 @@
|
|
|
|
|
// Row 129: the single seam between Console chat operations and the current human transport.
|
|
|
|
|
// No browser-selected bus verb, tenant, owner, capability or message class crosses this seam.
|
|
|
|
|
+import { createChannelCaller } from '../../plugins/src/channel.mjs';
|
|
|
|
|
+import { PluginError } from '../../plugins/src/kinds.mjs';
|
|
|
|
|
+import { BusReadError } from './bus.mjs';
|
|
|
|
|
+
|
|
|
|
|
export function createChat({ call }) {
|
|
|
|
|
if (typeof call !== 'function') throw new TypeError('chat needs a human transport');
|
|
|
|
|
+ const dispatch = createChannelCaller({ name: 'webui', call });
|
|
|
|
|
+ const channel = async (verb, args) => {
|
|
|
|
|
+ try { return await dispatch(verb, args); }
|
|
|
|
|
+ catch (err) {
|
|
|
|
|
+ if (err instanceof PluginError) throw new BusReadError(err.code, err.message);
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
return Object.freeze({
|
|
|
|
|
- projects: () => call('project.list', {}),
|
|
|
|
|
- createProject: ({ name, repo }) => call('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
|
|
|
|
- threads: ({ project } = {}) => call('thread.list', project === undefined ? {} : { project }),
|
|
|
|
|
- createThread: ({ project, name }) => call('thread.create', { project, name }),
|
|
|
|
|
- messages: ({ thread, after, limit }) => call('thread.messages', { thread, ...(after ? { after } : {}), limit }),
|
|
|
|
|
- send: ({ thread, body }) => call('thread.send', { thread, body, class: 'REQUEST' }),
|
|
|
|
|
- open: ({ thread }) => call('thread.open', { thread }),
|
|
|
|
|
+ projects: () => channel('project.list', {}),
|
|
|
|
|
+ createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
|
|
|
|
+ threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
|
|
|
|
+ createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
|
|
|
|
+ messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
|
|
|
|
|
+ send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
|
|
|
|
|
+ open: ({ thread }) => channel('thread.open', { thread }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
diff --git a/packages/webui/tests/channel-chat.test.mjs b/packages/webui/tests/channel-chat.test.mjs
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 000000000..c550cd39b
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/packages/webui/tests/channel-chat.test.mjs
|
|
|
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
+// Row 164: the chat switch and fixed recovery message stay local to chat HTTP.
|
|
|
|
|
+import test from "node:test";
|
|
|
|
|
+import assert from "node:assert/strict";
|
|
|
|
|
+import { request } from "node:http";
|
|
|
|
|
+import { createChat } from "../src/chat.mjs";
|
|
|
|
|
+import { startServer } from "../src/serve.mjs";
|
|
|
|
|
+import { BusReadError } from "../src/bus.mjs";
|
|
|
|
|
+import { close } from "./fixture.mjs";
|
|
|
|
|
+const P = "10000000-0000-4000-8000-000000000001";
|
|
|
|
|
+const T = "20000000-0000-4000-8000-000000000002";
|
|
|
|
|
+const preview = { host: "chat.example.com", header: "remote-user", user: "jason" };
|
|
|
|
|
+const headers = { host: preview.host, "remote-user": "jason", origin: "https://chat.example.com", "sec-fetch-site": "same-origin", "content-type": "application/json" };
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+function http(port, path, body) {
|
|
|
|
|
+ const raw = body === undefined ? undefined : JSON.stringify(body);
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const req = request({ host: '127.0.0.1', port, path, method: raw === undefined ? 'GET' : 'POST',
|
|
|
|
|
+ headers: { ...headers, ...(raw === undefined ? {} : { 'content-length': Buffer.byteLength(raw) }) },
|
|
|
|
|
+ }, res => {
|
|
|
|
|
+ let text = '';
|
|
|
|
|
+ res.on('data', chunk => text += chunk);
|
|
|
|
|
+ res.on('end', () => resolve({ status: res.statusCode, data: JSON.parse(text) }));
|
|
|
|
|
+ });
|
|
|
|
|
+ req.on('error', reject); req.end(raw);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function setup(t, selected) {
|
|
|
|
|
+ const calls = [], logs = [];
|
|
|
|
|
+ const chat = createChat({ call: async (verb, args) => {
|
|
|
|
|
+ calls.push([verb, args]);
|
|
|
|
|
+ if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } };
|
|
|
|
|
+ return [];
|
|
|
|
|
+ } });
|
|
|
|
|
+ const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: line => logs.push(line) });
|
|
|
|
|
+ t.after(() => close(server));
|
|
|
|
|
+ return { calls, logs, async request(path, body) {
|
|
|
|
|
+ return http(server.address().port, path, body);
|
|
|
|
|
+ } };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+test("WebUI channel off returns 503 and named recovery for every chat route without a chat operation", async t => {
|
|
|
|
|
+ for (const selected of [[], undefined, ["cli"]]) {
|
|
|
|
|
+ const s = await setup(t, selected);
|
|
|
|
|
+ for (const [path, body] of [
|
|
|
|
|
+ ["/api/chat/projects"], ["/api/chat/threads"], [`/api/chat/threads/${T}/messages`],
|
|
|
|
|
+ ["/api/chat/projects", { name: "work" }], ["/api/chat/threads", { project: P, name: "chat" }],
|
|
|
|
|
+ [`/api/chat/threads/${T}/messages`, { body: "private body" }], [`/api/chat/threads/${T}/open`, {}],
|
|
|
|
|
+ ]) {
|
|
|
|
|
+ s.calls.length = 0;
|
|
|
|
|
+ const res = await s.request(path, body);
|
|
|
|
|
+ assert.equal(res.status, 503);
|
|
|
|
|
+ assert.equal(res.data.error, "channel-disabled");
|
|
|
|
|
+ assert.match(res.data.message, /mosaic plugins.*mosaic plugins set/s);
|
|
|
|
|
+ assert.match(res.data.message, /plugins\.channel/);
|
|
|
|
|
+ assert.match(res.data.message, /cli.*webui/);
|
|
|
|
|
+ if (body !== undefined) assert.equal(res.data.sent, false);
|
|
|
|
|
+ assert.deepEqual(s.calls, [["plugins", {}]]);
|
|
|
|
|
+ assert.doesNotMatch(JSON.stringify(res.data), /private body/);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("WebUI only selection keeps web chat enabled while retaining the old read envelope", async t => {
|
|
|
|
|
+ const s = await setup(t, ["webui"]);
|
|
|
|
|
+ const res = await s.request("/api/chat/projects");
|
|
|
|
|
+ assert.equal(res.status, 200);
|
|
|
|
|
+ assert.deepEqual(res.data.rows, []);
|
|
|
|
|
+ assert.deepEqual(s.calls, [["plugins", {}], ["project.list", {}]]);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test("WebUI channel-disabled recovery text is fixed and never echoes transport text", async t => {
|
|
|
|
|
+ const chat = createChat({ call: async () => { throw new BusReadError("channel-disabled", "SENTINEL-private-input"); } });
|
|
|
|
|
+ const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} });
|
|
|
|
|
+ t.after(() => close(server));
|
|
|
|
|
+ const res = await http(server.address().port, '/api/chat/projects');
|
|
|
|
|
+ const result = res.data;
|
|
|
|
|
+ assert.equal(res.status, 503);
|
|
|
|
|
+ assert.equal(result.error, "channel-disabled");
|
|
|
|
|
+ assert.match(result.message, /mosaic plugins.*mosaic plugins set/s);
|
|
|
|
|
+ assert.doesNotMatch(JSON.stringify(result), /SENTINEL/);
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
|
|
|
|
|
index 00ee8326a..5b6611bb3 100644
|
|
|
|
|
--- a/packages/webui/tests/chat.test.mjs
|
|
|
|
|
+++ b/packages/webui/tests/chat.test.mjs
|
|
|
|
|
@@ -33,7 +33,12 @@ function http(port, path, { method = 'GET', value, raw, extra = {}, host = previ
|
|
|
|
|
async function setup(t, options = {}) {
|
|
|
|
|
const calls = [], logs = [];
|
|
|
|
|
const state = { answer: [] };
|
|
|
|
|
+ const managementReads = [];
|
|
|
|
|
const chat = createChat({ call: async (verb, args) => {
|
|
|
|
|
+ if (verb === 'plugins') {
|
|
|
|
|
+ managementReads.push([verb, args]);
|
|
|
|
|
+ return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
|
|
|
|
|
+ }
|
|
|
|
|
calls.push([verb, args]);
|
|
|
|
|
if (state.answer instanceof Error) throw state.answer;
|
|
|
|
|
return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer;
|
|
|
|
|
@@ -189,7 +194,12 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
|
|
|
|
|
let b = new Broker({ store, businesses });
|
|
|
|
|
let cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
|
|
|
const calls = [];
|
|
|
|
|
+ const managementReads = [];
|
|
|
|
|
const chat = createChat({ call: async (verb, args) => {
|
|
|
|
|
+ if (verb === 'plugins') {
|
|
|
|
|
+ managementReads.push([verb, args]);
|
|
|
|
|
+ return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
|
|
|
|
|
+ }
|
|
|
|
|
calls.push(verb);
|
|
|
|
|
try { return structuredClone(await b.request(cap, { verb, args })); }
|
|
|
|
|
catch (err) { throw new BusReadError(err.code); }
|