webui, bus, scripts: a loopback-only Console listener for the tester; 7332 refuses x-mosaic-tester (row 176, #1674)

serve --tester-port N starts a second listener on 127.0.0.1:N in the same
process, with a bus caller through TESTER_CLI. N is 1024..65535, not
--port and never 7332, and the flag needs the bus configured. The tester
listener takes only instances.set, routes.set and name.set; settings
save, project-file save, assistant stop, bus message and chat writes
refuse 403 with no bus call. It refuses the main listener's auth header,
and every other listener refuses any request carrying x-mosaic-tester.
/api/config answers tester: true and the page shows a tester banner with
the routes, instances and seat name editors only. tester-off and
tester-required map to 403 in a serve.mjs coreBusStatus wrapper, so
bus.mjs stays pinned (row 164).

The broker lets a tester read credentials (with #secretCheck), routes,
project.files and project.file; project.file.save stays human-only.
webui-service.sh takes --tester-port with serve's checks. stack-release
activate --tester-port N writes the flag to the pointer and the unit,
refuses tester-port-target when the target copy's Console lacks it, and
activate or rollback to such a copy drops the flag and says so in --plan.
docs/TOOLS.md rewords the stale row 175 two-phase step (#1673 F1).

Built by Rocko on 473258a51 (manifest a4c728fc...52c9), approved by Dewey
in round 2 (#1674 comment 29322) after round 1 changes (29309), Sage's
gate green on 473258a51 plus the patch in run 2. Run 1 failed T34's
failed-pm_launched subtest with capture-failed: activate and pm-on name
the activation by the second, so the two ran in one second and collided
on the rollback set. That race is row 169's (the base fails the same way
with the id pinned), and a follow-up row fixes it (Vikunja P19 391).
This commit is contained in:
2026-10-19 20:18:14 -05:00
parent 06a82c9fb0
commit 89af38a83a
18 changed files with 671 additions and 75 deletions
+37 -10
View File
@@ -449,16 +449,15 @@ copy fails its gate and rolls back. Use two phases instead (row 169, Gitea
gate check but the PM's, and writes the pointer with `pm` false and
`pmPending` true. The log's final line is `result ok` with `pm pending`.
`status` and `--plan` print the pending state and the next step.
2. Jason sets `harness` with `plugins set` through the live bus. Until row
175 lands, `scripts/mosaic` doesn't dispatch `plugins`, so the command is
`node <copy>/packages/cli/src/cli.mjs plugins set --file P --business ID`.
`<copy>` is the copy the bus runs from, the pointer's; the bus refuses
`human-required` from any other tree. P holds one patch,
`{"revision":"<64 hex>","plugins":{"<kind>":<value>|null}}`. The revision
comes from `node <copy>/packages/cli/src/cli.mjs plugins --business ID`.
A kind with a value is set to it, `null` removes the kind, and a kind the
patch doesn't name keeps its setting. Row 175's fixed `mosaic plugins
set` replaces this form once that row lands.
2. Jason sets `harness` with `plugins set` through the live bus. Copies from
before 98d29769e use `node <copy>/packages/cli/src/cli.mjs plugins set --file P
--business ID`; later copies dispatch `mosaic plugins set --file P --business ID`.
On the Q173 test system, the tester can run the latter with `--tester`.
`<copy>` is the pointer's copy, the one the bus runs from; human commands from
any other tree refuse `human-required`. P holds one patch,
`{"revision":"<64 hex>","plugins":{"<kind>":<value>|null}}`. Read the revision
with the same copy's `plugins --business ID` command (`--tester` for the tester).
A value sets the kind, null removes it, and an unnamed kind keeps its setting.
3. `pm-on BUSINESS` refuses `not-pending` without a pending pointer and
`harness-unset` unless `harness` is a non-empty list the pointer's copy
loads. It restarts the bus alone, with `--pm` on the same copy, and runs
@@ -813,3 +812,31 @@ oldest-first in pages of 100, with a 200-message display window. Sending does
not claim an assistant answer; an uncertain receipt is never retried. Read the
current list/history before trying again. Activation still needs Mos's separate
window; changing these files does not authorize a live Console action.
### Console tester listener (`serve --tester-port N`, row 176)
`node packages/webui/src/cli.mjs serve --tester-port N` adds a second listener
bound only to `127.0.0.1:N`. N must be an integer in 1024..65535, different
from `--port` and never 7332 (the tunnel's forwarded port). It requires the
bus configuration. It calls only the tester shim; the main listener keeps its
existing human transport and rejects every `x-mosaic-tester` request.
The tester listener rejects the main preview auth header and only accepts its
own loopback Host/port. Its Settings banner identifies the tester. Its instance,
route and name editors use the tester's three writes; settings, document saves,
assistant stop, role messages and chat writes stay refused. Writes require the
exact `http://127.0.0.1:N` Origin, same-origin Fetch Metadata, JSON and the existing
bounded strict bodies. `tester-off` and `tester-required` answer HTTP 403.
`scripts/webui-service.sh render|install --tester-port N` carries the flag into
ExecStart, and status names its URL. Bad tester ports exit 4. Install remains a
unit write for the authorized operator, with no start/restart verb.
`stack-release.sh activate ID BUSINESS --tester-port N` replaces or adds this
Console flag in both the installed unit and the pointer. It checks the target
copy's own Console before stopping anything; an explicit unsupported request
refuses `tester-port-target`. Without an explicit flag, activate and rollback
keep it between supporting copies, or drop it when the target predates support.
The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
These operations retain their existing Mos/Jason window authority. Nothing in
this row forwards the tester port through the tunnel or changes live units.
+7 -8
View File
@@ -95,10 +95,10 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
// Row 175 (#1673, Q173): the test system's reads and two writes, name.set and project.create,
// each recorded with `tester` as its actor. The organization branch keeps its own list below.
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create']);
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; limits.set, organization.set, routes and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set']);
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -1403,17 +1403,16 @@ export class Broker {
}
}
// Row 148 (#1642): the Console's reads and saves of the spec and docs. Human only: an agent
// session, a reader and a tester all refuse, and an agent writes through record.write. Like
// session and a reader refuse; a tester can read only, and an agent writes through record.write. Like
// #requestProject these are async Git effects, so no SQL transaction spans an await. A save is
// the project's own commit; the repository holds the record, the bus keeps no copy.
async #requestProjectFile(s, request) {
const { verb, args } = request;
let ProjectFailure;
try {
// Row 175 (#1673): these verbs dispatch before the store's tester gate, so they refuse a
// tester here, tester-refused like every other human-only verb.
if (s.tester) fail('tester-refused');
this.#human(s);
// Row 176: tester reads use the same bounded committed-Git adapter; saving stays human-only.
if (s.tester && verb === 'project.file.save') fail('project-spec-human-only');
if (!s.tester) this.#human(s);
if (!this.#project?.listFiles) fail('project-not-configured');
const { listFiles, readFile, writeFile, ProjectError } = this.#project;
ProjectFailure = ProjectError;
+6 -6
View File
@@ -30,7 +30,7 @@ function scratch(t, prefix) {
return root;
}
test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, the tester may only set, and each passes the verified actor', (t) => {
test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, the tester may read and set, and each passes the verified actor', (t) => {
const store = new Store(scratch(t, 'routes-verb-'));
t.after(() => store.close());
const calls = [];
@@ -42,8 +42,8 @@ test('row 65: routes and routes.set refuse every non-human session, the chief of
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
b.request(caps[role], { verb: 'role.claim' });
}
// Row 175 (#1673): the tester sets routes but never reads them (routes stays the human's).
assert.throws(() => b.request(tester, { verb: 'routes', args: {} }), /tester-refused/);
// Row 176 (#1674): the tester reads routes as well as setting them.
assert.deepEqual(b.request(tester, { verb: 'routes', args: {} }), { ok: true });
for (const [who, cap] of Object.entries(caps))
for (const verb of ['routes', 'routes.set'])
assert.throws(() => b.request(cap, { verb, args: verb === 'routes' ? {} : SET }), /human-required/, `${who} ${verb}`);
@@ -52,17 +52,17 @@ test('row 65: routes and routes.set refuse every non-human session, the chief of
assert.equal(ORGANIZATION_KEYS.includes('routes'), false);
for (const args of [SET, { revision: SET.revision, routes: SET.routes, instances: { pm: 'sonnet' } }])
assert.throws(() => b.request(caps.chief, { verb: 'organization.set', args }), /invalid-request/);
assert.equal(calls.length, 0);
assert.equal(calls.length, 1);
assert.throws(() => b.request(human, { verb: 'routes.set', args: { ...SET, actor: 'forged' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'routes.set', args: { revision: SET.revision } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'routes.set', args: { routes: SET.routes } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'routes', args: { x: 1 } }), /invalid-request/);
assert.equal(calls.length, 0);
assert.equal(calls.length, 1);
b.request(human, { verb: 'routes' });
b.request(human, { verb: 'routes.set', args: SET });
assert.throws(() => b.request(tester, { verb: 'routes.set', args: { ...SET, actor: 'forged' } }), /invalid-request/);
b.request(tester, { verb: 'routes.set', args: SET });
assert.deepEqual(calls, [['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason'], ['demo', 'routes.set', SET, 'tester']]);
assert.deepEqual(calls, [['demo', 'routes', {}, 'tester'], ['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason'], ['demo', 'routes.set', SET, 'tester']]);
});
test('row 65: a new launch binds only on a switched-on harness with a module name, and a run an earlier broker bound binds again after its harness is switched off', (t) => {
+4 -6
View File
@@ -17,9 +17,9 @@ const READS = [['usage', {}], ['names', {}], ['limits', {}], ['plugins', {}], ['
test('row 175: TESTER_VERBS and the organization branch tester set are pinned', () => {
assert.deepEqual([...TESTER_VERBS], [
'message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
]);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set']);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
});
@@ -122,7 +122,6 @@ test('row 175: the human-only actions refuse a tester tester-refused, and the fi
for (const [verb, args] of [
['limits.set', { revision, limits: { total: 0 } }],
['organization.set', { revision, limits: { total: 0 } }],
['routes', {}],
['organization.revision', {}],
// The gate runs before the argument checks: a bad key still refuses tester-refused.
['organization.set', { revision, nope: 1 }],
@@ -130,7 +129,6 @@ test('row 175: the human-only actions refuse a tester tester-refused, and the fi
['decision.resolve', { id: 'd-1', choice: 'yes' }],
['launch.revoke', {}],
['launch.restore', {}],
['credentials', {}],
['assistant.stop', { thread }],
['thread.create', { project, name: 'two' }],
['thread.open', { thread }],
@@ -138,8 +136,8 @@ test('row 175: the human-only actions refuse a tester tester-refused, and the fi
['thread.messages', { thread }],
]) assert.equal(r.code(r.tester, verb, args), 'tester-refused', verb);
// The project file verbs answer a promise.
for (const [verb, args] of [['project.file.save', { project: 'stack', path: 'README.md', content: 'x', head: 'a'.repeat(40) }], ['project.files', { project: 'stack' }]])
await assert.rejects(Promise.resolve().then(() => r.call(r.tester, verb, args)), { code: 'tester-refused' }, verb);
for (const [verb, args] of [['project.file.save', { project: 'stack', path: 'README.md', content: 'x', head: 'a'.repeat(40) }]])
await assert.rejects(Promise.resolve().then(() => r.call(r.tester, verb, args)), { code: 'project-spec-human-only' }, verb);
assert.equal(r.bytes(), before);
assert.equal(r.call(r.human, 'limits').revision, revision);
assert.deepEqual(views(), seen);
-1
View File
@@ -88,7 +88,6 @@ test('every verb outside the tester list refuses tester-refused and is recorded
['launch.restore', {}],
['event.emit', { kind: 'action.allowed', body: { action: 'routine' } }],
['state', {}],
['credentials', {}],
['requirements', {}],
];
for (const [verb, args] of refused) assert.throws(() => call(tester, verb, args), { code: 'tester-refused' }, verb);
+73 -1
View File
@@ -248,7 +248,7 @@ const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-b
assert.notEqual(KINDS_OLD,KINDS_NEW);
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
const d=join(COPIES,id);
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs','// fixture Console, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
}
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
@@ -1023,3 +1023,75 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
out=await quiet(0,'rollback','acme','--plan');
assert.deepEqual(out.slice(-5),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
});
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
const testerFlags=(e,port='7333')=>['--port',e.port,'--business','acme','--tester-port',port];
const consoleText=(e)=>readFileSync(join(e.units,CON),'utf8');
const stepText=(e)=>readFileSync(join(e.state,'stack-rollback',pointer(e).activation,'steps.log'),'utf8');
test('T37 tester-port activate writes pointer and unit, replaces the port, and supported re-pin and rollback keep it',async(t)=>{
const e=await mk(t);await seed(e);
let r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,testerFlags(e));assert.match(consoleText(e),/--tester-port 7333(?:\n|$)/);
r=await run(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
r=await run(e,'activate',B,'acme','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,testerFlags(e,'7334'));
assert.match(consoleText(e),/--tester-port 7334(?:\n|$)/);assert.doesNotMatch(consoleText(e),/--tester-port 7333/);
assert.equal((consoleText(e).match(/--tester-port/g)||[]).length,1);
r=await run(e,'activate',C1,'acme','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/Console: .*--tester-port 7334/);
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.deepEqual(pointer(e).console,testerFlags(e,'7334'));
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
assert.deepEqual(pointer(e).console,testerFlags(e,'7334'));assert.match(consoleText(e),/--tester-port 7334(?:\n|$)/);
});
test('T38 tester-port rollback to unsupported target drops flag in plan, steps, unit and pointer',async(t)=>{
const e=await mk(t);await seed(e,{copy:A});
let r=await run(e,'activate',B,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
const before=fp(e),n=calls(e).length;
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
assert.match(r.out,/drop[^\n]*--tester-port 7333/i);assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--tester-port/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,A);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme']);assert.doesNotMatch(consoleText(e),/--tester-port/);
assert.match(stepText(e),/drop[^\n]*--tester-port 7333/i);
});
test('T39 tester-port explicit unsupported target reads target copy and refuses before stopping either unit',async(t)=>{
const e=await mk(t);await seed(e);const before=fp(e),n=calls(e).length;
const r=await run(e,'activate',A,'acme','--tester-port','7333');assert.equal(r.code,3,r.out+r.err);
assert.match(r.err,/refusing tester-port-target:/);assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
assert.equal(units(e)[BUS].state,'active');assert.equal(units(e)[CON].state,'active');
// The repository's Console accepts the flag while A's Console does not: probing REPO fails this test.
assert.match(readFileSync(join(repo,'packages/webui/src/cli.mjs'),'utf8'),/--tester-port/);
});
test('T40 tester-port invalid values refuse exit 4 before stopping, including tunnel and main ports',async(t)=>{
const e=await mk(t);await seed(e);const before=fp(e),n=calls(e).length;
for(const value of ['7332',e.port,'1023','65536','x','7333.5']){
const r=await run(e,'activate',C1,'acme','--tester-port',value);assert.equal(r.code,4,`${value}: ${r.out}${r.err}`);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
}
});
test('T41 tester-port inherited activate to unsupported target drops it and retains drift refusal and failed-run recovery',async(t)=>{
const e=await mk(t);await seed(e);
let r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
r=await run(e,'activate',A,'acme','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/drop[^\n]*--tester-port 7333/i);
fault(e,`check-${A}`);const before=fp(e);
r=await run(e,'activate',A,'acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);assert.deepEqual(fp(e),before);
rmSync(join(e.sd,'faults',`check-${A}`));
edit(join(e.units,CON),'--tester-port 7333','--tester-port 7334');const n=calls(e).length;
r=await run(e,'activate',A,'acme');assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/unit-flags-drift/);assert.deepEqual(startsStops(e,n),[]);
edit(join(e.units,CON),'--tester-port 7334','--tester-port 7333');
r=await run(e,'activate',A,'acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme']);assert.doesNotMatch(consoleText(e),/--tester-port/);
assert.match(stepText(e),/drop[^\n]*--tester-port 7333/i);
});
test('T42 tester-port pm-on preserves pointer Console flags',async(t)=>{
const e=await mk(t);await seed(e,{pm:true});
let r=await run(e,'activate',C1,'acme','--pm-after-harness','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
biz(e,HARNESS);
r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,testerFlags(e));assert.match(consoleText(e),/--tester-port 7333(?:\n|$)/);
});
+23
View File
@@ -939,3 +939,26 @@ id and de-duplicates replies. An accepted receipt confirms the message only,
not that the assistant has answered. An uncertain result never retries: read
the list/history before explicitly trying again. Drafts are memory-only and
thread-scoped; sign-out clears private chat state. Bodies are inert text.
### Tester listener
`serve --tester-port 7333` starts a second listener on `127.0.0.1` only and
requires the bus config. The main listener keeps the human transport and rejects
`x-mosaic-tester` for every Host. The second listener rejects the main preview
auth header and uses `TESTER_CLI` for bus reads and the instance, route and name
writes. Its banner says tester; it offers only those editors. It never enables
settings saves, document saves, assistant stop, messages to roles or chat writes.
Both credential and committed-project reads keep their existing secret/path checks.
The tester port must be 1024..65535, different from `--port`, and never the
forwarded port 7332. Writes need the listener's own loopback Origin and
same-origin Fetch Metadata, plus the existing bounded strict JSON bodies.
With the tester disabled by the business file, reads refuse HTTP 403 and the
page says the tester is off. The listener's address and identity are printed at start.
The service script accepts the same `--tester-port` flag. The authorized window
uses `stack-release.sh activate ID BUSINESS --tester-port 7333` so both the unit
and pointer carry it. A target that lacks support refuses an explicit flag before
stopping anything. Re-pins/rollbacks preserve it for supporting targets and drop
it, visibly in the plan and steps log, for older ones. See docs/TOOLS.md. No live
listener, unit or tunnel changes are part of a build or test.
+22 -4
View File
@@ -2,6 +2,7 @@
import { createChat } from './chat.mjs';
import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller } from './bus.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
import { configDir } from '../../business/src/business.mjs';
@@ -10,12 +11,12 @@ import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {};
let sendOn = false;
let sendOn = false, testerPort = null;
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
@@ -25,6 +26,9 @@ try {
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--tester-port') {
if (!/^\d{1,5}$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1024 || Number(value) > 65535) throw new Error('--tester-port must be a whole number in 1024..65535');
testerPort = Number(value);
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
@@ -44,6 +48,7 @@ try {
else if (flag === '--allow-user') preview.allowUser = value;
else throw new Error(`unknown option: ${flag}`);
}
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
options.preview = previewOptions(preview);
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
// The bus views (slice 1 S5) need the system config. Without it the
@@ -70,14 +75,27 @@ try {
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
let testerServer = null;
if (testerPort !== null) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
tester: true, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
});
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
+40 -9
View File
@@ -262,6 +262,7 @@
// render. That read marks nothing. Row 150 (#1644): since row 142, `mosaic talk` prints only the
// reply to its own message, so a reply to a Console send stays unread in `mosaic inbox` until
// the human marks it read with `mosaic inbox --read`.
let isTester = false;
let canSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
@@ -687,7 +688,7 @@
const title='<h2 id="instances-title">Role instances</h2>';
if(!view)return `<section class="panel" aria-labelledby="instances-title">${title}<p class="small warn">The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.</p></section>`;
if(!instanceDrafts.add&&!instanceDrafts.change)instanceView=view;
const v=instanceView, disabled=!canSend||out()||instanceBusy?' disabled':'';
const v=instanceView, disabled=!(canSend || isTester)||out()||instanceBusy?' disabled':'';
const defs=Object.entries(v.definitions), accepted=defs.filter(([,d])=>d.accepted).map(([n])=>n);
const bound=defs.filter(([,d])=>Array.isArray(d.services)&&d.services.length).map(([n,d])=>`${txt(n)} (${d.services.map(txt).join(', ')})`);
const changeable=Object.entries(v.instances).filter(([,e])=>!e.protected).map(([n])=>n);
@@ -698,7 +699,7 @@
const changeLaunch=v.launch?`<label>Launch <select data-instance-field="launch"${disabled}>${[['','Keep'],['yes','Launch it'],['no','Don\'t launch it']].map(([x,l])=>`<option value="${x}"${(c.launch??'')===x?' selected':''}>${l}</option>`).join('')}</select></label>`:'';
const addForm=`<form id="instance-add-form" class="instance-form" data-op="add"><fieldset><legend>Add an instance</legend><div class="filters"><label>Instance <input data-instance-field="instance" value="${esc(a.instance??'')}" maxlength="64"${disabled}></label><label>Definition <select data-instance-field="definition"${disabled}>${opts(accepted,a.definition)}</select></label>${addLaunch}</div><label>Vars (JSON, empty for none) <textarea data-instance-field="vars" rows="3"${disabled}>${esc(a.vars??'')}</textarea></label><button type="submit"${disabled}>Add instance</button></fieldset></form>`;
const changeForm=changeable.length?`<form id="instance-change-form" class="instance-form" data-op="change"><fieldset><legend>Change an instance</legend><div class="filters"><label>Instance <select data-instance-field="instance"${disabled}>${opts(changeable,c.instance)}</select></label><label>Definition <select data-instance-field="definition"${disabled}>${opts(accepted,c.definition,'Keep the definition')}</select></label>${changeLaunch}</div><label>Vars (JSON, replaces them whole; empty keeps them) <textarea data-instance-field="vars" rows="3"${disabled}>${esc(c.vars??'')}</textarea></label><button type="submit"${disabled}>Save instance</button></fieldset></form>`:'';
return `<section class="panel" aria-labelledby="instances-title">${title}<p>Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.</p><div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table id="instances-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Vars</th></tr></thead><tbody>${rows}</tbody></table></div>${addForm}${changeForm}${bound.length?`<p class="small muted">Definitions that declare credentials, a hand edit: ${bound.join(', ')}.</p>`:''}<p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p><button type="button" id="instances-reload">Read current instances and discard drafts</button><p role="status" id="instances-receipt">${txt(instanceReceipt)}</p></section>`;
return `<section class="panel" aria-labelledby="instances-title">${title}<p>Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.</p><div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table id="instances-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Vars</th></tr></thead><tbody>${rows}</tbody></table></div>${addForm}${changeForm}${bound.length?`<p class="small muted">Definitions that declare credentials, a hand edit: ${bound.join(', ')}.</p>`:''}<p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p><button type="button" id="instances-reload">Read current instances and discard drafts</button><p role="status" id="instances-receipt">${txt(instanceReceipt)}</p></section>`;
}
const instanceFields=form=>Object.fromEntries([...form.querySelectorAll('[data-instance-field]')].map(el=>[el.dataset.instanceField,el.type==='checkbox'?el.checked:el.value]));
function instancePayload(op,f){
@@ -718,7 +719,7 @@
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form)instanceDrafts[form.dataset.op]=instanceFields(form);});
document.addEventListener('click',e=>{if(e.target.id!=='instances-reload')return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';last.delete('instances');render(true);});
document.addEventListener('submit',async e=>{
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||instanceBusy||!instanceView)return;
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||instanceBusy||!instanceView)return;
const op=form.dataset.op;instanceDrafts[op]=instanceFields(form);let payload;
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;render(false);return;}
instanceBusy=true;instanceReceipt='';render(false);
@@ -741,7 +742,7 @@
const title='<h2 id="routes-title">Routing</h2>';
if(!view)return `<section class="panel" aria-labelledby="routes-title">${title}<p class="small warn">The routes could not be read, so there is nothing to change. Read again to retry; they come from the bus host with your identity.</p></section>`;
if(!routesDraft)routesView=view;
const v=routesView, disabled=!canSend||out()||routesBusy?' disabled':'';
const v=routesView, disabled=!(canSend || isTester)||out()||routesBusy?' disabled':'';
const cells=name=>routesDraft?.[name]??routeCells(v.instances[name]);
const select=(name,key,list,current,why)=>{const offered=current===''||list.includes(current)?list:[...list,current];
return `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'none':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
@@ -752,7 +753,7 @@
const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
return `<tr><th scope="row">${txt(name)}</th><td>${harness('harness')}</td><td>${model('model')}</td><td>${thinking('thinking','harness')}</td><td>${harness('fallback.harness')}</td><td>${model('fallback.model')}</td><td>${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. <code>mosaic plugins set</code> switches one on.</p>';
return `<section class="panel" aria-labelledby="routes-title">${title}<p>Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
return `<section class="panel" aria-labelledby="routes-title">${title}<p>Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
}
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
@@ -772,7 +773,7 @@
if(kind==='change'&&/harness"\]$/.test(e.target.dataset?.route??''))render(false);});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!canSend||out()||routesBusy||!routesView)return;
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
@@ -780,6 +781,28 @@
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
// Tester management is deliberately separate from the human's assistant/settings forms.
function testerNames(names, instances) {
if (!instances || !names) return '';
const current = Object.fromEntries(names.map(row => [row.instance, row.name]));
return `<section class="panel" aria-labelledby="tester-names-title"><h2 id="tester-names-title">Seat names</h2>${Object.keys(instances.instances).map(instance => `<form class="tester-name-form" data-instance="${esc(instance)}"><label>${txt(instance)} <input name="name" maxlength="64" value="${esc(nameDrafts[instance] ?? current[instance] ?? '')}"${assistantBusy || out() ? ' disabled' : ''}></label><button type="submit"${assistantBusy || out() ? ' disabled' : ''}>Set name</button><p role="status">${txt(assistantReceipts[instance] ?? '')}</p></form>`).join('')}</section>`;
}
document.addEventListener('input', event => {
const form = event.target.closest?.('.tester-name-form');
if (form) nameDrafts[form.dataset.instance] = event.target.value;
});
document.addEventListener('submit', async event => {
const form = event.target.closest?.('.tester-name-form');
if (!form || event.target !== form) return;
event.preventDefault();
if (!isTester || assistantBusy || out()) return;
const instance = form.dataset.instance, name = form.elements.name.value.trim();
await assistantPost('/api/name', { instance, name }, instance, result => {
if (typeof result?.name !== 'string') return unknownAssistant;
delete nameDrafts[instance]; last.delete('names');
return `Seat name set to ${result.name}.`;
});
});
async function settingsView(get) {
const s = await get.api('settings'), n = s.notifier || {};
let usage, names = null;
@@ -791,6 +814,7 @@
try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
let limitRows = [], limitsRefused = false;
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
if (isTester) return `${head('Settings', `${readLine()} Tester management.`)}${routesSection(routes)}${instancesSection(instances)}${testerNames(names, instances)}`;
const notify = typeof n.binding === 'string'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
@@ -966,6 +990,7 @@
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
function why(err) {
if (err.code === 'tester-off' || err.code === 'tester-required') return 'The tester is off or this request did not bind the tester. Nothing was changed.';
const code = `<code>${txt(err.code)}</code>`;
// The queue, business and settings reads: the module's own text, redacted by the server.
if (err.module) {
@@ -1259,9 +1284,15 @@
// Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
// Message buttons are drawn from the roles already read.
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
canEdit = c?.edit === true; // row 148: implies send
if (c?.send !== true) return;
canSend = true;
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
if (isTester) {
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
}
if (!canSend && !isTester) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
+26 -15
View File
@@ -2,7 +2,9 @@ import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { chatHTTP } from './chat-http.mjs';
@@ -261,8 +263,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -304,8 +306,8 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -351,8 +353,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -432,9 +434,12 @@ async function projectFileRead(res, read, path, search) {
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
if (tester && [send, settingsWrite, projectSave, assistantStop, chat].some(value => value !== null)) throw new Error('tester listener permits only instance, route and name writes');
const upstream = boardURL(board);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -445,6 +450,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
// Presence, even with an empty value: never turn the public listener into a tester ingress.
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
@@ -468,6 +476,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path))
return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
return settingsSend(req,res,{settingsWrite,preview,log});
@@ -475,18 +486,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (Object.hasOwn(SEAT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
const write = path === '/api/name' ? nameWrite : assistantStop;
if (!send || !write || !named) return json(res, 403, { error: 'read-only', sent: false });
return seatSend(req, res, { route: SEAT_ROUTES[path], write, preview, log });
if ((!tester && (!send || !named)) || !write) return json(res, 403, { error: 'read-only', sent: false });
return seatSend(req, res, { route: SEAT_ROUTES[path], write, preview: writer, log });
}
if (path === '/api/instances') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!send || !instancesWrite || !named) return json(res, 403, { error: 'read-only', sent: false });
return instanceSend(req, res, { instancesWrite, preview, log });
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
return instanceSend(req, res, { instancesWrite, preview: writer, log });
}
if (path === '/api/routes') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!send || !routesWrite || !named) return json(res, 403, { error: 'read-only', sent: false });
return routesSend(req, res, { routesWrite, preview, log });
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
return routesSend(req, res, { routesWrite, preview: writer, log });
}
if (path === '/api/project-file' && req.method === 'POST') {
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
@@ -553,7 +564,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
if (path === '/api/config') return json(res, 200, { board: upstream, send: Boolean(send) && named, edit: Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+72
View File
@@ -122,6 +122,78 @@ async function run(dir, args, bin = null) {
const execStart = (unit) => unit.split('\n').find((l) => l.startsWith('ExecStart='));
// Row 176: dropping flag propagation, port guards, or status reporting must
// fail these checks. All installs remain inside the existing fake-copy fixture.
test('tester service render carries the tester port alongside existing flags', async (t) => {
const dir = tmp(t);
for (const port of ['1024', '7333', '65535']) {
const r = await run(dir, ['render', '--tester-port', port, ...PREVIEW, '--send']);
assert.equal(r.status, 0, r.stderr);
assert.match(execStart(r.stdout), new RegExp(` --tester-port ${port}( |$)`));
assert.match(execStart(r.stdout), / --port 7332 /);
assert.ok(execStart(r.stdout).endsWith(` ${PREVIEW_FLAGS} --send --tester-port ${port}`));
}
});
test('tester service refuses forwarded 7332 even with another main port', async (t) => {
const dir = tmp(t);
for (const main of ['7332', '7444']) {
const r = await run(dir, ['render', '--port', main, '--tester-port', '7332']);
assert.equal(r.status, 4, r.stderr);
assert.equal(r.stdout, '');
assert.match(r.stderr, /tester.port.*7332/i);
}
});
test('tester service invalid ports refuse render and install with exit 4 and leave the unit alone', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const target = join(unitDir, 'mosaic-webui.service');
assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0);
const before = readFileSync(target, 'utf8');
for (const args of [
['--tester-port', ''], ['--tester-port', '1023'], ['--tester-port', '65536'],
['--tester-port', '-1'], ['--tester-port', '3.5'], ['--tester-port', '1e4'],
['--tester-port', 'abc'], ['--tester-port', '7333 '], ['--tester-port', '7333\n'],
['--port', '7444', '--tester-port', '7444'],
['--tester-port', '7444', '--port', '7444'],
]) {
for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload']]) {
const r = await run(dir, [...verb, ...args]);
assert.equal(r.status, 4, `${verb[0]} ${JSON.stringify(args)}: ${r.stderr}`);
assert.equal(r.stdout, '');
assert.match(r.stderr, /tester.port/i);
assert.equal(readFileSync(target, 'utf8'), before);
}
}
assert.equal((await run(dir, ['render', '--tester-port'])).status, 4);
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
});
test('tester service install and status show the tester port without changing send reporting', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const bin = standIn(dir);
const port = await closedPort();
const installed = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--tester-port', '1024', ...PREVIEW, '--send'], bin);
assert.equal(installed.status, 0, installed.stderr);
assert.match(installed.stdout, /^serve: .* --tester-port 1024( |$)/m);
assert.match(execStart(readFileSync(join(unitDir, 'mosaic-webui.service'), 'utf8')), / --tester-port 1024( |$)/);
const status = await run(dir, ['status', '--dir', unitDir], bin);
assert.equal(status.status, 0, status.stderr);
assert.match(status.stdout, /^tester: http:\/\/127\.0\.0\.1:1024(?: .*)?$/m);
assert.match(status.stdout, /^send: on /m);
assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port)], bin)).status, 0);
const plain = await run(dir, ['status', '--dir', unitDir], bin);
assert.match(plain.stdout, /^tester: off$/m);
assert.match(plain.stdout, /^send: off /m);
for (const verb of ['status', 'uninstall']) {
const refused = await run(dir, [verb, '--dir', unitDir, '--tester-port', '7333'], bin);
assert.equal(refused.status, 4);
}
assert.ok(existsSync(join(unitDir, 'mosaic-webui.service')));
});
test('render prints the unit without the preview flags', async (t) => {
const dir = tmp(t);
const r = await run(dir, ['render']);
+2 -1
View File
@@ -59,7 +59,8 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'"]);
// Row 176 (#1674): the tester name form adds a second /api/name caller.
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
assert.equal(read('icons.svg').toString().includes('<script'), false);
});
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { browser } from './browser.mjs';
import { testerFixture } from './tester-fixture.mjs';
test('tester Console banner exposes only instance route and name editors and saves a route as tester', { timeout: 60000 }, async t => {
const s = await testerFixture(t), b = await browser();
try {
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<150;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
await b.navigate(`http://127.0.0.1:${s.web.address().port}/#/settings`);
await wait('!!document.querySelector("#tester-banner") && document.querySelector("#routes-form button[type=submit]")?.disabled === false');
assert.match(await b.evaluate('document.querySelector("#tester-banner")?.textContent ?? ""'), /tester/i);
assert.equal(await b.evaluate('document.querySelector("#routes-form button[type=submit]").disabled'), false);
assert.ok(await b.evaluate('!!document.querySelector("#instances-table")'));
assert.ok(await b.evaluate('!!document.querySelector(".tester-name-form")'));
assert.equal(await b.evaluate('!!document.querySelector("#launch-settings-form, .assistant-form, #token-limits-table [data-limit-reset], #send-section")'), false);
const selector = JSON.stringify('[data-route=\'["coder","thinking"]\']');
await b.evaluate(`(()=>{const e=document.querySelector(${selector});e.value='low';e.dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('#routes-form').requestSubmit();return true;})()`);
await wait('document.querySelector("#routes-receipt")?.textContent.startsWith("Routes saved")');
assert.equal(JSON.parse(readFileSync(s.file, 'utf8')).roles.coder.vars.thinking, 'low');
assert.equal(s.history().at(-1).actor, 'tester');
assert.ok(s.lines.some(l => l.includes('routes user="tester"')));
} finally { await b.close(); }
});
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { TESTER_CLI, HUMAN_CLI } from '../../cli/src/transport.mjs';
import { testerFixture } from './tester-fixture.mjs';
import { request } from 'node:http';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
const get = (port, path) => new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
}); q.on('error', reject); q.end();
});
async function freePort() {
const s = createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening');
const port = s.address().port; await new Promise(r => s.close(r)); return port;
}
test('tester-port validates range tunnel main port and requires configured bus before listening', t => {
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
for (const flags of [['--tester-port', '7332'], ['--port', '7444', '--tester-port', '7444'], ['--tester-port', '7444', '--port', '7444'], ...['1023', '65536', '1.5', '-1', 'nope'].map(p => ['--tester-port', p])]) {
const r = spawnSync(process.execPath, [CLI, 'serve', ...flags], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*(1024|7332|differ|whole)/i);
}
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-port', '7333'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester-port needs the bus configured/);
});
test('CLI tester listener binds loopback and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
const f = await testerFixture(t), port = await freePort(), log = join(f.root, 'spawn.jsonl');
const hook = join(f.root, 'spawn-hook.mjs');
writeFileSync(log, '');
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-port', String(port), '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '', error = '';
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('startup did not name tester: ' + output + error)), 5000);
const check = () => { if (output.includes(`Tester Console: http://127.0.0.1:${port}/`)) { clearTimeout(timer); resolve(); } };
child.stdout.on('data', check); child.once('exit', code => { clearTimeout(timer); reject(Error('CLI exit ' + code + ': ' + output + error)); }); check();
});
assert.match(output, /binds (the )?tester/);
const paths = ['inbox', 'tasks', 'agents', 'trail?subject=coder', 'decisions?list=roles', 'credentials', 'replies?id=absent', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'].map(p => '/api/bus/' + p);
paths.push('/api/project-files?project=stack', '/api/project-file?project=stack&path=spec%2Fnorth-star.md');
for (const path of paths) {
const before = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).length;
const r = await get(port, path);
const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
assert.equal(calls.length, before + 1, path);
assert.equal(calls.at(-1).cli, TESTER_CLI, path);
assert.equal(calls.some(c => c.cli === HUMAN_CLI), false);
assert.ok(![401, 403, 500, 502, 503].includes(r.status), path + ': ' + r.body);
}
});
+67
View File
@@ -0,0 +1,67 @@
// Row 176: full fixture config, credential sentinels and Git project; never the user's config.
import { mkdirSync, readFileSync, writeFileSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { loadSystem, bootConfig } from '../../cli/src/config.mjs';
import { loadBusiness } from '../../business/src/index.mjs';
import { writeJson } from '../../business/tests/helpers.mjs';
import { initProject } from '../../project/src/index.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { BusReadError } from '../src/bus.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const TOKEN = 'row176-fixture-token-sentinel';
export const VARIABLE = 'ROW176_FIXTURE_CREDENTIAL_VARIABLE';
export const OUTSIDE = 'row176-outside-target-must-never-be-read';
export async function testerFixture(t, { enabled = true } = {}) {
const root = tmp(t, 'console-tester-');
const f = fixture(root, 'acme', doc => {
doc.vars['bus.tester'] = enabled;
doc.projects.stack.repo = join(root, 'records');
return doc;
});
const file = join(root, 'config/businesses/acme.json');
const tokenPath = f.doc.roles.pm.credentials.gitea.file;
writeFileSync(tokenPath, TOKEN, { mode: 0o600 });
const old = process.env[VARIABLE]; process.env[VARIABLE] = TOKEN;
t.after(() => { if (old === undefined) delete process.env[VARIABLE]; else process.env[VARIABLE] = old; });
f.doc.roles.coder.credentials.gitea = { env: VARIABLE, rotateBy: '2099-01-01' };
writeJson(file, f.doc);
const business = loadBusiness('acme', { dir: join(root, 'config'), rolesDir: f.roles });
await initProject({ business, project: 'stack', dataRoot: f.dataRoot });
writeFileSync(join(root, 'outside.md'), OUTSIDE);
symlinkSync(join(root, 'outside.md'), join(root, 'records/docs/out.md'));
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'add', 'docs/out.md'], { cwd: join(root, 'records') });
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'commit', '-m', 'Fixture symlink'], {
cwd: join(root, 'records'), env: { ...process.env, GIT_AUTHOR_NAME: 'jason', GIT_AUTHOR_EMAIL: 'fixture@invalid', GIT_COMMITTER_NAME: 'jason', GIT_COMMITTER_EMAIL: 'fixture@invalid' },
});
mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 });
const runtime = await startBroker(bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }));
t.after(() => runtime.close());
const calls = [];
const call = async (verb, args = {}) => {
calls.push([verb, args]);
try { return structuredClone(await runtime.broker.request(runtime.broker.bindTester({ business: 'acme' }), { verb, args })); }
catch (err) { if (err instanceof BusError) throw new BusReadError(err.code); throw err; }
};
const bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
const lines = [];
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', bus,
reads: { settings: async () => ({ business: 'acme', release: 'fixture', notifier: {}, launch: await call('limits') }) },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
log: line => lines.push(line),
});
t.after(() => close(web));
const history = () => readFileSync(join(root, 'config/businesses/.history/acme/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
const nameRows = () => {
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
try { return db.prepare('SELECT name,by FROM instance_names').all(); } finally { db.close(); }
};
return { root, f, file, tokenPath, runtime, calls, call, web, lines, history, nameRows };
}
@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { request, createServer } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
import { testerFixture, TOKEN, VARIABLE, OUTSIDE } from './tester-fixture.mjs';
export function http(server, path, { method = 'GET', headers = {}, data } = {}) {
const port = server.address().port;
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers: { host: `127.0.0.1:${port}`, ...headers } }, res => {
let text = ''; res.on('data', c => text += c);
res.on('end', () => resolve({ status: res.statusCode, text, body: res.headers['content-type']?.includes('json') ? JSON.parse(text) : null }));
});
req.on('error', reject); req.end(data === undefined ? undefined : JSON.stringify(data));
});
}
export const post = (web, path, data, headers = {}) => http(web, path, { method: 'POST', data, headers: {
origin: `http://127.0.0.1:${web.address().port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers,
} });
test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
let hits = 0;
const upstream = createServer((q, r) => { hits++; r.end('{}'); });
await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
t.after(() => close(web));
for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example'])
for (const path of ['/api/board', '/api/bus/agents']) {
const r = await http(web, path, { headers: { host, ...(host === 'console.example' ? { 'x-user': 'jason' } : {}), 'x-mosaic-tester': '' } });
assert.equal(r.status, 403, host + path); assert.equal(hits, 0);
}
});
test('tester listener rejects preview binding and requires its loopback Host and no proxy auth header', async t => {
const wrong = await startServer({ port: 0, tester: true, preview: { host: 'console.example', header: 'x-user', user: 'jason' } }).catch(e => e);
if (!(wrong instanceof Error)) { await close(wrong); assert.fail('tester accepted preview binding'); }
assert.match(wrong.message, /tester.*preview/i);
await assert.rejects(startServer({ port: 0, host: '0.0.0.0', tester: true }), /non-loopback/);
const s = await testerFixture(t);
assert.equal(s.web.address().address, '127.0.0.1');
for (const headers of [{ host: 'elsewhere.example' }, { host: `127.0.0.1:${s.web.address().port + 1}` }, { 'x-user': 'jason' }]) {
const r = await http(s.web, '/api/bus/names', { headers });
assert.equal(r.status, 403); assert.deepEqual(s.calls, []);
}
const config = await http(s.web, '/api/config');
assert.equal(config.body.tester, true); assert.equal(config.body.send, false); assert.equal(config.body.edit, false);
});
test('tester listener writes only instances routes and names with tester receipts and actor', async t => {
const s = await testerFixture(t);
const read = await http(s.web, '/api/bus/instances');
assert.equal(read.status, 200, read.text);
const added = await post(s.web, '/api/instances', { revision: read.body.rows[0].revision, instance: 'gate-176', op: 'add', definition: 'merge-gate' });
assert.equal(added.status, 200, added.text);
assert.equal(s.history().at(-1).actor, 'tester');
assert.equal(s.history().at(-1).transaction, added.body.transaction);
const routes = await http(s.web, '/api/bus/routes');
const changed = await post(s.web, '/api/routes', { revision: routes.body.rows[0].revision, routes: { coder: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null } } });
assert.equal(changed.status, 200, changed.text); assert.equal(s.history().at(-1).actor, 'tester');
assert.equal(s.history().at(-1).transaction, changed.body.transaction);
const named = await post(s.web, '/api/name', { instance: 'coder', name: 'fixture-seat' });
assert.equal(named.status, 200, named.text);
assert.equal(s.nameRows().at(-1).by, 'tester');
assert.equal(s.lines.filter(l => l.includes('done')).length, 3);
assert.ok(s.lines.every(l => l.includes('user="tester"')));
const before = s.calls.length;
for (const headers of [{ origin: 'http://elsewhere' }, { 'sec-fetch-site': 'cross-site' }, { 'content-type': 'text/plain' }]) {
assert.ok([400, 403, 415].includes((await post(s.web, '/api/name', { instance: 'coder', name: 'nope' }, headers)).status));
assert.equal(s.calls.length, before);
}
});
test('tester listener refuses every human-only write with no bus call', async t => {
for (const key of ['settingsWrite', 'projectSave', 'assistantStop', 'chat']) {
const unsafe = await startServer({ port: 0, tester: true, [key]: async () => ({}) }).catch(e => e);
if (!(unsafe instanceof Error)) { await close(unsafe); assert.fail('tester accepted forbidden capability ' + key); }
assert.match(unsafe.message, /tester.*writes/i);
}
const s = await testerFixture(t);
for (const path of ['/api/settings', '/api/project-file', '/api/assistant-stop', '/api/bus/message', '/api/chat/projects', '/api/chat/threads', '/api/chat/threads/x/messages', '/api/chat/threads/x/open']) {
const r = await post(s.web, path, { any: 'value' });
assert.equal(r.status, 403, path); assert.deepEqual(s.calls, []);
}
});
test('tester-off and tester-required reads return HTTP 403', async t => {
const s = await testerFixture(t, { enabled: false });
const r = await http(s.web, '/api/bus/names');
assert.equal(r.status, 403); assert.equal(r.body.error, 'tester-off');
const required = await startServer({ port: 0, tester: true, board: 'http://127.0.0.1:9', bus: { names: async () => { throw new BusReadError('tester-required'); } } });
t.after(() => close(required));
const missing = await http(required, '/api/bus/names');
assert.equal(missing.status, 403); assert.equal(missing.body.error, 'tester-required');
});
test('tester credentials routes and committed project reads keep secrets and paths bounded', async t => {
const s = await testerFixture(t);
const direct = await s.call('credentials');
assert.ok(direct.length > 0);
const via = await http(s.web, '/api/bus/credentials'); assert.equal(via.status, 200, via.text);
for (const forbidden of [TOKEN, s.tokenPath, VARIABLE]) {
assert.equal(JSON.stringify(direct).includes(forbidden), false);
assert.equal(via.text.includes(forbidden), false);
}
assert.ok((await s.call('routes')).instances.coder);
assert.ok((await s.call('project.files', { project: 'stack' })).files.length);
assert.match((await s.call('project.file', { project: 'stack', path: 'spec/north-star.md' })).content, /./);
await assert.rejects(s.call('project.file.save', { project: 'stack', path: 'spec/north-star.md', base: 'a'.repeat(40), content: '# changed' }), e => e.code === 'project-spec-human-only');
for (const path of ['docs/../../x.md', '/etc/passwd', 'docs/%2e%2e/x.md', 'docs/out.md']) {
const r = await http(s.web, `/api/project-file?project=stack&path=${encodeURIComponent(path)}`);
assert.equal(r.status, path === 'docs/out.md' ? 502 : 400, path + ': ' + r.text);
assert.ok(['invalid-request', 'project-read-path', 'project-read-content'].includes(r.body.error), path + ': ' + r.text);
await assert.rejects(s.call('project.file', { project: 'stack', path }), e => e.code === (path === 'docs/out.md' ? 'project-read-content' : 'project-read-path'));
if (path === 'docs/out.md') assert.equal(r.body.error, 'project-read-content');
assert.equal(r.text.includes(OUTSIDE), false);
}
});
+46 -8
View File
@@ -2,6 +2,7 @@
# Stack activate and rollback, health-gated (D1, #1659; docs/plans/2026-10-18_stack-release.md).
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -25,12 +26,12 @@ COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS=""
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--timeout S] [--plan]
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
@@ -57,6 +58,12 @@ refuse() {
exit 3
}
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
valid_tester_port() {
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] &&
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ]
}
parse_opts() {
while [ $# -gt 0 ]; do
case "$1" in
@@ -66,6 +73,7 @@ parse_opts() {
--plan) [ "$OP" = activate ] || [ "$OP" = rollback ] || usage; PLAN=1; shift ;;
--pm) [ "$OP" = activate ] || usage; WANT_PM=1; shift ;;
--pm-after-harness) [ "$OP" = activate ] || usage; AFTER_HARNESS=1; shift ;;
--tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] || usage; TESTER_PORT="$2"; valid_tester_port "$TESTER_PORT" 7332 || usage; shift 2 ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}$ ]] || usage; RESTORE="$2"; shift 2 ;;
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
@@ -607,9 +615,37 @@ flags_drift() {
[ -z "$d" ] || refuse unit-flags-drift "the units differ from the pointer; settle it first:$d"
}
# Resolve desired flags independently of the pointer and effective units, so a hand-edited
# unit still refuses drift and a failed attempt can restore exactly the captured stack.
# Read the target's own Console, because old copies cannot start with the new flag.
console_for_target() {
local supported=0 main=7332 inherited="" i
local -a without_tester=()
grep -q -- '--tester-port' "$COPIES/$TO/packages/webui/src/cli.mjs" && supported=1
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do
if [ "${P_CONSOLE[i]}" = --tester-port ]; then
i=$((i + 1)); inherited="${P_CONSOLE[i]:-}"
else
without_tester+=("${P_CONSOLE[i]}")
if [ "${P_CONSOLE[i]}" = --port ]; then main="${P_CONSOLE[i+1]:-7332}"; fi
fi
done
RUN_CONSOLE=("${P_CONSOLE[@]}")
DROP_TESTER=""
if [ -n "$TESTER_PORT" ]; then
valid_tester_port "$TESTER_PORT" "$main" || usage
[ "$supported" = 1 ] || refuse tester-port-target "$TO's Console does not support --tester-port"
RUN_CONSOLE=("${without_tester[@]}" --tester-port "$TESTER_PORT")
elif [ -n "$inherited" ] && [ "$supported" = 0 ]; then
RUN_CONSOLE=("${without_tester[@]}")
DROP_TESTER="--tester-port $inherited"
fi
}
# The item 3 checks after the pointer, shared by activate and rollback, for target $TO.
precheck() {
check_target
console_for_target
instance_busy
drop_in_override
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -734,7 +770,8 @@ plan() {
[ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM"
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${P_CONSOLE[*]:+ ${P_CONSOLE[*]}}"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
@@ -745,6 +782,7 @@ plan() {
run_steps() {
log_line result started
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -771,11 +809,11 @@ run_steps() {
elif [ -n "$RESTORE" ]; then
rollback_in_run store-differs
fi
install_stack "$TO" "$RUN_PM" "$P_CLAUDE" "${P_CONSOLE[@]}" || after_fail "$FAIL"
install_stack "$TO" "$RUN_PM" "$P_CLAUDE" "${RUN_CONSOLE[@]}" || after_fail "$FAIL"
start_both || after_fail start
gate "$TO" "$RUN_PM" || after_fail "$GATE"
trap - INT TERM HUP
write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"
write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"
if [ "$RUN_PENDING" = 1 ]; then
log_line result ok pm pending
echo "stack-release: $OP ok: $TO is live for $BUSINESS without its PM; the PM is pending: Jason's plugins set of harness, then pm-on"
@@ -801,7 +839,7 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
new_activation
@@ -813,7 +851,7 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
@@ -969,7 +1007,7 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PLAN_REFUSE=()
case "$OP" in
adopt) do_adopt ;;
activate) do_activate ;;
+43 -6
View File
@@ -22,7 +22,7 @@
# remove the unit; refuses while it is running
# status [--dir DIR] unit state, the copy it runs and whether
# refactor has moved past it, whether it sends,
# then GET /healthz on its port
# the tester listener, then GET /healthz on its port
#
# With --copy the unit's WorkingDirectory and ExecStart point at the copy,
# and ExecStart passes this checkout as serve's --checkout: the queue read
@@ -40,7 +40,8 @@
# row 85 supplies the shared copy and bus unit flags; Mos owns switching.
#
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --business ID, --board URL, and the preview set --public-host
# Console), --tester-port N (1024..65535, neither --port nor the tunnel's
# 7332; binds the tester on loopback), --business ID, --board URL, and the preview set --public-host
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
# (row 83), taken only with the preview set: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd
@@ -48,7 +49,8 @@
# character set and refused otherwise. None is a secret.
#
# There is no start, stop, enable or restart verb. The Console reads the
# bus through the human transport, so whoever starts it holds the human's
# main listener's bus through the human transport; the optional loopback
# listener binds the tester. Whoever starts it holds the human's
# capability. Jason starts and restarts it
# (`systemctl --user enable --now mosaic-webui`), and so may Mos under lead
# decision 102, which accepts that sends from a Console Mos started enter
@@ -64,7 +66,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
die() { echo "webui-service: $*" >&2; exit 1; }
@@ -78,6 +81,8 @@ ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
TESTER_PORT=""
TESTER_SET=0
BUSINESS=""
BOARD=""
PUBLIC_HOST=""
@@ -95,6 +100,7 @@ parse_flags() {
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
@@ -104,6 +110,28 @@ parse_flags() {
*) usage ;;
esac
done
# Validate here so a bad tester value exits 4 before render's existing
# operation-failure wrappers, and before install reads or changes units.
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
tester_port_usage() {
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
exit 4
}
tester_port_ok() {
case "$TESTER_PORT" in ''|*[!0-9]*) tester_port_usage ;; esac
local tester_port="$TESTER_PORT"
# Compare decimal values, including values written with leading zeros.
while [ "${tester_port#0}" != "$tester_port" ]; do tester_port="${tester_port#0}"; done
[ -n "$tester_port" ] && [ "${#tester_port}" -le 5 ] || tester_port_usage
[ "$tester_port" -ge 1024 ] && [ "$tester_port" -le 65535 ] || tester_port_usage
[ "$tester_port" -ne 7332 ] || tester_port_usage
# Invalid main ports retain their existing operation-failure behavior.
if [[ "$PORT" =~ ^[0-9]+$ ]] && [ "${#PORT}" -le 5 ]; then
[ "$tester_port" -ne "$PORT" ] || tester_port_usage
fi
}
# The serve flags as one ExecStart fragment. Each character set excludes
@@ -148,6 +176,7 @@ serve_flags() {
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
fi
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
}
@@ -330,13 +359,21 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send right after the allowed user. The allowed user
# render puts --send right after the allowed user, before the optional
# tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -q '^ExecStart=.* --allow-user [^ ][^ ]* --send$' "$target"; then
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]* --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if [ -n "$tester_port" ]; then
echo "tester: http://127.0.0.1:$tester_port (binds the tester)"
else
echo "tester: off"
fi
local port
port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")"
[ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; }