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 /); } 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); }