Compare commits

...
23 Commits
Author SHA1 Message Date
jason.woltje d6ab5047af Merge rename stage A (org-rename-stage-a) into refactor 2026-11-09 11:15:59 -06:00
jason.woltje 311ae46e3d Merge test-health fixes (org-test-health-fix) into refactor 2026-11-09 11:14:55 -06:00
jason.woltje d070ed6d4a dev fixture: take a stale record atomically so a concurrent init keeps a live record
A stale-record init now renames the record aside, checks it is still the one it
observed, and puts it back otherwise. Two inits racing over the same stale record
can no longer delete the record of a live endpoint the other one just started.
Fixes the P2 from the test-health review.
2026-11-08 23:07:48 -06:00
jason.woltje 87b7a34f1c Merge rename part A2: --org selectors with --business aliases
# Conflicts:
#	packages/cli/tests/org-console.test.mjs
#	packages/webui/src/cli.mjs
2026-11-08 23:04:19 -06:00
jason.woltje a36d45af10 Merge rename part A1: pin v1 organization contracts 2026-11-08 23:04:19 -06:00
jason.woltje 9a52eecffd selector: org-console test asserts the Console child's exact argv
The detached Console is a selector producer: the test reads the child's
/proc cmdline and asserts serve --port 0 --business alpha --dev-local-owner.
2026-11-08 22:35:24 -06:00
jason.woltje 4fa6995c13 selector: --org and --business in land-work
land-work takes --org as --business in one scope. A repeated, conflicting,
missing, empty or flag-shaped value exits 4 before the config is read. The
id keeps its old grammar (none): any other text names businesses/<ID>.json
and a missing file exits 3. A value of --subject or --attribution is never
read as a selector.
2026-11-08 22:35:24 -06:00
jason.woltje 381039fe5f selector: --org and --business in stack-copy check
check takes --org as --business in one scope: each spelling once, both only
with the same value. A repeated, conflicting, missing, empty or flag-shaped
value exits 4 before any unit is read; an id outside check's own grammar
still exits 1. copy and wrapper refuse either spelling with usage, as
before. The sourced-library path used by stack-release.sh does not read
selector.sh; the command path refuses if it cannot.
2026-11-08 22:35:24 -06:00
jason.woltje 1105de280b selector: stack-release rehearsal fixture copies org-selector.mjs
The rehearsal builds fake copies from an explicit file list. f198cfd47 made
packages/cli/src/access.mjs import org-selector.mjs, which the list lacked,
so every rehearsal that ran the copied CLI failed with "Cannot find module"
(17 of 226 tests) from f198cfd47 until this commit. Real copies were not
affected: stack-copy.sh COPY_PATHS takes packages/cli/src whole.
2026-11-08 22:35:24 -06:00
jason.woltje 9e458878df selector: --org and --business in stack-upgrade's args-file scopes
read_args takes --org as --business in the unit flags before -- and in the
serve flags after it, with per-scope duplicate and conflict refusal, and
refuses a file whose serve flags name no selector or another business, as
case2-unit.sh would at the render after A2 stopped the units. The serve
flags are walked with case2-unit.sh's grammar so a value is never read as a
selector. The file is not rewritten; only --copy's value changes, as before.
A checkout without scripts/selector.sh refuses checkout-unreadable.
2026-11-08 22:05:44 -06:00
jason.woltje 226be2e935 selector: --org and --business in case1-accept's three scopes 2026-11-08 21:56:02 -06:00
jason.woltje dbb6909dcd selector: case2-precheck and case2-install refuse when selector.sh cannot be read
Neither script runs under set -e, so a failed source left selector_take
undefined and parsing carried on without the duplicate and conflict rules.
2026-11-08 21:56:02 -06:00
jason.woltje 4b7ddbcb43 selector: --org and --business in case2-unit's outer and serve scopes 2026-11-08 21:39:20 -06:00
jason.woltje 7f882a905d selector: --org and --org-file in case2-install
Rename design r2, "Selector scopes and parser matrix", case2-install row. --org/--business
and --org-file/--business-file are two families, each its own scope: each spelling once, and
a second spelling must carry the same text (file paths compared as given). Missing values
are usage; a flag-shaped or invalid id keeps the business-id refusal; a flag-shaped file, a
repeated spelling and a conflict say why. All exit 4 before anything is read or written.
The persona-import call it spawns still passes --business.
2026-11-08 21:27:37 -06:00
jason.woltje 3baf6f2297 selector: --org and --business in case2 persona-import and precheck
Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org
through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both
keep their exit codes and their old business-id wording; a repeated or conflicting selector
says why. persona-import now checks the id while parsing, so stage refuses an invalid
selector before it reads anything.

The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real
copy already does (COPY_PATHS takes packages/business/src).
2026-11-08 21:25:49 -06:00
jason.woltje 259302a368 selector: --org and --business in the Console CLI and service parsers
cli.mjs serve and webui-service.sh each take --org as --business in their own
scope through the shared selector rules; the service unit still carries the
canonical --business. A flag-shaped or invalid value keeps each script's old
wording and exit code; a repeated or conflicting selector is a usage error.
2026-11-08 21:20:09 -06:00
jason.woltje f198cfd47b selector: --org and --business in each CLI parser's own scope
Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs
and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once,
and a second spelling must carry exactly the same value; a repeated spelling, a missing or
flag-shaped value, an invalid id and unequal values refuse. The file family
(--business-file/--org-file) compares paths as given.

Every CLI parser that took --business now takes --org through orgSelector, and the old
global --org rewrite in main() is gone, so a value another option consumes is never read as
a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin
set no longer reads the token and proxy init no longer prompts for the key before an
invalid id refuses. access gains the same local id check.
2026-11-08 21:14:11 -06:00
jason.woltje 500418e710 test: pin remaining v1 organization contracts 2026-11-08 20:53:16 -06:00
jason.woltje c2a6fad2a8 dev fixture: --ttl for the endpoint, and init replaces a stale record (P19 #882)
The endpoint ended after MOSAIC_FIXTURE_TTL_S, but init passed it only PATH, so
the lifetime was always the 14400 s default. Its record stayed after it ended,
and init refused fixture-exists until `dev fixture stop`.

init now takes --ttl SECONDS (0 = no expiry; otherwise 60 to 2147483, the
longest delay a Node timer holds; the default stays 14400), passes it to the
endpoint, and records ttlSeconds and expiresAt. /health and status report them.
A record whose pid is no longer the endpoint is stale: init removes it and says
which pid it replaced. A live endpoint still refuses fixture-exists.

A running bus keeps the endpoint it read at start, so after a replacement
the bus needs a restart; --ttl 0 avoids the expiry in the first place.
2026-11-08 20:53:03 -06:00
jason.woltje 7d1ab7cd96 test: pin v1 resolver and command contracts 2026-11-08 20:45:50 -06:00
jason.woltje 041e163d3c tester-cli: load the bus client only in the nonce child (P19 #876)
The tester-cli test makes about 20 Console reads and chat calls in a
row. Each one spawns TESTER_CLI, which re-executes itself so the nonce
is in its initial environment; both processes statically imported the
client and the broker module graph behind it, though the parent only
spawns the child. The child now imports Client and readProcess after
the re-exec branch, so the parent is a bare node start. Interleaved
over 20 calls at load 19: 258 ms per call before, 192 ms after. The
proof, nonce and argv are unchanged.

What remains is legitimate serial work: about 20 round trips of two
node processes each, about 3 s idle and 10 s at load 20 on 16 cores
(before this change). 30 s left under 3x room on a busy machine, so the
test's hang guard is now 60 s.
2026-11-08 20:45:23 -06:00
jason.woltje 588253e524 test: stop dev-local-owner buses before removing their root (P19 #875)
The Console-and-bus test in packages/webui/tests/dev-local-owner.test.mjs
registered rmSync(root) as its first t.after hook. Hooks run in
registration order, so root went first, both `bus stop` calls then failed,
and the SIGTERM fallback reached only the outer `bus start`. Each run left
the beta and acme --internal-host processes and their brokers running.

One hook now kills the Console, stops both buses while root exists, waits
for every recorded process (outer CLIs, hosts from their receipts, and the
hosts' children, each matched by start time) and fails if any survives,
SIGKILLing the survivors first so the failure does not leak. Root is
removed last. A mutant that skips the stops fails with six survivors.
packages/bus/tests/dev-local-owner.test.mjs already stopped its bus and
leaves nothing behind.
2026-11-08 20:39:31 -06:00
jason.woltje 93a73a7375 test: leave a linked node_modules out of the copy fixtures
On a deploy tree node_modules is a symlink. .gitignore's node_modules/
matches only a directory, so git ls-files -co --exclude-standard lists
the link as an untracked file and the fixtures' cpSync throws EISDIR
on it (8 failures in pinned-copy.test.mjs). stack-copy.sh never takes
node_modules from git (it archives COPY_PATHS and runs npm ci), so the
fixtures now exclude **/node_modules from the listing. The copy code is
unchanged.
2026-11-08 20:34:53 -06:00
57 changed files with 2495 additions and 101 deletions

No files matched your search

+5 -2
View File
@@ -4,8 +4,6 @@ import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { Client } from './client.mjs';
import { readProcess } from './human.mjs';
const file = fileURLToPath(import.meta.url);
if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
@@ -14,6 +12,11 @@ if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
});
process.exit(child.status ?? 2);
}
// P19 #876: only the re-executed child, whose initial environment holds the nonce, talks to the
// bus. Loading the client (and the broker module graph behind it) here instead of at the top
// keeps the re-exec parent to a bare node start.
const { Client } = await import('./client.mjs');
const { readProcess } = await import('./human.mjs');
try {
const request = JSON.parse(readFileSync(0, 'utf8'));
if (
+54
View File
@@ -0,0 +1,54 @@
// Organization selector options (rename design r2, "Selector scopes and parser matrix").
// scripts/selector.sh is the shell half and keeps the same rules; packages/business/tests/
// selector.test.mjs runs one table against both.
//
// --org and --business are one option family, and --org-file and --business-file are another.
// A scope is one parser's own options: the parser that knows which tokens its other options
// consume calls take() for a selector token it meets, so a value or positional text that happens
// to read "--org" never reaches this file. Within one scope each spelling may appear once, and a
// second spelling must carry exactly the same value. A missing value, an empty one, one that
// starts with "-", an invalid one (by the parser's own predicate) and differing values refuse
// through the parser's own refuse function, so each entry point keeps its exit code and wording.
// The file family compares the strings given; its paths are checked later under existing rules.
export const SELECTOR_FAMILIES = Object.freeze({
id: Object.freeze({ canonical: '--business', spellings: Object.freeze(['--business', '--org']) }),
file: Object.freeze({ canonical: '--business-file', spellings: Object.freeze(['--business-file', '--org-file']) }),
});
// Each refusal names one of these kinds, so a parser can map it to its own usage error.
export const SELECTOR_REFUSALS = Object.freeze(['missing', 'flag', 'repeated', 'invalid', 'differ']);
export function isSelector(token, family = 'id') {
return SELECTOR_FAMILIES[family]?.spellings.includes(token) ?? false;
}
// refuse(kind, message) must throw; take() throws its own error if it returns.
export function selectorScope({ family = 'id', valid = null, refuse }) {
const spec = SELECTOR_FAMILIES[family];
if (!spec) throw new TypeError(`unknown selector family ${family}`);
if (typeof refuse !== 'function') throw new TypeError('selectorScope needs a refuse function');
const seen = [];
let value;
const fail = (kind, message) => {
refuse(kind, message);
throw new Error(`selector refusal returned: ${message}`);
};
return Object.freeze({
family,
canonical: spec.canonical,
owns: token => spec.spellings.includes(token),
take(flag, given) {
if (!spec.spellings.includes(flag)) throw new TypeError(`${flag} is not a ${family} selector`);
if (seen.includes(flag)) fail('repeated', `${flag} given twice`);
if (typeof given !== 'string' || given === '') fail('missing', `${flag} needs one value`);
if (given.startsWith('-')) fail('flag', `${flag} needs one value, not ${given}`);
if (valid && !valid(given)) fail('invalid', `${flag} ${given} is not valid`);
if (seen.length && given !== value) fail('differ', `${seen[0]} ${value} and ${flag} ${given} conflict`);
seen.push(flag);
value = given;
},
get value() { return value; },
get given() { return seen.length > 0; },
});
}
@@ -0,0 +1 @@
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
@@ -0,0 +1,95 @@
{
"business": "acme",
"project": "stack",
"instance": "coder",
"definition": "coder",
"holder": null,
"contract": "<ROOT>/roles/coder.md",
"vars": {
"tracker.kind": "vikunja",
"tracker.reconcileMinutes": 60,
"tracker.pollSeconds": 60,
"bus.tester": false,
"tracker.descendants": false,
"environment": "development",
"dataRoot": "<ROOT>/data",
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "glm-5.3-flash",
"execution.adapter": "mock",
"tracker.baseUrl": "http://127.0.0.1:3456",
"gitea.baseUrl": "https://git.example",
"tracker.project": 3,
"harness": "pi",
"limits.network": "none"
},
"provenance": {
"tracker.kind": "default",
"tracker.reconcileMinutes": "default",
"tracker.pollSeconds": "business:acme",
"bus.tester": "default",
"tracker.descendants": "default",
"environment": "system",
"dataRoot": "system",
"execution.backend": "system",
"execution.provider": "system",
"execution.model": "system",
"execution.adapter": "system",
"tracker.baseUrl": "business:acme",
"gitea.baseUrl": "business:acme",
"tracker.project": "project:stack",
"harness": "business:acme:roles.coder",
"limits.network": [
"business:acme:roles.coder"
]
},
"limits": {
"tools": [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls"
],
"network": "none",
"authority": {
"withinRole": [
"task.update.assigned",
"git.push.working",
"review.request",
"message.send",
"record.write"
],
"crossRole": [
"task.scope.change"
]
}
},
"credentials": {
"gitea": {
"service": "gitea",
"file": "<ROOT>/secrets/coder-gitea.token",
"rotateBy": "2099-01-01"
},
"vikunja": {
"service": "vikunja",
"file": "<ROOT>/secrets/coder-vikunja.token",
"expires": "2099-01-01"
}
},
"tracker": {
"bot": "bot-acme-coder",
"botId": 5
},
"launch": null,
"webSource": null,
"skills": [
"ms-unslop",
"ms-sdlc",
"ms-file-read",
"ms-file-write"
],
"digest": "<DIGEST>"
}
@@ -0,0 +1,14 @@
{
"business": "acme",
"file": "<ROOT>/config/businesses/acme.json",
"projects": {
"stack": "valid"
},
"instances": {
"pm": "<DIGEST>",
"cto": "<DIGEST>",
"coder": "<DIGEST>",
"reviewer": "<DIGEST>",
"chief": "<DIGEST>"
}
}
@@ -0,0 +1 @@
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
+63
View File
@@ -0,0 +1,63 @@
{
"business": "acme",
"project": "stack",
"instance": "coder",
"definition": "coder",
"holder": "example",
"contract": "/synthetic/roles/coder.md",
"vars": {
"tracker.kind": "vikunja",
"tracker.reconcileMinutes": 60,
"tracker.pollSeconds": 15,
"bus.tester": false,
"tracker.descendants": false,
"environment": "development",
"dataRoot": "/synthetic/data",
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "system-model",
"execution.adapter": "mock",
"tracker.baseUrl": "http://127.0.0.1:3456",
"tracker.project": 3,
"model": "agent-model",
"limits.network": "none"
},
"provenance": {
"tracker.kind": "default",
"tracker.reconcileMinutes": "default",
"tracker.pollSeconds": "project:stack:roles.coder",
"bus.tester": "default",
"tracker.descendants": "default",
"environment": "system",
"dataRoot": "system",
"execution.backend": "system",
"execution.provider": "system",
"execution.model": "system",
"execution.adapter": "system",
"tracker.baseUrl": "business:acme",
"tracker.project": "project:stack",
"model": "business:acme:roles.coder",
"limits.network": [
"business:acme:roles.coder"
]
},
"limits": {
"tools": [
"read",
"write"
],
"network": "none",
"authority": {
"withinRole": [
"message.send"
],
"crossRole": []
}
},
"credentials": {},
"tracker": null,
"launch": null,
"webSource": null,
"skills": [],
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
}
@@ -0,0 +1,12 @@
// Synthetic, path-stable resolver input; no file is read by resolveInstance.
export const input = {
system: { environment: 'development', dataRoot: '/synthetic/data', 'execution.backend': 'docker', 'execution.provider': 'zai', 'execution.model': 'system-model', 'execution.adapter': 'mock' },
business: {
id: 'acme', vars: { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'tracker.pollSeconds': 60 },
projects: { stack: { root: '/synthetic/project' } },
roles: { coder: { definition: 'coder', holder: 'example', vars: { model: 'agent-model', 'limits.network': 'none' }, credentials: {}, tracker: null } },
definitions: { coder: { name: 'coder', tools: ['read', 'write'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] }, contractPath: '/synthetic/roles/coder.md', skills: [] } },
},
project: { id: 'stack', file: '/synthetic/project/.mosaic/project.json', vars: { 'tracker.project': 3, 'tracker.pollSeconds': 20 }, roles: { coder: { vars: { 'tracker.pollSeconds': 15 } } } },
instance: 'coder',
};
@@ -0,0 +1,124 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import * as publicApi from '../src/index.mjs';
import * as direct from '../src/business.mjs';
import { BusinessError, refuse } from '../src/errors.mjs';
import { canonicalJson, sha256 } from '../src/util.mjs';
import { validateRecord } from '../../project/src/validate.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
import { input } from './fixtures/rename-a/resolver-input.mjs';
const fixture = name => readFileSync(new URL(`./fixtures/rename-a/${name}`, import.meta.url), 'utf8');
test('v1 resolver serialization, canonical preimage and digest are pinned', () => {
const result = publicApi.resolveInstance(input);
assert.equal(`${JSON.stringify(result, null, 2)}\n`, fixture('resolved.json'));
const { digest, ...preimage } = result;
const canonical = fixture('preimage.json');
assert.equal(`${canonicalJson(preimage)}\n`, canonical);
assert.equal(sha256(canonical.slice(0, -1)), digest);
assert.equal(result.provenance['tracker.pollSeconds'], 'project:stack:roles.coder');
assert.equal(result.provenance.model, 'business:acme:roles.coder');
});
test('v1 business public and direct exports share identity and refusal constructor', () => {
for (const name of ['configDir', 'businessFilePath', 'validateBusinessDocument', 'loadBusiness']) {
assert.equal(publicApi[name], direct[name], name);
}
assert.equal(publicApi.BusinessError, BusinessError);
assert.throws(() => refuse('fixture', 4), error => error instanceof publicApi.BusinessError && error.exitCode === 4 && error.message === 'fixture');
});
test('v1 business document rejects missing, mixed and unknown version fields', t => {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
const check = value => publicApi.validateBusinessDocument(value, join(root, 'businesses', 'acme.json'), { rolesDir: REPO_ROLES });
assert.equal(check(doc).id, 'acme');
for (const change of [d => delete d.businessVersion, d => { d.businessVersion = 2; }, d => { d.businessVersion = '1'; }, d => { d.organizationVersion = 2; }, d => { d.unexpected = true; }]) {
const candidate = structuredClone(doc); change(candidate);
assert.throws(() => check(candidate), BusinessError);
}
});
test('v1 project layout requires its version and business identity exclusively', () => {
const doc = { layoutVersion: 1, business: 'acme', project: 'stack', name: 'Stack', createdAt: '2026-01-01T00:00:00.000Z' };
assert.deepEqual(validateRecord('project', doc), []);
for (const change of [d => delete d.layoutVersion, d => { d.layoutVersion = 2; }, d => { d.layoutVersion = '1'; }, d => delete d.business, d => { d.organization = 'acme'; }, d => { d.unknown = true; }]) {
const candidate = structuredClone(doc); change(candidate);
assert.notDeepEqual(validateRecord('project', candidate), []);
}
});
test('installed business validate and resolve have identical wrapper and direct responses', async t => {
const { spawnSync } = await import('node:child_process');
const { businessDoc, REPO, rolesCopy, systemConfig, tmp, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
writeJson(publicApi.projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } });
const env = { PATH: process.env.PATH, HOME: join(root, 'home'), TMPDIR: process.env.TMPDIR, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
const directCli = join(REPO, 'packages/business/src/cli.mjs');
const wrapper = join(REPO, 'scripts/mosaic');
const invoke = (command, args) => {
const p = spawnSync(command, args, { env, cwd: root, encoding: 'utf8' });
assert.equal(p.error, undefined);
return { status: p.status, stdout: p.stdout, stderr: p.stderr };
};
for (const args of [['validate', 'acme'], ['resolve', 'acme', 'coder', '--project', 'stack'], ['validate', 'missing'], ['resolve', 'acme', 'unknown']]) {
const expected = invoke(process.execPath, [directCli, ...args]);
const wrapped = invoke(wrapper, ['business', ...args]);
assert.deepEqual(wrapped, expected, args.join(' '));
assert.equal(expected.status, args[1] === 'missing' ? 4 : args[2] === 'unknown' ? 2 : 0);
assert.doesNotMatch(expected.stdout + expected.stderr, /placeholder-not-a-token/);
}
});
test('org validate interprets an installed-ID-shaped argument as a draft filename', async t => {
const { spawnSync } = await import('node:child_process');
const { templateRender } = await import('../src/org-service.mjs');
const { REPO, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
writeJson(join(root, 'acme'), await templateRender('acme', { fixture: 'local' }));
const env = { PATH: process.env.PATH, HOME: root, TMPDIR: process.env.TMPDIR };
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['org', 'validate', 'acme'], { cwd: root, env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'valid\n');
assert.equal(result.stderr, '');
});
test('installed wrapper pins v1 validate and resolve output, preimage and digest', async t => {
const { spawnSync } = await import('node:child_process');
const { REPO, rolesCopy, systemConfig, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
writeJson(publicApi.projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } });
const env = { PATH: process.env.PATH, HOME: join(root, 'home'), TMPDIR: process.env.TMPDIR, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
const invoke = args => {
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['business', ...args], { env, cwd: root, encoding: 'utf8' });
assert.deepEqual([result.status, result.stderr], [0, '']);
return result.stdout;
};
const validate = invoke(['validate', 'acme']);
const parsed = JSON.parse(validate);
const resolved = invoke(['resolve', 'acme', 'coder', '--project', 'stack']);
const { digest, ...preimage } = JSON.parse(resolved);
assert.equal(sha256(canonicalJson(preimage)), digest);
for (const [id, expected] of Object.entries(parsed.instances)) {
const { digest: actual, ...record } = JSON.parse(invoke(['resolve', 'acme', id]));
assert.equal(sha256(canonicalJson(record)), actual);
assert.equal(actual, expected);
}
const normalized = text => text.split(root).join('<ROOT>').replace(/[a-f0-9]{64}/g, '<DIGEST>');
const snapshots = {
'installed-validate.json': normalized(validate),
'installed-resolve.json': normalized(resolved),
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
};
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
for (const [name, actual] of Object.entries(snapshots)) {
assert.equal(actual, fixture(name), name);
}
});
+138
View File
@@ -0,0 +1,138 @@
// The organization selector helpers: one table, run against packages/business/src/selector.mjs and
// scripts/selector.sh through the same small parser, so the two halves keep one set of rules.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { REPO } from './helpers.mjs';
import { ID_PATTERN } from '../src/vocabulary.mjs';
import { SELECTOR_FAMILIES, isSelector, selectorScope } from '../src/selector.mjs';
// A parser with one other value option (--note), positional text and a `--` separator.
function viaJs(family, args) {
const scope = selectorScope({ family, valid: family === 'id' ? id => ID_PATTERN.test(id) : null, refuse: kind => { throw Object.assign(new Error(kind), { kind }); } });
const out = { value: null, note: null, positional: [] };
try {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (scope.owns(a)) { scope.take(a, args[i + 1]); i++; }
else if (a === '--note') out.note = args[++i] ?? null;
else if (a === '--') { out.positional.push(...args.slice(i + 1)); break; }
else out.positional.push(a);
}
} catch (e) { if (e.kind) return { refused: e.kind }; throw e; }
out.value = scope.value ?? null;
return out;
}
const DRIVER = `
set -euo pipefail
. "$1"; family="$2"; shift 2
refuse() { printf 'refused %s\\n' "$1"; exit 3; }
valid() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
v=-; [ "$family" = id ] && v=valid
note=""; has_note=0; pos=()
while [ $# -gt 0 ]; do
case "$family:$1" in
id:--business|id:--org|file:--business-file|file:--org-file) selector_take s "$family" refuse "$v" "$1" "\${@:2:1}"; shift 2 ;;
*:--note) has_note=1; note="\${2-}"; shift; [ $# -gt 0 ] && shift ;;
*:--) shift; pos+=("$@"); break ;;
*) pos+=("$1"); shift ;;
esac
done
printf 'value %s\\n' "$(selector_value s)"
[ "$has_note" = 1 ] && printf 'note %s\\n' "$note"
for p in "\${pos[@]+"\${pos[@]}"}"; do printf 'pos %s\\n' "$p"; done
`;
function viaShell(family, args) {
const r = spawnSync('bash', ['-c', DRIVER, 'driver', join(REPO, 'scripts/selector.sh'), family, ...args], { encoding: 'utf8' });
if (r.status === 3) return { refused: /^refused (\S+)$/.exec(r.stdout.trim())[1] };
assert.equal(r.status, 0, r.stderr);
const out = { value: null, note: null, positional: [] };
for (const line of r.stdout.split('\n').filter(Boolean)) {
const [, key, rest] = /^(\S+) ?(.*)$/.exec(line);
if (key === 'value') out.value = rest || null;
else if (key === 'note') out.note = rest;
else out.positional.push(rest);
}
return out;
}
const ok = (value, more = {}) => ({ value, note: null, positional: [], ...more });
const ID_TABLE = [
['old alone', ['--business', 'acme'], ok('acme')],
['new alone', ['--org', 'acme'], ok('acme')],
['matching, old first', ['--business', 'acme', '--org', 'acme'], ok('acme')],
['matching, new first', ['--org', 'acme', '--business', 'acme'], ok('acme')],
['unequal, old first', ['--business', 'acme', '--org', 'beta'], { refused: 'differ' }],
['unequal, new first', ['--org', 'acme', '--business', 'beta'], { refused: 'differ' }],
['old repeated', ['--business', 'acme', '--business', 'acme'], { refused: 'repeated' }],
['new repeated', ['--org', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['new repeated after a match', ['--org', 'acme', '--business', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['old missing', ['--business'], { refused: 'missing' }],
['new missing', ['--org'], { refused: 'missing' }],
['new empty', ['--org', ''], { refused: 'missing' }],
['old flag-shaped', ['--business', '--org', 'acme'], { refused: 'flag' }],
['new flag-shaped', ['--org', '-x'], { refused: 'flag' }],
['old invalid', ['--business', 'Acme'], { refused: 'invalid' }],
['new invalid', ['--org', 'acme/../x'], { refused: 'invalid' }],
['equal but invalid', ['--org', 'A', '--business', 'A'], { refused: 'invalid' }],
['option-like value of another option', ['--note', '--org', '--business', 'acme'], ok('acme', { note: '--org' })],
['option-like value, new spelling', ['--note', '--business', '--org', 'acme'], ok('acme', { note: '--business' })],
['text after the separator', ['--org', 'acme', '--', '--business', 'beta', '--org'], ok('acme', { positional: ['--business', 'beta', '--org'] })],
['neither', ['word'], ok(null, { positional: ['word'] })],
];
const FILE_TABLE = [
['old alone', ['--business-file', 'a.json'], ok('a.json')],
['new alone', ['--org-file', 'a.json'], ok('a.json')],
['identical, old first', ['--business-file', 'a.json', '--org-file', 'a.json'], ok('a.json')],
['identical, new first', ['--org-file', 'a.json', '--business-file', 'a.json'], ok('a.json')],
['different paths, old first', ['--business-file', 'a.json', '--org-file', 'b.json'], { refused: 'differ' }],
['different paths, new first', ['--org-file', 'a.json', '--business-file', 'b.json'], { refused: 'differ' }],
// Strings are compared as given, so two spellings of one path differ.
['same file, other spelling', ['--org-file', 'a.json', '--business-file', './a.json'], { refused: 'differ' }],
['new repeated', ['--org-file', 'a.json', '--org-file', 'a.json'], { refused: 'repeated' }],
['old repeated', ['--business-file', 'a.json', '--business-file', 'a.json'], { refused: 'repeated' }],
['new missing', ['--org-file'], { refused: 'missing' }],
['new flag-shaped', ['--org-file', '--business-file', 'a.json'], { refused: 'flag' }],
['an id spelling is data here', ['--org', 'x', '--org-file', 'a.json'], ok('a.json', { positional: ['--org', 'x'] })],
];
for (const [family, table] of [['id', ID_TABLE], ['file', FILE_TABLE]]) {
for (const [name, args, expected] of table) {
test(`${family} selector: ${name}`, () => {
assert.deepEqual(viaJs(family, args), expected, 'selector.mjs');
assert.deepEqual(viaShell(family, args), expected, 'selector.sh');
});
}
}
test('selector families and spellings are fixed', () => {
assert.deepEqual(SELECTOR_FAMILIES.id.spellings, ['--business', '--org']);
assert.deepEqual(SELECTOR_FAMILIES.file.spellings, ['--business-file', '--org-file']);
assert.equal(isSelector('--org'), true);
assert.equal(isSelector('--org-file'), false);
assert.equal(isSelector('--org-file', 'file'), true);
assert.equal(isSelector('--organization'), false);
});
test('a refuse function that returns still refuses, and scopes are separate', () => {
const quiet = selectorScope({ refuse: () => {} });
assert.throws(() => quiet.take('--org', ''), /selector refusal returned/);
assert.throws(() => quiet.take('--org-file', 'x'), TypeError);
const refuse = kind => { throw new Error(kind); };
const outer = selectorScope({ refuse }), child = selectorScope({ refuse });
outer.take('--org', 'acme'); child.take('--org', 'beta');
assert.deepEqual([outer.value, child.value, outer.given], ['acme', 'beta', true]);
assert.equal(selectorScope({ refuse }).given, false);
});
test('the shell helper refuses a misuse and keeps scopes separate', () => {
const run = body => spawnSync('bash', ['-c', `set -u; . "$1"; refuse() { echo "refused $1"; exit 3; }; quiet() { :; }; ${body}`, 'x', join(REPO, 'scripts/selector.sh')], { encoding: 'utf8' });
assert.equal(run('selector_take s id refuse - --org-file a').status, 70);
assert.equal(run('selector_take bad-name id refuse - --org a').status, 70);
assert.equal(run('selector_take s id quiet - --org ""').status, 70);
const two = run('selector_take outer id refuse - --org acme; selector_take child id refuse - --business beta; echo "$(selector_value outer) $(selector_value child) [$(selector_value none)]"');
assert.equal(two.status, 0, two.stderr);
assert.equal(two.stdout, 'acme beta []\n');
});
+5
View File
@@ -13,6 +13,7 @@ import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
@@ -70,14 +71,18 @@ export function accessContextFor(system, env) {
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(why => new CliError(`${why}\n${ACCESS_USAGE}`, 4));
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -8
View File
@@ -40,6 +40,7 @@ import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../business/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
@@ -97,17 +98,22 @@ export const SEAT_USAGE = [
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
// A verb that takes --business also takes --org, in this parser's scope only, so a value another
// option consumes or positional text is never read as a selector (rename design r2).
function parse(args, { flags = [], values = [] }) {
const out = { positional: [], flags: new Set(), values: {} };
const selector = values.includes("--business") ? orgSelector(usage) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw usage(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw usage(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
@@ -682,13 +688,6 @@ async function app(io, deps) {
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
if (argv.length === 0) return app(io, deps);
if (argv[0] !== 'org') {
const org = argv.indexOf('--org'), business = argv.indexOf('--business');
if (org !== -1 && business !== -1) {
if (!argv[org + 1] || !argv[business + 1] || argv[org + 1] !== argv[business + 1]) throw usage('--org and --business conflict');
argv = argv.filter((_, i) => i !== org && i !== org + 1);
} else if (org !== -1) argv = argv.map((arg, i) => i === org ? '--business' : arg);
}
const [verb, ...rest] = argv;
if (verb === 'org') return orgCommand(rest, io, deps);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
+7 -2
View File
@@ -3,6 +3,7 @@
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
@@ -15,15 +16,19 @@ const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(CREDENTIALS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+49 -13
View File
@@ -7,9 +7,10 @@
// so a launch runs the real launcher, sandbox, runner and pi against it.
// Refuses: the default dev instance, an instance with a proxy configured, a
// provider credential in the environment, a non-loopback endpoint and a second
// fixture. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
// fixture. A record whose endpoint is gone (its lifetime ended, or it crashed)
// is stale: init replaces it. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
import { spawn } from 'node:child_process';
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { get } from 'node:http';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -19,9 +20,10 @@ import { loadSystem } from './config.mjs';
import { parseArgs } from './thread.mjs';
import { isProviderVariable } from '../../seat/src/session.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_KEY_NAME, FIXTURE_MODEL } from '../../plugins/model-provider/fixture/index.mjs';
import { TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
export const ENDPOINT = resolve(dirname(fileURLToPath(import.meta.url)), '../../plugins/model-provider/fixture/endpoint.mjs');
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--json]
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--ttl SECONDS] [--json]
mosaic dev fixture status [--json]
mosaic dev fixture stop [--json]`;
export const fixtureDir = dataRoot => join(dataRoot, 'dev-fixture');
@@ -93,11 +95,21 @@ function instanceOf(io, parsed) {
return instance;
}
function startEndpoint(logFile, { timeoutMs = 10000 } = {}) {
// --ttl: the endpoint's lifetime in whole seconds, TTL_DEFAULT_S when absent. 0 is no expiry,
// for a long-running dev instance; it is safe only because this command runs on a named dev
// instance and nothing else starts the endpoint with 0.
function ttlOf(raw) {
if (raw === undefined) return TTL_DEFAULT_S;
const n = /^(0|[1-9][0-9]{0,6})$/.test(raw) ? Number(raw) : NaN;
if (n === 0 || (n >= TTL_MIN_S && n <= TTL_MAX_S)) return n;
throw invalid(`--ttl takes 0 (no expiry) or whole seconds from ${TTL_MIN_S} to ${TTL_MAX_S}`);
}
function startEndpoint(logFile, ttlSeconds, { timeoutMs = 10000 } = {}) {
const log = openSync(logFile, 'a', 0o600);
let child;
try {
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } });
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin', MOSAIC_FIXTURE_TTL_S: String(ttlSeconds) } });
} finally { closeSync(log); }
return new Promise((resolve, reject) => {
let out = '';
@@ -114,32 +126,56 @@ function startEndpoint(logFile, { timeoutMs = 10000 } = {}) {
});
}
// Remove the stale record this init inspected, and only that one. The record is taken by an
// atomic rename, then checked: if a concurrent init already replaced it with a live endpoint's
// record, that record goes back (link refuses to overwrite) and this init refuses fixture-exists.
// An unlink by path after the check would delete the other init's new record instead.
export function takeStale(dataRoot, existing, instance) {
const file = fixtureFile(dataRoot), aside = `${file}.stale-${process.pid}`;
try { renameSync(file, aside); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
let taken = null;
try { taken = JSON.parse(readFileSync(aside, 'utf8')); } catch {}
if (taken?.pid === existing.pid && taken?.startedAt === existing.startedAt) { rmSync(aside, { force: true }); return; }
// EEXIST means a third init published meanwhile; that record stays and this init still refuses.
try { linkSync(aside, file); } catch {} finally { rmSync(aside, { force: true }); }
throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
}
const print = (io, json, value, line) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${line}\n`);
async function init(args, io, deps) {
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind'] });
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind', '--ttl'] });
if (parsed.positional.length) throw invalid('fixture init takes no positional arguments');
if (parsed.values['--model'] !== 'deterministic') throw invalid('fixture init needs --model deterministic');
const ttlSeconds = ttlOf(parsed.values['--ttl']);
const bind = parsed.values['--bind'] ?? '127.0.0.1';
if (bind !== '127.0.0.1') throw refuse('fixture-endpoint-not-loopback', `the fixture binds 127.0.0.1 only, not ${bind}`);
const instance = instanceOf(io, parsed);
noCredentials(io.env);
const system = deps.system ?? loadSystem({ env: io.env, instance });
if (system.proxy) throw refuse('fixture-proxy-configured', `instance ${instance} has a model proxy configured; the fixture is for an instance without one`);
if (readFixture(system.dataRoot)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
const existing = readFixture(system.dataRoot);
if (existing && isEndpoint(existing.pid)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
// P19 #882: an endpoint whose lifetime ended left its record, and init refused fixture-exists
// until `dev fixture stop`. A record whose pid is no longer the endpoint is stale; it goes.
// A second init racing this one still loses at the exclusive write of the new record.
if (existing) takeStale(system.dataRoot, existing, instance);
const replaced = existing ? { pid: existing.pid, startedAt: existing.startedAt ?? null } : null;
try { mkdirSync(fixtureDir(system.dataRoot), { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; }
ownedPrivate(fixtureDir(system.dataRoot), true);
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'));
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'), ttlSeconds);
const endpoint = `http://127.0.0.1:${port}`;
const started = new Date();
const record = { fixtureVersion: 1, fixture: 'fixture', model: 'deterministic', modelKey: FIXTURE_KEY_NAME, entry: FIXTURE_ENTRY,
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: new Date().toISOString() };
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: started.toISOString(),
ttlSeconds, expiresAt: ttlSeconds ? new Date(started.getTime() + ttlSeconds * 1000).toISOString() : null };
try {
if (!(await health(endpoint))?.ok) throw refuse('fixture-start-failed', 'the endpoint did not answer its health check');
writeFileSync(fixtureFile(system.dataRoot), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
} catch (e) { try { process.kill(child.pid, 'SIGTERM'); } catch {} throw e; }
const receipt = { receipt: 'dev-fixture-init', ...record, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
const receipt = { receipt: 'dev-fixture-init', ...record, replaced, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
print(io, parsed.flags.has('--json'), receipt,
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}`);
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}, ${ttlSeconds ? `expires ${record.expiresAt}` : 'no expiry'}${replaced ? `; replaced the stale record of pid ${replaced.pid}` : ''}`);
}
async function status(args, io, deps) {
@@ -153,8 +189,8 @@ async function status(args, io, deps) {
const answer = alive ? await health(record.endpoint) : null;
const report = { instance, ready: Boolean(alive && answer?.ok && !system.proxy), endpoint: record.endpoint, loopback: true, upstream: 'none', external: false,
model: record.model, modelKey: record.modelKey, entry: record.entry, principal: record.principal, pid: record.pid, alive,
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt };
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running)'}`);
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt, ttlSeconds: record.ttlSeconds ?? null, expiresAt: record.expiresAt ?? null };
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running; init replaces the record)'}`);
}
async function stop(args, io, deps) {
+7 -2
View File
@@ -6,6 +6,7 @@
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
// the human's, and the set is the human's Approve of the change.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
@@ -15,13 +16,16 @@ export const GRANTS_USAGE =
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
const MAX_TOKEN = 16384;
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
const VALUES = ["--reason", "--url", "--account", "--rotate-by", "--revision"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(GRANTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
out.flags.add(arg);
} else if (VALUES.includes(arg)) {
@@ -30,6 +34,7 @@ function parse(args) {
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,21 +4,26 @@
// reads a key: the answers carry fingerprints only. Row 235 (#1741, r6 section 6, X3) adds revoke, which
// takes an agent's grant on a host away at once, and sweep, which removes the lines a failure left.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const HOSTS_USAGE =
"mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove|sweep HOST [--tester] [--business ID] | revoke AGENT HOST --reason R [--tester] [--business ID]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(HOSTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--business" || arg === "--reason") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--reason") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(HOSTS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(HOSTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { loadSystem } from "./config.mjs";
import { systemVars } from "../../business/src/index.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
@@ -34,15 +35,19 @@ export function formatTokens(tokens) {
export function parseLimits(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(LIMITS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { configDir } from '../../business/src/business.mjs';
import { createProxyLock } from '../../bus/src/proxy-lock.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
@@ -60,11 +61,15 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
}
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
const parsed = { flags: new Set(), values: {} }; let port;
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => failure('proxy-usage', 4));
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
if (verb !== 'init' || !['--business', '--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
parsed.values[flag] = rest[++i];
}
if (selector.given) parsed.values['--business'] = selector.value;
if (parsed.values['--port'] !== undefined) {
if (!/^[0-9]+$/.test(parsed.values['--port'])) throw failure('proxy-config-invalid');
port = Number(parsed.values['--port']);
+17
View File
@@ -0,0 +1,17 @@
// The organization selector for the CLI's own parsers (rename design r2, "Selector scopes and
// parser matrix"): --org and --business in one parser's scope, under packages/business/src/
// selector.mjs. An id outside the organization grammar refuses as resolveOrg does, OrgError
// org-invalid with exit 3, but while parsing, so no verb reads stdin, prompts for a key or reads a
// business file first. Every other refusal is the parser's own usage error.
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { OrgError } from '../../business/src/org-registry.mjs';
import { selectorScope } from '../../business/src/selector.mjs';
// usageError(message) returns the parser's usage error for a missing, flag-shaped, repeated or
// conflicting selector.
export function orgSelector(usageError) {
return selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw kind === 'invalid' ? new OrgError('org-invalid', 'invalid organization id') : usageError(why); },
});
}
+7 -2
View File
@@ -5,20 +5,25 @@
// `tester`) in the organization's changes.jsonl.
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(PLUGINS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--file") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(PLUGINS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -7,6 +7,7 @@
// on the tester's own threads (owner `tester`, row 177). The broker's ownership check is the only one.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { shortId } from "./format.mjs";
import { configDir, loadBusiness } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
@@ -26,15 +27,19 @@ export const THREAD_TALK_USAGE = [
export function parseArgs(args, usage, { flags = [], values = [] } = {}) {
const out = { positional: [], flags: new Set(), values: {} };
const refuse = (why) => new CliError(`${why}\n${usage}`, 4);
// A command that takes --business also takes --org, in this parser's scope (rename design r2).
const selector = values.includes("--business") ? orgSelector(refuse) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw refuse(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw refuse(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
+5 -1
View File
@@ -1,13 +1,17 @@
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
export const USAGE_USAGE = 'mosaic usage [--business ID] [--json] [--tester]';
export async function usageCommand(args, io, callFor) {
const parsed = { flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(USAGE_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if ((arg === '--json' || arg === '--tester') && !parsed.flags.has(arg)) parsed.flags.add(arg);
else if (arg === '--business' && args[i + 1] && !args[i + 1].startsWith('--') && !parsed.values[arg]) parsed.values[arg] = args[++i];
else if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else throw new CliError(USAGE_USAGE, 4);
}
if (selector.given) parsed.values['--business'] = selector.value;
const result = await callFor(parsed)('usage', {});
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const counts = row => `input=${row.input} output=${row.output} cacheWrite=${row.cacheWrite} cacheRead=${row.cacheRead} unknown=${row.unknownTurns}`;
+11 -1
View File
@@ -251,7 +251,8 @@ test("an agent caller is refused before the bus is asked", async (t) => {
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
// GRANT already names the business; a repeated --business refuses (rename design r2).
const args = verb.includes("--business") ? [...verb, "--tester"] : [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
@@ -508,3 +509,12 @@ test("R4 the help keeps the provider-removal order and says what a refused reloa
assert.match(text, /grant and revoke ask the bus to re-read the file after the write\. If the bus refuses, the write still stands \(exit 0\), stderr names the refusal, and the bus's timer applies the change within 30 s\./);
assert.ok(text.indexOf("so do that second") < text.indexOf("re-read the file"), "next to the provider-removal order");
});
test('v1 grant receipt preserves business identity and exact JSON line shape', async t => {
const s = setup(t);
await run([...GRANT, '--revision', '1'], s.deps);
const raw = s.receipts();
const grant = s.read().grants[0].id;
assert.match(grant, /^g-[0-9a-f]{8}$/);
assert.equal(raw, JSON.stringify({ at: '2026-10-20T20:00:00.000Z', verb: 'grant', business: 'acme', user: 'alice', actor: 'human', revisionBefore: 1, revisionAfter: 2, grants: [grant] }) + '\n');
});
+96
View File
@@ -437,6 +437,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
@@ -783,6 +784,101 @@ test("row 195 accept: usage errors and a run without --root as a user exit 2", (
assert.ok(!existsSync(join(h.hc, "calls.log")));
});
// The selector (rename design r2): --org and --business are one option in each of three scopes, the
// script's own arguments and the args file's unit and serve arguments either side of --. A spelling
// may appear once per scope, and the other spelling only with the same value; never counted across --.
const SELECT = { old: ["--business", "mosaic-stack"], new: ["--org", "mosaic-stack"],
both: ["--business", "mosaic-stack", "--org", "mosaic-stack"], swapped: ["--org", "mosaic-stack", "--business", "mosaic-stack"] };
// The args file with outer unit selector tokens and serve selector tokens in ARGS's places.
const argsWith = (outer, serve) => [...ARGS.slice(0, 6), ...outer, ...ARGS.slice(8, 16), "--port", "732", ...serve, "--tester-socket"];
const invocation = (h, sel) => [...sel, "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root];
test("row 195 accept: --org or --business names the business in each of the three scopes, and talk and precheck still get --business", (t) => {
const runs = [["old", "old", "old"], ["new", "new", "new"], ["both", "swapped", "both"], ["swapped", "both", "swapped"],
["new", "old", "new"], ["old", "new", "old"], ["both", "old", "swapped"]];
const seen = [];
for (const [inv, outer, serve] of runs) {
const h = host(t), name = `${inv} ${outer} -- ${serve}`;
put(h.args, argsWith(SELECT[outer], SELECT[serve]).join("\n") + "\n", 0o644);
const r = run(h, {}, invocation(h, SELECT[inv]));
assert.equal(r.status, 0, `${name}: ${r.out}${r.err}`);
assert.deepEqual(failed(r), [], name);
const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk "));
assert.equal(talk.length, 1, name);
assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/, name);
assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"], name);
seen.push(r.lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => l.split(" ").slice(0, 3).join(" ")));
}
for (const marks of seen) assert.deepEqual(marks, seen[0]);
});
test("row 195 accept: a bad selector in any scope, or scopes that name different businesses, refuse with exit 2 before any check", (t) => {
const usage = "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N\n";
const refusing = (why) => `case1-accept: refusing: ${why}\n`;
const before = (why) => refusing(`/etc/mosaic-stack/dev.args before --: ${why}`);
const after = (why) => refusing(`/etc/mosaic-stack/dev.args after --: ${why}`);
const cases = [
// The script's own arguments.
["invocation unequal", ["--business", "mosaic-stack", "--org", "beta"], null, refusing("--business mosaic-stack and --org beta conflict")],
["invocation unequal, new first", ["--org", "mosaic-stack", "--business", "beta"], null, refusing("--org mosaic-stack and --business beta conflict")],
["invocation old twice", ["--business", "mosaic-stack", "--business", "mosaic-stack"], null, refusing("--business given twice")],
["invocation new twice", ["--org", "mosaic-stack", "--org", "mosaic-stack"], null, refusing("--org given twice")],
["invocation new empty", ["--org", ""], null, usage],
["invocation new flag-shaped", ["--org", "--args"], null, usage],
["invocation new invalid", ["--org", "Mosaic"], null, usage],
["invocation old invalid", ["--business", "a/../b"], null, usage],
// The args file's unit arguments.
["unit unequal", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--org", "beta"], SELECT.old), before("--business mosaic-stack and --org beta conflict")],
["unit unequal, new first", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--business", "beta"], SELECT.old), before("--org mosaic-stack and --business beta conflict")],
["unit old twice", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--business", "mosaic-stack"], SELECT.old), before("--business given twice")],
["unit new twice", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--org", "mosaic-stack"], SELECT.new), before("--org given twice")],
["unit new missing", SELECT.old, [...ARGS.slice(0, 6), ...ARGS.slice(8, 15), "--org"], before("--org needs one value")],
["unit new flag-shaped", SELECT.old, argsWith(["--org"], SELECT.old), before("--org needs one value, not --copy")],
["unit new invalid", SELECT.old, argsWith(["--org", "Mosaic"], SELECT.old), before("--org Mosaic is not valid")],
["unit names another business", SELECT.new, argsWith(["--org", "beta"], SELECT.new), refusing("/etc/mosaic-stack/dev.args is for business beta, not mosaic-stack")],
// --home takes --org as its value, so --business is the unit's one selector and the home refuses.
["unit --home value", SELECT.old, argsWith(["--business", "mosaic-stack"], SELECT.old).map((a, i) => (i === 5 ? "--org" : a)),
refusing("/etc/mosaic-stack/dev.args lacks a plain --user, --home, --business, --node, --claude or --copy")],
// The args file's serve arguments.
["serve unequal", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--org", "beta"]), after("--business mosaic-stack and --org beta conflict")],
["serve unequal, new first", SELECT.old, argsWith(SELECT.old, ["--org", "mosaic-stack", "--business", "beta"]), after("--org mosaic-stack and --business beta conflict")],
["serve old twice", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--business", "mosaic-stack"]), after("--business given twice")],
["serve new twice", SELECT.old, argsWith(SELECT.new, ["--org", "mosaic-stack", "--org", "mosaic-stack"]), after("--org given twice")],
["serve new missing", SELECT.old, [...argsWith(SELECT.old, []), "--org"], after("--org needs one value")],
["serve new flag-shaped", SELECT.old, argsWith(SELECT.old, ["--org"]), after("--org needs one value, not --tester-socket")],
["serve new invalid", SELECT.old, argsWith(SELECT.old, ["--org", "Mosaic"]), after("--org Mosaic is not valid")],
["serve names another business", SELECT.new, argsWith(SELECT.new, ["--org", "beta"]), refusing("serve business differs")],
["serve has no selector", SELECT.old, argsWith(SELECT.old, []), refusing("/etc/mosaic-stack/dev.args needs --business or --org after --")],
// --port takes --org as its value; the serve selector is the --business after it.
["serve --port value", SELECT.old, [...argsWith(SELECT.old, []).slice(0, 17), "--org", "--business", "mosaic-stack", "--tester-socket"],
refusing("/etc/mosaic-stack/dev.args lacks a privileged numeric --port after --")],
];
for (const [name, sel, file, err] of cases) {
const h = host(t);
if (file) put(h.args, file.join("\n") + "\n", 0o644);
const r = run(h, {}, invocation(h, sel));
assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`);
assert.equal(r.err, err, name);
assert.equal(r.out, "", name);
assert.ok(!existsSync(join(h.hc, "calls.log")), name);
}
// A missing value at the end of the script's own arguments is a usage error.
const h = host(t);
const r = run(h, {}, ["--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root, "--org"]);
assert.equal(r.status, 2);
assert.equal(r.err, usage);
});
test("row 195 accept: a copy of the script without selector.sh beside it refuses before parsing", (t) => {
const h = host(t), lone = join(h.dir, "lone", "case1-accept.sh");
put(lone, read(SCRIPT), 0o755);
const r = run(h, {}, undefined, lone);
assert.equal(r.status, 2, r.out + r.err);
assert.ok(r.err.endsWith(`case1-accept: refusing: cannot read ${join(h.dir, "lone", "selector.sh")}\n`), r.err);
assert.equal(r.out, "");
assert.ok(!existsSync(join(h.hc, "calls.log")));
});
test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=<node dir>:...", (t) => {
const h = host(t);
const r = run(h);
+70 -2
View File
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
done
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
@@ -250,6 +250,64 @@ test("the install refuses root, a business id mismatch, another --home and a con
assert.equal(existsSync(x.business), false);
});
// The selectors (rename design r2): --org/--business and --org-file/--business-file are two
// families, each its own scope. A bad selector refuses with exit 4 before anything is read or written.
const rest = (x) => ["--commit", COMMIT, "--profile", "library", "--personas", x.staging];
test("the install takes --org and --org-file, alone or matching their old spelling in either order", (t) => {
for (const sel of [
(f) => ["--org", "acme", "--org-file", f],
(f) => ["--business", "acme", "--org", "acme", "--business-file", f, "--org-file", f],
(f) => ["--org", "acme", "--business", "acme", "--org-file", f, "--business-file", f],
]) {
const x = world(t);
const r = install(x, { args: [...sel(x.input), ...rest(x)] });
assert.equal(r.code, 0, r.out);
assert.equal(JSON.parse(readFileSync(x.business, "utf8")).id, "acme");
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
}
});
test("the install refuses a bad selector in either family before it reads or writes anything", (t) => {
const x = world(t);
const f = x.input, g = `${x.root}/input/../input/acme.json`; // join() would normalize it to f
assert.notEqual(g, f);
const usage = "usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]\n";
const notId = (v) => `case2-install: --business must be a business id: ${v}\n`;
for (const [args, out] of [
[["--business", "acme", "--org", "beta", "--business-file", f], "case2-install: --business acme and --org beta conflict\n"],
[["--org", "acme", "--business", "beta", "--business-file", f], "case2-install: --org acme and --business beta conflict\n"],
[["--business", "acme", "--business", "acme", "--business-file", f], "case2-install: --business given twice\n"],
[["--org", "acme", "--org", "acme", "--business-file", f], "case2-install: --org given twice\n"],
[["--business-file", f, "--org"], usage],
[["--business-file", f, "--business"], usage],
[["--org", "", "--business-file", f], usage],
[["--org", "--business-file", f], notId("--business-file")],
[["--business", "-x", "--business-file", f], notId("-x")],
[["--org", "Acme", "--business-file", f], notId("Acme")],
[["--org", "a.b", "--business-file", f], notId("a.b")],
// The file family compares the text given: the same file named another way still conflicts.
[["--org", "acme", "--business-file", f, "--org-file", g], `case2-install: --business-file ${f} and --org-file ${g} conflict\n`],
[["--org", "acme", "--org-file", g, "--business-file", f], `case2-install: --org-file ${g} and --business-file ${f} conflict\n`],
[["--org", "acme", "--org-file", f, "--org-file", f], "case2-install: --org-file given twice\n"],
[["--org", "acme", "--business-file", f, "--business-file", f], "case2-install: --business-file given twice\n"],
[["--org", "acme", "--org-file"], usage],
[["--org", "acme", "--org-file", ""], usage],
[["--org", "acme", "--org-file", "--commit", COMMIT], "case2-install: --org-file needs one value, not --commit\n"],
]) {
const last = ["--business-file", "--business", "--org", "--org-file"].includes(args.at(-1));
const r = install(x, { args: last ? args : [...args, ...rest(x)] });
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, out, args.join(" "));
}
// A selector spelling taken as another option's value is that option's text.
const r = install(x, { args: ["--org", "acme", "--business-file", f, "--commit", "--org", "--profile", "library", "--personas", x.staging] });
assert.equal(r.code, 4, r.out);
assert.equal(r.out, "case2-install: --commit must be a full 40-character commit id: --org\n");
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install needs --personas", (t) => {
const x = world(t);
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
@@ -433,7 +491,7 @@ function dependencyFreeClone(t) {
const x = world(t);
const clone = join(x.root, "clone");
mkdirSync(join(clone, "scripts"), { recursive: true });
for (const file of ["case2-install.sh", "case2-persona-import.mjs"]) {
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
}
// Isolate the persona step; bootstrap and business validation are covered above.
@@ -463,6 +521,16 @@ test("row 322 F8: library personas install from the copy with no clone node_modu
assert.equal(existsSync(join(x.clone, "node_modules")), false);
});
// Without selector.sh beside it the install refuses before parsing, rather than skip the selector rules.
test("the install refuses when its selector.sh is missing, before it reads or writes anything", (t) => {
const x = dependencyFreeClone(t);
rmSync(join(x.clone, "scripts", "selector.sh"));
const r = x.run();
assert.equal(r.code, 4, r.out);
assert.ok(r.out.endsWith(`case2-install: cannot read ${join(x.clone, "scripts", "selector.sh")}\n`), r.out);
assert.equal(existsSync(x.business), false);
});
test("row 322 F9: importer refusal keeps the error line rather than the Node banner", (t) => {
const x = dependencyFreeClone(t);
const copy = join(x.home, ".local", "share", "mosaic-stack", "copies", COMMIT);
@@ -398,3 +398,62 @@ test("usage: stage needs one of --staging and --dry-run, install takes only its
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
assert.ok(!existsSync(s.staging));
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one option in
// this script's own parser, run directly, not through case2-install.sh.
test("install takes --org or --business, alone or matching in either order", (t) => {
for (const selector of [["--business", "case2"], ["--org", "case2"], ["--business", "case2", "--org", "case2"], ["--org", "case2", "--business", "case2"]]) {
const s = scratch(t);
assert.equal(stage(s).status, 0);
const r = run(["install", "--staging", s.staging, "--config-dir", s.configDir, ...selector]);
assert.equal(r.status, 0, `${selector.join(" ")}: ${r.all}`);
assert.deepEqual(readdirSync(join(s.configDir, "personas")), ["case2"], selector.join(" "));
assert.deepEqual(readdirSync(join(s.configDir, "personas", "case2")).sort(), ["bean.md", "chet.md"], selector.join(" "));
}
});
test("a bad selector refuses before the staging directory or the Hermes home is read", (t) => {
const s = scratch(t);
assert.equal(stage(s).status, 0);
for (const [selector, status, err] of [
[["--business", "case2", "--org", "other"], 2, "case2-persona-import: --business case2 and --org other conflict\n"],
[["--org", "case2", "--business", "other"], 2, "case2-persona-import: --org case2 and --business other conflict\n"],
[["--business", "case2", "--business", "case2"], 2, "case2-persona-import: --business given twice\n"],
[["--org", "case2", "--org", "case2"], 2, "case2-persona-import: --org given twice\n"],
[["--org"], 2, "case2-persona-import: --org needs one value\n"],
[["--business"], 2, "case2-persona-import: --business needs one value\n"],
[["--org", ""], 2, "case2-persona-import: --org needs one value\n"],
[["--org", "--dry-run"], 2, "case2-persona-import: --org needs one value, not --dry-run\n"],
[["--org", "-x"], 1, "case2-persona-import: refused: business -x isn't a business id\n"],
[["--org", "Case2"], 1, "case2-persona-import: refused: business Case2 isn't a business id\n"],
[["--business", "../x"], 1, "case2-persona-import: refused: business ../x isn't a business id\n"],
[["--org", "a.b"], 1, "case2-persona-import: refused: business a.b isn't a business id\n"],
]) {
for (const verb of [["install", "--staging", s.staging, "--config-dir", s.configDir], ["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]]) {
rmSync(s.trace, { force: true });
const r = run([...verb, ...selector], { trace: s.trace });
const label = `${verb[0]} ${selector.join(" ")}`;
assert.equal(r.status, status, `${label}: ${r.all}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1));
assert.deepEqual(opened.filter((p) => p.startsWith(s.root)), [], `${label} read nothing of the scratch tree`);
}
}
assert.equal(existsSync(join(s.configDir, "personas")), false);
// The control: the same trace sees install read the staging directory.
rmSync(s.trace, { force: true });
assert.equal(install(s, "case2", { trace: s.trace }).status, 0);
assert.ok(readFileSync(s.trace, "utf8").includes(join(s.staging, "manifest.json")));
});
test("a selector spelling is never another option's value, and stage still ignores a valid selector", (t) => {
const s = scratch(t);
// This grammar refuses any value starting "--", so a selector spelling can't be one.
const r = run(["install", "--staging", "--org", "case2", "--config-dir", s.configDir]);
assert.equal(r.status, 2, r.all);
assert.equal(r.err, "case2-persona-import: --staging needs a value\n");
const st = stage(s, ["--org", "case2"]);
assert.equal(st.status, 0, st.all);
assert.deepEqual(readdirSync(s.staging).sort(), ["bean.md", "chet.md", "manifest.json"]);
});
+113 -4
View File
@@ -107,9 +107,9 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
encoding: "utf8",
timeout: 30000,
@@ -338,6 +338,71 @@ test("a clean read-only library render writes both units 0644 with the fixed lin
]);
});
// The selector (rename design r2): --org and --business are one option in each of two scopes, the
// script's own flags before -- and the serve flags after it. Nothing is counted across --; the serve
// selector is required, must be the unit's business, and renders as --business.
const SELECTORS = [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]];
test("the unit takes --org or --business on either side of --, renders --business once, and re-installs as the same units", (t) => {
const h = hub(t);
assert.equal(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme"] }).code, 0);
const before = [read(busFile(h)), read(webuiFile(h))];
assert.deepEqual(lines(before[1], "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme`]);
for (const outer of SELECTORS) {
for (const child of SELECTORS) {
const r = unit(h, { profile: "factory", outer, flags: ["--port", "443", ...child] });
assert.equal(r.code, 0, `${outer.join(" ")} -- ${child.join(" ")}: ${r.err}`);
assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before, `${outer.join(" ")} -- ${child.join(" ")}`);
}
}
// The selector keeps its place among the serve flags.
const g = hub(t);
assert.equal(unit(g, { profile: "factory", outer: ["--org", "acme"], flags: ["--org", "acme", "--port", "443", "--business", "acme"] }).code, 0);
assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --business acme --port 443`]);
});
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
const h = hub(t);
const serve = ["--port", "443", "--business", "acme"];
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
for (const [label, argv, code, err] of [
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
["outer unequal, new first", [...head, "--org", "acme", "--business", "beta", "--", ...serve], 4, "case2-unit: --org acme and --business beta conflict\n"],
["outer old repeated", [...head, "--business", "acme", "--business", "acme", "--", ...serve], 4, "case2-unit: --business given twice\n"],
["outer new repeated", [...head, "--org", "acme", "--org", "acme", "--", ...serve], 4, "case2-unit: --org given twice\n"],
["outer missing", [...head, "--org"], 4, usage],
["outer empty", [...head, "--org", "", "--", ...serve], 4, usage],
["outer none", [...head, "--", ...serve], 4, usage],
["outer invalid", [...head, "--org", "Acme", "--", ...serve], 1, "case2-unit: --business must be a business id: Acme\n"],
["outer flag-shaped", [...head, "--business", "-x", "--", ...serve], 1, "case2-unit: --business must be a business id: -x\n"],
["outer takes no separator", [...head, "--org", "--", ...serve], 1, "case2-unit: --business must be a business id: --\n"],
["serve unequal, old first", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--org", "beta"], 1, "case2-unit: serve --business acme and --org beta conflict\n"],
["serve unequal, new first", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--business", "beta"], 1, "case2-unit: serve --org acme and --business beta conflict\n"],
["serve old repeated", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--business", "acme"], 1, "case2-unit: serve flag --business given twice\n"],
["serve new repeated", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--org", "acme"], 1, "case2-unit: serve flag --org given twice\n"],
["serve missing", [...head, "--business", "acme", "--", "--port", "443", "--org"], 1, "case2-unit: serve flag --org needs a value\n"],
["serve empty", [...head, "--business", "acme", "--", "--port", "443", "--org", ""], 1, "case2-unit: serve flag --org needs a value\n"],
["serve none", [...head, "--org", "acme", "--", "--port", "443"], 1, "case2-unit: serve flag --business is required\n"],
["serve invalid", [...head, "--business", "acme", "--", "--port", "443", "--org", "Acme"], 1, "case2-unit: serve --org Acme is not the unit's business acme\n"],
["serve flag-shaped", [...head, "--business", "acme", "--", "--port", "443", "--org", "--tester-socket"], 1, "case2-unit: serve --org --tester-socket is not the unit's business acme\n"],
["outer and serve differ", [...head, "--org", "acme", "--", "--port", "443", "--org", "beta"], 1, "case2-unit: serve --org beta is not the unit's business acme\n"],
["serve and outer differ", [...head, "--org", "beta", "--", "--port", "443", "--business", "acme"], 1, "case2-unit: serve --business acme is not the unit's business beta\n"],
["a selector as --copy's value", ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", "--org", "--node", USR_NODE, "--profile", "factory", "--", ...serve], 1, "case2-unit: --copy must be a full 40-character lower-case commit id: --org\n"],
]) {
const r = unit(h, { argv });
assert.equal(r.code, code, `${label}: ${r.err}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
}
// A selector spelling taken as a library serve flag's value is that flag's text.
const library = libraryFlags(h).map((f) => (f === "books.example" ? "--org" : f));
refuses(unit(h, { flags: library }), /^case2-unit: public host must be a hostname, no port or scheme: --org\n$/);
assert.equal(existsSync(busFile(h)), false);
assert.equal(existsSync(webuiFile(h)), false);
assert.equal(existsSync(h.log), false);
});
test("the read-only library profile explicitly refuses --send before writing a unit", (t) => {
const h = hub(t);
const r = unit(h, { flags: [...libraryFlags(h), "--send"] });
@@ -736,8 +801,8 @@ function pathWithoutClaude(x) {
return `${x.bin}:${usr}`;
}
function precheck(x, { profile = "library", env = {}, path } = {}) {
const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], {
function precheck(x, { profile = "library", env = {}, path, args = ["--business", "acme", "--profile", profile] } = {}) {
const r = spawnSync(PRECHECK, args, {
encoding: "utf8",
timeout: 60000,
env: {
@@ -772,6 +837,50 @@ test("the precheck passes on a good host and prints free memory, without the key
assert.doesNotMatch(r.out, /claude .*fake|marker/);
});
// The selector (rename design r2): --org and --business are one option in the precheck's scope, and a
// bad one refuses before any check line, with the script's usage exit 4.
test("the precheck takes --org or --business, alone or matching in either order, and refuses a bad selector before any check", (t) => {
const x = host(t);
for (const sel of [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]) {
const r = precheck(x, { args: [...sel, "--profile", "library"] });
assert.equal(r.code, 0, `${sel.join(" ")}: ${r.out}`);
assert.match(r.out, /ok {6}config validates/, sel.join(" "));
}
const usage = "usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status\n";
const notId = (v) => `case2-precheck: --business must be a business id: ${v}\n`;
for (const [args, out] of [
[["--business", "acme", "--org", "beta"], "case2-precheck: --business acme and --org beta conflict\n"],
[["--org", "acme", "--business", "beta"], "case2-precheck: --org acme and --business beta conflict\n"],
[["--business", "acme", "--business", "acme"], "case2-precheck: --business given twice\n"],
[["--org", "acme", "--org", "acme"], "case2-precheck: --org given twice\n"],
[["--org"], usage],
[["--business"], usage],
[["--org", ""], usage],
[["--org", "--profile", "library"], notId("--profile")],
[["--business", "-x"], notId("-x")],
[["--org", "Acme"], notId("Acme")],
[["--org", "acme/../x"], notId("acme/../x")],
]) {
const r = precheck(x, { args });
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, out, args.join(" "));
}
// A selector spelling taken as --profile's value is that option's text.
const r = precheck(x, { args: ["--profile", "--org", "--business", "acme"] });
assert.equal(r.code, 4, r.out);
assert.equal(r.out, "case2-precheck: --profile must be factory or library: --org\n");
});
test("the precheck refuses when its selector.sh is missing, before any check", (t) => {
const x = host(t), lone = join(x.root, "lone", "scripts", "case2-precheck.sh");
mkdirSync(dirname(lone), { recursive: true });
cpSync(PRECHECK, lone);
const r = spawnSync(lone, ["--org", "acme"], { encoding: "utf8", env: { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home } });
assert.equal(r.status, 4, r.stdout + r.stderr);
assert.equal(r.stdout, "");
assert.ok(r.stderr.endsWith(`case2-precheck: cannot read ${join(x.root, "lone", "scripts", "selector.sh")}\n`), r.stderr);
});
test("the precheck refuses each failed host check", (t) => {
blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/);
blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/);
+86 -4
View File
@@ -1,12 +1,14 @@
// `mosaic dev fixture`: refusals, the state file's checks, and one real
// init/status/stop of the loopback endpoint in a scratch HOME.
// `mosaic dev fixture`: refusals, the state file's checks, one real
// init/status/stop of the loopback endpoint in a scratch HOME, and the
// replacement of a record whose endpoint has ended.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { devCommand, fixtureFile, fixtureLaunchEnv, fixtureLauncherOptions, isEndpoint, orgUsesFixture, readFixture } from '../src/dev-fixture.mjs';
import { devCommand, fixtureFile, fixtureLaunchEnv, fixtureLauncherOptions, isEndpoint, orgUsesFixture, readFixture, takeStale } from '../src/dev-fixture.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY } from '../../plugins/model-provider/fixture/index.mjs';
import { TTL_DEFAULT_S, TTL_MAX_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
import { io } from './helpers.mjs';
// devOwnerInstance reads the instance from MOSAIC_CONFIG under $HOME/.config.
@@ -36,6 +38,8 @@ test('init refuses before it starts anything', async () => {
await refused(INIT, envFor('fxtest', { ANTHROPIC_API_KEY: 'sk-ant-value-never-printed' }), system, 'fixture-credential-present');
await assert.rejects(devCommand(INIT, io(envFor('fxtest', { ZAI_API_KEY: 'zai-value-never-printed' })), { system }), e => /ZAI_API_KEY/.test(e.message) && !/never-printed/.test(e.message));
await refused(INIT, envFor(), { ...system, proxy: { url: 'http://127.0.0.1:1' } }, 'fixture-proxy-configured');
for (const ttl of ['-1', '1.5', '59', String(TTL_MAX_S + 1), '060', '1e3', 'soon', ''])
await assert.rejects(devCommand([...INIT, '--ttl', ttl], io(envFor()), { system }), e => e.exitCode === 4 && /--ttl takes 0 \(no expiry\) or whole seconds/.test(e.message), `--ttl ${ttl}`);
assert.equal(existsSync(join(system.dataRoot, 'dev-fixture')), false);
});
@@ -98,6 +102,7 @@ test('a real init, status and stop on loopback', async t => {
assert.equal(receipt.receipt, 'dev-fixture-init');
assert.match(receipt.endpoint, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.deepEqual([receipt.loopback, receipt.external, receipt.upstream, receipt.instance, receipt.principal, receipt.modelKey], [true, false, 'none', 'fxtest', 'local-dev:local-dev-owner', 'fixture-deterministic']);
assert.deepEqual([receipt.ttlSeconds, receipt.replaced], [TTL_DEFAULT_S, null]);
assert.equal(isEndpoint(receipt.pid), true);
assert.equal(JSON.parse(readFileSync(fixtureFile(system.dataRoot), 'utf8')).pid, receipt.pid);
assert.doesNotMatch(readFileSync(fixtureFile(system.dataRoot), 'utf8'), new RegExp(FIXTURE_KEY));
@@ -106,6 +111,8 @@ test('a real init, status and stop on loopback', async t => {
await devCommand(['fixture', 'status', '--json'], s, { system });
const status = JSON.parse(s.out.text);
assert.deepEqual([status.ready, status.alive, status.external, status.upstream, status.proxyConfigured, status.health.upstream], [true, true, false, 'none', false, 'none']);
// The default lifetime reaches the endpoint process.
assert.deepEqual([status.ttlSeconds, status.health.ttlSeconds], [TTL_DEFAULT_S, TTL_DEFAULT_S]);
const p = io(env);
await devCommand(['fixture', 'status', '--json'], p, { system: { ...system, proxy: {} } });
assert.equal(JSON.parse(p.out.text).ready, false);
@@ -119,3 +126,78 @@ test('a real init, status and stop on loopback', async t => {
assert.equal(readFixture(system.dataRoot), null);
await refused(['fixture', 'status'], env, system, undefined, 2);
});
// P19 #882. The endpoint's lifetime ends through the same stop as SIGTERM, which leaves the
// record behind; init used to refuse fixture-exists on it until `dev fixture stop`. The
// lifetime's timer is tested in packages/plugins/tests/model-provider-fixture.test.mjs.
test('init replaces the record of an endpoint that has ended, and --ttl reaches the endpoint', async t => {
const system = { dataRoot: dataRoot() };
const env = envFor();
const pids = [];
t.after(() => { for (const pid of pids) if (isEndpoint(pid)) process.kill(pid, 'SIGKILL'); });
const init = async (...extra) => {
const x = io(env); await devCommand([...INIT, '--json', ...extra], x, { system });
const receipt = JSON.parse(x.out.text); pids.push(receipt.pid); return receipt;
};
const status = async () => { const x = io(env); await devCommand(['fixture', 'status', '--json'], x, { system }); return JSON.parse(x.out.text); };
const first = await init('--ttl', '0');
assert.deepEqual([first.ttlSeconds, first.expiresAt, first.replaced], [0, null, null]);
let s = await status();
assert.deepEqual([s.ready, s.health.ttlSeconds, s.health.expiresAt], [true, 0, null]);
process.kill(first.pid, 'SIGTERM');
for (let i = 0; i < 50 && isEndpoint(first.pid); i++) await new Promise(r => setTimeout(r, 100));
assert.equal(isEndpoint(first.pid), false);
assert.equal(readFixture(system.dataRoot).pid, first.pid, 'an ended endpoint leaves its record');
s = await status();
assert.deepEqual([s.ready, s.alive], [false, false]);
const second = await init('--ttl', '120');
assert.notEqual(second.pid, first.pid);
assert.deepEqual(second.replaced, { pid: first.pid, startedAt: first.startedAt });
assert.equal(readFixture(system.dataRoot).pid, second.pid);
s = await status();
assert.deepEqual([s.ready, s.ttlSeconds, s.health.ttlSeconds], [true, 120, 120]);
assert.ok(Math.abs(Date.parse(s.health.expiresAt) - Date.parse(second.expiresAt)) < 10000, `${s.health.expiresAt} vs ${second.expiresAt}`);
assert.equal(Date.parse(second.expiresAt) - Date.parse(second.startedAt), 120000);
// A live endpoint's record still refuses.
await refused(INIT, env, system, 'fixture-exists');
const stopped = io(env);
await devCommand(['fixture', 'stop', '--json'], stopped, { system });
assert.equal(JSON.parse(stopped.out.text).signaled, true);
// A record whose pid now names another process (this test runner) is stale too.
const reused = dataRoot();
writeState(reused, { ...RECORD, pid: process.pid });
const x = io(env); await devCommand([...INIT, '--json'], x, { system: { dataRoot: reused } });
const third = JSON.parse(x.out.text); pids.push(third.pid);
assert.deepEqual(third.replaced, { pid: process.pid, startedAt: null });
await devCommand(['fixture', 'stop'], io(env), { system: { dataRoot: reused } });
assert.equal(isEndpoint(third.pid), false);
});
// Review P2 (org-test-health): two inits both observe the same stale record. The first replaces
// it with its live endpoint's record; the second, resuming, must not delete that new record.
test('a stale record is removed only if it is still the one observed; a newer record stays', () => {
const observed = { ...RECORD, startedAt: '2026-11-08T00:00:00.000Z' };
const root = dataRoot();
writeState(root, observed);
takeStale(root, observed, 'fxtest');
assert.equal(existsSync(fixtureFile(root)), false, 'the observed stale record goes');
assert.deepEqual(readdirSync(join(root, 'dev-fixture')), [], 'nothing is left aside');
const raced = dataRoot();
const newer = { ...RECORD, pid: 4242, startedAt: '2026-11-08T00:00:05.000Z' };
writeState(raced, newer);
const before = readFileSync(fixtureFile(raced), 'utf8');
assert.throws(() => takeStale(raced, observed, 'fxtest'), e => e.code === 'fixture-exists');
assert.equal(readFileSync(fixtureFile(raced), 'utf8'), before, 'the other init\'s record is back, unchanged');
assert.deepEqual(readdirSync(join(raced, 'dev-fixture')), ['fixture.json'], 'nothing is left aside');
const gone = dataRoot();
mkdirSync(join(gone, 'dev-fixture'), { mode: 0o700 });
takeStale(gone, observed, 'fxtest');
assert.equal(existsSync(fixtureFile(gone)), false, 'a record already taken by the other init is not an error');
});
+32
View File
@@ -11,6 +11,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
@@ -358,3 +360,33 @@ test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call",
}
assert.equal(s.calls.length, 0);
});
test('v1 business instances, instance and veto retain broker proof refusals and leave files untouched', async t => {
const s = await setup(t);
const before = readFileSync(s.file, 'utf8');
const reader = s.bus.broker.bindReader({ business: 'acme' });
const calls = [];
const deps = { system: { dataRoot: s.f.dataRoot }, transport: business => async (verb, args) => {
calls.push({ business, verb, args });
try { return structuredClone(s.bus.broker.request(reader, { verb, args })); }
catch (error) { const e = new CliError(error.code, busExit(error.code)); e.code = error.code; throw e; }
} };
const cases = [
[['instances', 'acme', '--json'], 'instances', {}],
[['instance', 'set', 'acme', 'coder', '--launch', '--revision', 'a'.repeat(64)], 'instances.set', { revision: 'a'.repeat(64), instance: 'coder', op: 'change', launch: true }],
[['veto', 'acme', 'keep', 'coder', '--revision', 'a'.repeat(64)], 'veto.set', { revision: 'a'.repeat(64), op: 'keep', name: 'coder' }],
];
for (const [args, verb, request] of cases) {
const x = io(s.f.env);
assert.equal(await run(['business', ...args], x, deps), 3, args.join(' '));
assert.equal(x.out.text, '');
assert.match(x.err.text, /human-required/);
assert.deepEqual(calls.at(-1), { business: 'acme', verb, args: request });
const count = calls.length;
const agent = io({ ...s.f.env, CODEX_THREAD_ID: 'fixture-agent' });
assert.equal(await run(['business', ...args], agent, deps), 3);
assert.match(agent.err.text, /human shell/);
assert.equal(calls.length, count);
}
assert.equal(readFileSync(s.file, 'utf8'), before);
});
+3
View File
@@ -29,6 +29,9 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
const url = new URL(result.url);
assert.equal(url.hostname, '127.0.0.1');
assert.ok(Number.isSafeInteger(result.pid));
// A selector producer (rename r2): the Console child gets the active organization as --business.
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
assert.equal(status, 200);
+211
View File
@@ -0,0 +1,211 @@
// The organization selector on every CLI parser that takes --business (rename design r2, "Selector
// scopes and parser matrix"): --org is the same option in that parser's scope. Each entry point runs
// through main() with a stub transport that records the organization callFor binds, then stops; a
// refused selector binds nothing.
import { test } from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { accessCommand } from "../src/access.mjs";
import { parseLimits } from "../src/limits.mjs";
import { parseArgs } from "../src/thread.mjs";
import { CliError } from "../src/errors.mjs";
import { OrgError } from "../../business/src/org-registry.mjs";
import { io, orgFixture, tmp } from "./helpers.mjs";
const STOP = "selector test stop";
const stop = () => { throw new CliError(STOP, 99); };
function stub(t) {
const h = { bound: [], calls: [], prompted: 0 };
const transport = b => { h.bound.push(b); return async (verb, args) => { h.calls.push({ verb, args }); stop(); }; };
h.deps = {
system: { dataRoot: tmp(t, "sel-") },
transport,
testerTransport: transport,
promptProxyKey: async () => { h.prompted++; stop(); },
};
return h;
}
// What one argv does: reached the bus (or the key prompt), refused, or failed some other way.
async function outcome(t, argv, { tty = false, input } = {}) {
const h = stub(t), term = io();
if (tty) { term.stdin.isTTY = true; term.stdout.isTTY = true; }
if (input !== undefined) term.stdin.end(input);
const seen = { bound: h.bound, calls: h.calls, get prompted() { return h.prompted; }, stdin: term.stdin };
try {
await main(argv, term, h.deps);
return { ...seen, kind: "done" };
} catch (e) {
if (e instanceof CliError && e.message === STOP) return { ...seen, kind: "reached" };
if (e instanceof OrgError) return { ...seen, kind: "org", code: e.code };
if (e instanceof CliError) return { ...seen, kind: "refused", exitCode: e.exitCode, code: e.code, message: e.message };
throw e;
}
}
// The design's per-parser cases. The selector goes after the verb's own arguments.
const CASES = [
["old alone", ["--business", "acme"], "acme"],
["new alone", ["--org", "acme"], "acme"],
["matching, old first", ["--business", "acme", "--org", "acme"], "acme"],
["matching, new first", ["--org", "acme", "--business", "acme"], "acme"],
["unequal, old first", ["--business", "acme", "--org", "beta"], "conflict"],
["unequal, new first", ["--org", "acme", "--business", "beta"], "conflict"],
["old repeated", ["--business", "acme", "--business", "acme"], "usage"],
["new repeated", ["--org", "acme", "--org", "acme"], "usage"],
["old missing", ["--business"], "usage"],
["new missing", ["--org"], "usage"],
["new empty", ["--org", ""], "usage"],
["new flag-shaped", ["--org", "--json"], "usage"],
["old flag-shaped", ["--business", "--org", "acme"], "usage"],
["new invalid", ["--org", "Acme"], "invalid"],
["old invalid", ["--business", "acme/../x"], "invalid"],
];
const ENTRIES = [
["inbox"], ["decide", "d1234567", "yes"], ["tasks"], ["agents"], ["trail", "t1"],
["talk", "pm", "hi"], ["follow"], ["names"], ["name", "coder", "Bob"],
["halt"], ["resume"], ["processing", "status"], ["launches", "off"], ["launches", "on"],
["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "work/t1"], ["session", "list", "work/t1"],
["limits"], ["limits", "set", "--file", "patch.json"], ["usage"], ["plugins"], ["plugins", "set", "--file", "patch.json"],
["credentials", "status"], ["credentials", "delete", "coder", "--reason", "gone"],
["hosts", "status"], ["hosts", "revoke", "coder", "h1", "--reason", "gone"],
["grants", "status", "gitea"], ["grants", "revoke", "coder", "gitea", "--reason", "gone"],
];
function check(r, expected, label) {
if (expected === "acme") {
assert.deepEqual(r.bound, ["acme"], `${label}: ${r.kind} ${r.message ?? r.code ?? ""}`);
return;
}
assert.deepEqual(r.bound, [], `${label} bound nothing`);
assert.equal(r.calls.length, 0, `${label} made no call`);
if (expected === "invalid") {
assert.equal(r.kind, "org", label);
assert.equal(r.code, "org-invalid", label);
} else {
assert.equal(r.kind, "refused", `${label}: ${r.kind}`);
assert.equal(r.exitCode, 4, label);
}
}
for (const base of ENTRIES) {
test(`selector on mosaic ${base.join(" ")}`, async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, [...base, ...selector]);
check(r, expected, `${base.join(" ")} ${name}`);
// The verbs whose usage error carries the reason say which spellings conflict.
if (expected === "conflict" && !["limits", "usage", "plugins", "credentials", "hosts", "grants"].includes(base[0])) assert.match(r.message, /conflict/i, name);
}
});
}
test("selector on mosaic proxy init: the key prompt waits for a valid selector", async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, ["proxy", "init", ...selector], { tty: true });
check(r, expected, `proxy init ${name}`);
assert.equal(r.prompted, expected === "acme" ? 1 : 0, `proxy init ${name} prompted`);
if (r.kind === "refused") assert.equal(r.code, "proxy-usage", name);
}
// fetch takes no selector.
assert.equal((await outcome(t, ["proxy", "fetch", "--org", "acme"])).code, "proxy-usage");
});
test("selector on mosaic access: the human proof binds the selected organization", async (t) => {
for (const verb of [["list"], ["user", "add", "ana"]]) {
for (const [name, selector, expected] of CASES) {
const bound = [], term = io();
const deps = { system: { dataRoot: tmp(t, "sel-") }, transport: b => { bound.push(b); return async () => stop(); }, contextFor: () => { throw new Error("context read before the proof"); } };
let r;
try { await accessCommand([...verb, ...selector], term, deps); r = { kind: "done" }; }
catch (e) {
if (e instanceof CliError && e.message === STOP) r = { kind: "reached" };
else if (e instanceof OrgError) r = { kind: "org", code: e.code };
else if (e instanceof CliError) r = { kind: "refused", exitCode: e.exitCode, message: e.message };
else throw e;
}
check({ ...r, bound, calls: [] }, expected, `access ${verb.join(" ")} ${name}`);
if (expected === "conflict") assert.match(r.message, /conflict/i);
}
}
});
test("a selector token taken as another option's value is that option's text", async (t) => {
const note = await outcome(t, ["decide", "d1234567", "yes", "--note", "--org", "--business", "acme"]);
assert.deepEqual(note.bound, ["acme"]);
const project = await outcome(t, ["threads", "--project", "--org", "--business", "acme"]);
assert.deepEqual(project.bound, ["acme"]);
// The channel's first call reads plugins, so the repo shows in the shared parser's result.
const repo = await outcome(t, ["project", "create", "work", "--repo", "--org", "--business", "acme"]);
assert.deepEqual(repo.bound, ["acme"]);
assert.deepEqual(parseArgs(["work", "--repo", "--org", "--business", "acme"], "u", { values: ["--repo", "--business"] }), { positional: ["work"], flags: new Set(), values: { "--repo": "--org", "--business": "acme" } });
for (const argv of [
["credentials", "delete", "coder", "--reason", "--org", "--business", "acme"],
["hosts", "revoke", "coder", "h1", "--reason", "--business", "--org", "acme"],
["grants", "revoke", "coder", "gitea", "--reason", "--org", "--business", "acme"],
]) {
const r = await outcome(t, argv);
assert.deepEqual(r.bound, ["acme"], argv.join(" "));
assert.equal(r.calls[0].args.reason, argv[argv.indexOf("--reason") + 1], argv.join(" "));
}
// callFor binds before the file is read, so the bound organization and the file name both show.
const plugins = await outcome(t, ["plugins", "set", "--file", "--org", "--business", "acme"]);
assert.deepEqual(plugins.bound, ["acme"]);
assert.match(plugins.message, /--org/);
assert.deepEqual(parseLimits(["set", "--file", "--org", "--business", "acme"]).values, { "--file": "--org", "--business": "acme" });
// proxy init: --port takes --org, so the port check refuses, not the selector.
const port = await outcome(t, ["proxy", "init", "--port", "--org", "--business", "acme"], { tty: true });
assert.equal(port.code, "proxy-config-invalid");
// access keeps its own grammar: an option value may not start with --.
await assert.rejects(accessCommand(["list", "--business", "acme", "--user", "--org"], io(), { transport: stop, contextFor: stop }), /--user needs one value/);
});
test("positional text next to a selector stays positional", async (t) => {
const r = await outcome(t, ["talk", "pm", "--org", "acme", "--business", "acme", "hi"]);
assert.deepEqual(r.bound, ["acme"]);
assert.deepEqual(r.calls[0], { verb: "message.send", args: { to: "pm", body: "hi", class: "REQUEST" } });
});
test("an invalid selector refuses before stdin is read", async (t) => {
const argv = ["grants", "admin", "set", "gitea", "--url", "https://git.example", "--account", "a", "--rotate-by", "2027-01-01"];
const bad = await outcome(t, [...argv, "--org", "Bad"], { input: "tok" });
assert.equal(bad.code, "org-invalid");
assert.ok(bad.stdin.readableLength > 0, "the token is still unread");
const good = await outcome(t, [...argv, "--org", "acme"], { input: "tok" });
assert.equal(good.kind, "reached");
assert.deepEqual(good.bound, ["acme"]);
assert.equal(good.calls[0].args.token, "tok");
});
test("--org is no longer a global option: a verb without a selector refuses it", async (t) => {
for (const argv of [["console", "start", "--org", "acme"], ["bus", "status", "--org", "acme"]]) {
const r = await outcome(t, argv);
assert.equal(r.kind, "refused", argv.join(" "));
assert.match(r.message, /--org/, argv.join(" "));
}
});
test("stop and launches list resolve the selected organization", async (t) => {
const { f } = await orgFixture(t, "acme");
const run = async (argv) => {
const term = io(f.env);
try { await main(argv, term, { system: { dataRoot: f.dataRoot } }); return { kind: "done", out: term.out.text }; }
catch (e) {
if (e instanceof OrgError) return { kind: "org", code: e.code };
if (e instanceof CliError) return { kind: "refused", exitCode: e.exitCode, message: e.message.split("\n")[0] };
throw e;
}
};
for (const [name, selector, expected] of CASES) {
const list = await run(["launches", "list", ...selector]);
if (expected === "acme") assert.deepEqual(list, { kind: "done", out: "no sessions\n" }, name);
else if (expected === "invalid") assert.deepEqual(list, { kind: "org", code: "org-invalid" }, name);
else assert.equal(list.exitCode, 4, name);
}
const reference = await run(["stop", "r1", "--business", "acme"]);
assert.notEqual(reference.kind, "org");
for (const selector of [["--org", "acme"], ["--org", "acme", "--business", "acme"]]) assert.deepEqual(await run(["stop", "r1", ...selector]), reference);
assert.deepEqual(await run(["stop", "r1", "--org", "beta"]), { kind: "org", code: "org-unknown" });
assert.equal((await run(["stop", "r1", "--org", "acme", "--business", "beta"])).exitCode, 4);
});
+57 -1
View File
@@ -22,7 +22,8 @@ export function setup(t, realPin=false){
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
@@ -361,6 +362,61 @@ test('row 232: check --units external reads the system units, the default reads
}
});
// Rename r2 "Selector scopes and parser matrix": check takes --org as --business, each spelling once, both only
// with one value; a refused selector reads no unit, and --org is refused outside check as --business is.
test('rename r2: check takes --org as --business, once per spelling, and refuses before any unit read',async t=>{
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create'])).status,0);
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--copies',s.copies,'--bin',s.bin];
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
const shows=b=>[`--user show mosaic-bus@${b}.service -p LoadState -p WorkingDirectory -p ExecStart`,'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart'];
for(const sel of [['--business','demo'],['--org','demo'],['--business','demo','--org','demo'],['--org','demo','--business','demo'],['--org','demo','--units','user']]){
const r=await s.run('stack-copy.sh',[...base,...sel]);
assert.equal(r.status,0,sel.join(' ')+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'),sel.join(' '));rmSync(argv);
}
// The selector may come before the other options too.
let r=await s.run('stack-copy.sh',['check','--org','demo','--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'));rmSync(argv);
for(const [sel,status,message] of [
[['--business','demo','--org','beta'],4,'--business demo and --org beta conflict'],
[['--org','beta','--business','demo'],4,'--org beta and --business demo conflict'],
[['--business','demo','--business','demo'],4,'--business given twice'],
[['--org','demo','--org','demo'],4,'--org given twice'],
[['--org','demo','--business','demo','--org','demo'],4,'--org given twice'],
[['--org'],4,'--org needs one value'],
[['--business'],4,'--business needs one value'],
[['--org',''],4,'--org needs one value'],
[['--org','--units','user'],4,'--org needs one value, not --units'],
[['--business','-x'],4,'--business needs one value, not -x'],
[['--org','Demo'],1,'--org Demo is not valid'],
[['--business','demo_x'],1,'--business demo_x is not valid'],
[['--org','-demo'],4,'--org needs one value, not -demo'],
]){
r=await s.run('stack-copy.sh',[...base,...sel]);
assert.equal(r.status,status,sel.join(' '));assert.equal(r.stderr,`stack-copy: ${message}\n`,sel.join(' '));assert.equal(r.stdout,'',sel.join(' '));
assert.ok(!existsSync(argv),`${sel.join(' ')} reads no unit`);
}
// Positional text after a selector value stays a usage error; --org outside check is one too.
for(const args of [[...base,'--org','demo','beta'],['copy',s.id,'--copies',s.copies,'--org','demo'],['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--org','demo'],['copy',s.id,'--business','demo']]){
r=await s.run('stack-copy.sh',args);assert.equal(r.status,4,args.join(' '));assert.match(r.stderr,/^usage: stack-copy\.sh /,args.join(' '));
assert.ok(!existsSync(argv),args.join(' '));
}
});
test('rename r2: check --instance takes --org for its business and still needs one selector',async t=>{
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
assert.equal((await s.run('bootstrap.sh',['--instance','alpha'])).status,0);
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin])).status,0);
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--instance','alpha','--copies',s.copies,'--bin',s.bin];
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
for(const sel of [['--org','demo'],['--business','demo','--org','demo']]){
const r=await s.run('stack-copy.sh',[...base,...sel]);assert.equal(r.status,0,sel.join(' ')+r.stderr);
assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
assert.equal(readFileSync(argv,'utf8').split('\n')[0],'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',sel.join(' '));rmSync(argv);
}
const r=await s.run('stack-copy.sh',base);assert.equal(r.status,4);assert.match(r.stderr,/^usage: stack-copy\.sh /);assert.ok(!existsSync(argv));
});
test('Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find',async t=>{
const s=setup(t),cwd=join(s.root,'unreadable');mkdirSync(cwd);
const bundle=join(s.root,'release.bundle');git(s.repo,'bundle','create','-q',bundle,'refactor');
+2 -1
View File
@@ -272,7 +272,8 @@ after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,f
export const repo=join(base,'repo'),COPIES=join(base,'copies'),SBIN=join(base,'sbin');
mkdirSync(repo);mkdirSync(COPIES);mkdirSync(SBIN);
writeFileSync(join(base,'package.json'),'{"type":"commonjs"}\n');
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
export const pkg={name:'copy-fixture',version:'1.0.0'};
writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
+1 -1
View File
@@ -2069,7 +2069,7 @@ export class Store {
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
const d=join(RCOPIES,id);
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/org-selector.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
// Row 333 R2 (#1831): what the business loader imports from outside its directory, model-quota.mjs included.
for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p));
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
+105 -3
View File
@@ -9,7 +9,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawn,spawnSync} from 'node:child_process';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync} from 'node:fs';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync,realpathSync} from 'node:fs';
import {DatabaseSync} from 'node:sqlite';
import {join,dirname} from 'node:path';
import {once} from 'node:events';
@@ -47,7 +47,7 @@ function fakeSudo(K){
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
// as case2-unit.sh does. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// probe.sh, a second driver, and records its exit and stderr.
function fakeRender(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
@@ -64,7 +64,7 @@ function fakeRender(K){
const sh=(n,...x)=>cp.execFileSync('bash',[path.join(get('--home'),'src','mosaic-stack','scripts',n),...x],{stdio:['ignore','ignore','inherit']});
sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude'));
sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve);
const names=[`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service'];
const names=[`mosaic-bus@${get(pre.includes('--business')?'--business':'--org')}.service`,'mosaic-webui.service'];
if(pre.includes('--proxy')){
fs.writeFileSync(path.join(process.env.XDG_CONFIG_HOME,'systemd','user','mosaic-proxy.service'),`[Service]\nWorkingDirectory=${copies}/${copy}\nExecStart=${get('--node')} ${copies}/${copy}/packages/model-proxy/src/run.mjs\n`);
names.unshift('mosaic-proxy.service');
@@ -592,6 +592,108 @@ test('#385 R2-F1 controls: library args without --send and a factory args file p
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
// Rename r2, "Selector scopes and parser matrix": --org is the same option as --business before -- (the
// unit's flags) and after it (the serve flags). selArgs moves the business into each scope as given: the
// unit's selectors first, the serve flags' last, the other lines as useed wrote them.
const selArgs=(e,outer,serve)=>{
const l=e.argsText.trimEnd().split('\n'),cut=l.indexOf('--'),drop=(a)=>{a.splice(a.indexOf('--business'),2);return a};
return [...outer,...drop(l.slice(0,cut)),'--',...drop(l.slice(cut+1)),...serve].join('\n')+'\n';
};
test('rename r2: --org or --business names the business before and after --, read_args passes either spelling or both, matched, in either order',async(t)=>{
const e=await useed(t);
// A bundle that does not exist stops the run at its first step after read_args, with nothing stopped.
const gone=join(e.root,'no.bundle');
for(const [outer,serve] of [
[['--org','acme'],['--org','acme']],
[['--business','acme'],['--org','acme']],
[['--org','acme'],['--business','acme']],
[['--business','acme','--org','acme'],['--org','acme','--business','acme']],
[['--org','acme','--business','acme'],['--business','acme','--org','acme']],
]){
const why=`${outer.join(' ')} -- ${serve.join(' ')}`,text=selArgs(e,outer,serve);
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why);
assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why);
assert.equal(readFileSync(e.args,'utf8'),text,why);
}
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('rename r2: an upgrade from an args file that spells both selectors --org keeps every line but --copy\'s value, and the render gets them as written',async(t)=>{
const e=await useed(t);
e.argsText=selArgs(e,['--org','acme'],['--org','acme']);writeFileSync(e.args,e.argsText);
arm(e,'confirm.sh',`cp ${e.args} ${join(e.sd,'args-at-confirm')}\n`);
const r=await upgrade(e,C1);assert.equal(r.code,0,r.all);
assert.equal(r.out.split('\n').at(-2),`stack-upgrade: upgrade ok: ${C1} is live for acme`);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
assert.equal(readFileSync(join(e.sd,'args-at-confirm'),'utf8'),e.argsText);
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
assert.equal(readFileSync(e.args,'utf8'),after);
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
// The stack's user's steps get the business as a word, never a spelling.
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
assert.deepEqual(live(e),['active','active',C1,C1]);
});
test('rename r2: a bad selector in either scope, or scopes that name different businesses, refuse args-unreadable before anything stops',async(t)=>{
const e=await useed(t),f=e.args;
const l=e.argsText.trimEnd().split('\n'),pre=l.slice(0,l.indexOf('--')).filter((x,i,a)=>x!=='--business'&&a[i-1]!=='--business');
const before=(m)=>`${f} before --: ${m}`,after=(m)=>`${f} after --: ${m}`;
const cases=[
['unit: two spellings, two businesses',selArgs(e,['--business','acme','--org','beta'],['--business','acme']),before('--business acme and --org beta conflict')],
['unit: two businesses, the other order',selArgs(e,['--org','acme','--business','beta'],['--business','acme']),before('--org acme and --business beta conflict')],
['unit: --org twice',selArgs(e,['--org','acme','--org','acme'],['--business','acme']),before('--org given twice')],
['unit: --business twice',selArgs(e,['--business','acme','--business','acme'],['--business','acme']),before('--business given twice')],
['unit: --org at the end',[...pre,'--org'].join('\n')+'\n',before('--org needs one value')],
['unit: --org with an empty line',selArgs(e,['--org',''],['--business','acme']),before('--org needs one value')],
['unit: --org before a flag',selArgs(e,['--org'],['--business','acme']),before('--org needs one value, not --user')],
['unit: --business before --',[...pre,'--business','--','--port',e.port,'--business','acme'].join('\n')+'\n',before('--business needs one value, not --')],
['unit: an invalid business',selArgs(e,['--org','Acme'],['--org','Acme']),before('--org Acme is not valid')],
['unit: --org as --home\'s value is no selector',selArgs(e,['--business','acme'],['--business','acme']).replace(`--home\n${e.env.HOME}\n`,'--home\n--org\n'),`${f} lacks a plain --user, --home, --business, --node or --copy`],
['serve: two spellings, two businesses',selArgs(e,['--business','acme'],['--business','acme','--org','beta']),after('--business acme and --org beta conflict')],
['serve: two businesses, the other order',selArgs(e,['--business','acme'],['--org','beta','--business','acme']),after('--org beta and --business acme conflict')],
['serve: --org twice',selArgs(e,['--business','acme'],['--org','acme','--org','acme']),after('--org given twice')],
['serve: --business twice',selArgs(e,['--org','acme'],['--business','acme','--business','acme']),after('--business given twice')],
['serve: --org at the end',selArgs(e,['--business','acme'],['--org']),after('--org needs one value')],
['serve: --org before a flag',selArgs(e,['--business','acme'],['--org','--sso']),after('--org needs one value, not --sso')],
['serve: an invalid business',selArgs(e,['--business','acme'],['--org','acme_x']),after('--org acme_x is not valid')],
['serve: another business',selArgs(e,['--business','acme'],['--org','beta']),`${f} names business acme before -- and beta after it`],
['serve: another business, unit spelled --org',selArgs(e,['--org','acme'],['--business','beta']),`${f} names business acme before -- and beta after it`],
['serve: no selector',selArgs(e,['--business','acme'],[]),`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: --business as --allow-user\'s value is no selector',selArgs(e,['--business','acme'],['--allow-user','--business']),`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: no -- at all',[...pre,'--org','acme'].join('\n')+'\n',`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: a flag case2-unit.sh does not take',selArgs(e,['--business','acme'],['--business','acme','--tester']),`${f} has --tester after --, which case2-unit.sh does not take`],
];
for(const [why,text,msg] of cases){
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await upgrade(e,C1);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${msg} (nothing was stopped)`,why);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why);
assert.equal(readFileSync(e.args,'utf8'),text,why);assert.deepEqual(readdirSync(e.etc),['dev.args'],why);
}
// A rollback reads the same file the same way.
writeFileSync(e.args,cases[0][1]);
const rb=await drive(e,B,'rollback','--instance','dev','--to',B,...TO);assert.equal(rb.code,3,rb.all);
assert.equal(rb.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${cases[0][2]} (nothing was stopped)`);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log')));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('rename r2: a checkout without scripts/selector.sh refuses checkout-unreadable before reading the args file',async(t)=>{
const e=await useed(t),d=join(realpathSync(e.root),'lone');
mkdirSync(join(d,'scripts'),{recursive:true});copyFileSync(join(checkout(C1),'scripts','stack-upgrade.sh'),join(d,'scripts','stack-upgrade.sh'));
const r=spawnSync('bash',[join(d,'scripts','stack-upgrade.sh'),'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,...TO],{env:e.denv,encoding:'utf8'});
assert.equal(r.status,3,r.stdout+r.stderr);
assert.equal(r.stderr.split('\n').at(-2),`stack-upgrade: checkout-unreadable: cannot read ${d}/scripts/selector.sh (nothing was stopped)`);
assert.deepEqual(slog(e).map((x)=>x.split(' ')[0]),['start','result']);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e);
});
// R3-F1 (#385, PR 1836): a library host rendered by base case2-unit.sh has --send in its installed Console
// unit's ExecStart, not only in its args file. With the args line removed, A2 would stop both units and A4
// and render-old would both refuse the installed unit (check_unit's ExecStart and the full-text match), so
@@ -2,20 +2,33 @@
// messages API with FIXTURE_WORD for FIXTURE_MODEL, only on 127.0.0.1, only
// with the published fixture key, and forwards nothing anywhere.
// Run as a process: prints one JSON line {"ready":true,"port":N} and serves
// until SIGTERM/SIGINT or its lifetime ends.
// until SIGTERM/SIGINT or its lifetime (MOSAIC_FIXTURE_TTL_S) ends.
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { FIXTURE_KEY, FIXTURE_MODEL, FIXTURE_WORD } from './index.mjs';
export const HOST = '127.0.0.1';
const BODY_MAX = 1024 * 1024;
export const TTL_DEFAULT_S = 14400;
export const TTL_MIN_S = 60;
// The longest delay a Node timer holds; a longer one fires after 1 ms.
export const TTL_MAX_S = 2147483;
export function createEndpoint({ log = () => {} } = {}) {
// Seconds the process lives, from MOSAIC_FIXTURE_TTL_S. Unset, empty or not a number is the
// default; 0 is no expiry (`mosaic dev fixture init --ttl 0`, a named dev instance only);
// anything else is held to [TTL_MIN_S, TTL_MAX_S].
export function lifetimeSeconds(raw) {
const n = raw === undefined || raw === '' ? TTL_DEFAULT_S : Number(raw);
if (!Number.isFinite(n)) return TTL_DEFAULT_S;
return n === 0 ? 0 : Math.min(TTL_MAX_S, Math.max(TTL_MIN_S, n));
}
export function createEndpoint({ log = () => {}, ttlSeconds = null, expiresAt = null } = {}) {
const counts = { messages: 0, refused: 0 };
const server = createServer((req, res) => {
const reply = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
const error = (status, type, message) => { counts.refused++; log(`${req.method} ${req.url} ${status} ${type}`); reply(status, { type: 'error', error: { type, message } }); };
if (req.method === 'GET' && req.url === '/health') return reply(200, { ok: true, model: FIXTURE_MODEL, upstream: 'none', counts });
if (req.method === 'GET' && req.url === '/health') return reply(200, { ok: true, model: FIXTURE_MODEL, upstream: 'none', counts, ttlSeconds, expiresAt });
if (req.method !== 'POST' || !/^\/v1\/messages(\?|$)/.test(req.url ?? '')) return error(404, 'not_found_error', 'fixture serves POST /v1/messages only');
if (req.headers['x-api-key'] !== FIXTURE_KEY) return error(401, 'authentication_error', 'fixture accepts only the fixture key');
let size = 0; const chunks = [];
@@ -50,14 +63,24 @@ export function createEndpoint({ log = () => {} } = {}) {
};
}
async function main() {
const ttl = Number(process.env.MOSAIC_FIXTURE_TTL_S ?? 14400);
const endpoint = createEndpoint({ log: line => process.stderr.write(`${new Date().toISOString()} ${line}\n`) });
const { port } = await endpoint.listen(0);
process.stdout.write(`${JSON.stringify({ ready: true, port, pid: process.pid })}\n`);
const stop = async () => { await endpoint.close(); process.exit(0); };
process.on('SIGTERM', stop); process.on('SIGINT', stop);
// Listens on loopback and runs `expire` once ttlSeconds pass; ttlSeconds 0 never expires.
export async function serve({ ttlSeconds, log, expire }) {
const expiresAt = ttlSeconds ? new Date(Date.now() + ttlSeconds * 1000).toISOString() : null;
const endpoint = createEndpoint({ log, ttlSeconds, expiresAt });
const address = await endpoint.listen(0);
// A bounded lifetime, so an endpoint a crashed caller left behind still ends.
setTimeout(stop, Math.max(60, Number.isFinite(ttl) ? ttl : 14400) * 1000).unref();
const timer = ttlSeconds ? setTimeout(expire, ttlSeconds * 1000) : null;
timer?.unref();
return { endpoint, ...address, expiresAt, timer };
}
async function main() {
let endpoint;
// Expiry and SIGTERM end the process the same way, leaving any record its caller wrote.
const stop = async () => { await endpoint?.close(); process.exit(0); };
const served = await serve({ ttlSeconds: lifetimeSeconds(process.env.MOSAIC_FIXTURE_TTL_S), log: line => process.stderr.write(`${new Date().toISOString()} ${line}\n`), expire: stop });
endpoint = served.endpoint;
process.stdout.write(`${JSON.stringify({ ready: true, port: served.port, pid: process.pid })}\n`);
process.on('SIGTERM', stop); process.on('SIGINT', stop);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main().catch(e => { process.stderr.write(`fixture endpoint: ${e.message}\n`); process.exit(1); });
@@ -4,7 +4,7 @@ import { request } from 'node:http';
import { createLoader, hasModule } from '../src/loader.mjs';
import { selectModelProvider } from '../src/model-provider-selection.mjs';
import * as fixture from '../model-provider/fixture/index.mjs';
import { createEndpoint, HOST } from '../model-provider/fixture/endpoint.mjs';
import { createEndpoint, HOST, lifetimeSeconds, serve, TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../model-provider/fixture/endpoint.mjs';
const { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_MODEL, FIXTURE_WORD } = fixture;
@@ -82,3 +82,37 @@ test('the endpoint binds loopback, answers the fixed word and refuses a wrong ke
assert.equal((await call(baseUrl, { method: 'GET' })).status, 404);
assert.deepEqual(ep.counts, { messages: 2, refused: 4 });
});
// P19 #882: the lifetime an endpoint process takes from MOSAIC_FIXTURE_TTL_S.
test('the endpoint lifetime: a default, 0 for no expiry, and bounds a Node timer can hold', () => {
for (const raw of [undefined, '', 'soon']) assert.equal(lifetimeSeconds(raw), TTL_DEFAULT_S, String(raw));
assert.equal(TTL_DEFAULT_S, 14400);
assert.equal(lifetimeSeconds('0'), 0);
assert.equal(lifetimeSeconds('120'), 120);
for (const raw of ['5', '-3']) assert.equal(lifetimeSeconds(raw), TTL_MIN_S, raw);
// Past 2^31-1 ms a timer fires after 1 ms, so a longer lifetime is held to the longest one.
for (const raw of [String(TTL_MAX_S + 1), '1e12']) assert.equal(lifetimeSeconds(raw), TTL_MAX_S, raw);
assert.ok(TTL_MAX_S * 1000 <= 2 ** 31 - 1);
});
test('serve expires the endpoint when its lifetime ends, and never with a lifetime of 0', async t => {
const open = await serve({ ttlSeconds: 0, expire: () => assert.fail('a lifetime of 0 expired') });
t.after(() => open.endpoint.close());
assert.equal(open.timer, null);
const health = JSON.parse((await call(open.baseUrl, { method: 'GET', path: '/health' })).text);
assert.deepEqual([health.ok, health.ttlSeconds, health.expiresAt], [true, 0, null]);
t.mock.timers.enable({ apis: ['setTimeout'] });
let expired = 0;
const before = Date.now();
const timed = await serve({ ttlSeconds: 120, expire: () => { expired++; } });
t.after(() => timed.endpoint.close());
const at = Date.parse(timed.expiresAt) - before;
assert.ok(at >= 120000 && at < 130000, timed.expiresAt);
t.mock.timers.tick(119999);
assert.equal(expired, 0);
t.mock.timers.tick(1);
assert.equal(expired, 1);
t.mock.timers.tick(TTL_MAX_S * 1000);
assert.equal(expired, 1, 'a lifetime of 0 set no timer');
});
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { initProject, readSpec } from '../src/index.mjs';
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=fixture', '-c', '[email protected]', '-C', repo, ...args], { encoding: 'utf8' });
const bytes = (repo, rev, path) => git(repo, 'show', `${rev}:${path}`);
test('v1 metadata at each reachable committed revision is checked before reading', async t => {
const root = mkdtempSync(join(tmpdir(), 'revision-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const business = { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } };
const ctx = { business, project: 'stack', dataRoot: root };
const { repo, commit: initial } = await initProject(ctx);
const original = bytes(repo, initial, 'project.json');
const meta = JSON.parse(original);
assert.deepEqual({ ...meta, createdAt: '<time>' }, { layoutVersion: 1, business: 'acme', project: 'stack', name: 'stack', createdAt: '<time>' });
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/);
assert.equal(bytes(repo, initial, 'templates/project.json'), readFileSync(new URL('../templates/project.json', import.meta.url), 'utf8'));
const save = (metadata, content) => {
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
writeFileSync(join(repo, 'spec/north-star.md'), content);
git(repo, 'add', 'project.json', 'spec/north-star.md');
git(repo, 'commit', '-m', 'fixture revision');
return git(repo, 'rev-parse', 'HEAD').trim();
};
const valid = save({ ...meta, name: 'Second' }, '# Second\n');
const badOrg = save({ ...meta, business: 'other' }, '# Wrong org\n');
const badProject = save({ ...meta, project: 'other' }, '# Wrong project\n');
const badMix = save({ ...meta, organization: 'acme' }, '# Mixed\n');
const head = save(meta, '# Head\n');
for (const rev of [initial, valid, head]) assert.equal((await readSpec({ ...ctx, commit: rev })).revision, rev);
assert.equal((await readSpec({ ...ctx, commit: valid })).content, '# Second\n');
for (const rev of [badOrg, badProject, badMix]) await assert.rejects(readSpec({ ...ctx, commit: rev }), { code: 'project-invalid' });
writeFileSync(join(repo, 'project.json'), '{"business":"dirty"}');
assert.equal((await readSpec({ ...ctx, commit: initial })).revision, initial);
assert.equal(bytes(repo, initial, 'project.json'), original);
});
+9 -4
View File
@@ -23,6 +23,7 @@ import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { selectorScope } from '../../business/src/selector.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
@@ -31,8 +32,15 @@ try {
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
const devNames = [];
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
// or invalid value keeps the old wording.
const selector = selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw new Error(kind === 'invalid' || kind === 'flag' ? 'business must be a business id' : why); },
});
while (args.length) {
const flag = args.shift();
if (selector.owns(flag)) { selector.take(flag, args.shift()); continue; }
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
@@ -54,10 +62,6 @@ try {
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
options.checkout = value;
}
else if (flag === '--business') {
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
options.business = value;
}
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
else if (flag === '--public-host') preview.publicHost = value;
else if (flag === '--auth-header') preview.authHeader = value;
@@ -72,6 +76,7 @@ try {
} else if (flag === '--dev-allow-host') devNames.push(value);
else throw new Error(`unknown option: ${flag}`);
}
if (selector.given) options.business = selector.value;
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
+30 -6
View File
@@ -103,11 +103,32 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
const { Store } = await import('../../bus/src/store.mjs');
const { Broker } = await import('../../bus/src/broker.mjs');
const root=mkdtempSync(join(tmpdir(),'o-'));
t.after(()=>rmSync(root,{recursive:true,force:true}));
// P19 #875: t.after hooks run in registration order, so removing root came first, both
// `bus stop` calls then failed, and SIGTERM to the outer `bus start` reached neither the
// --internal-host process nor its broker; they outlived the file. One hook now stops the
// Console and both buses while root exists, fails if any recorded process survives, then
// removes root. Each process is recorded with its start time so a reused pid is not it.
const startOf=pid=>{ try { const raw=readFileSync(`/proc/${pid}/stat`,'utf8'), fields=raw.slice(raw.lastIndexOf(')')+2).split(' '); return fields[0]==='Z'||fields[0]==='X'?null:fields[19]; } catch { return null; } };
const tracked=new Map();
const track=pid=>{ const start=startOf(pid); if(!start||tracked.has(pid))return; tracked.set(pid,start); let kids=''; try { kids=readFileSync(`/proc/${pid}/task/${pid}/children`,'utf8'); } catch {} for(const kid of kids.split(' ').filter(Boolean))track(Number(kid)); };
const survivors=()=>[...tracked].filter(([pid,start])=>startOf(pid)===start).map(([pid])=>pid);
const hostOf=(out,org)=>JSON.parse(out.split('\n').find(line=>line.startsWith('{') && line.includes(`"org":"${org}"`))).pid;
let env, pid;
t.after(async()=>{
try {
if(pid){ try { process.kill(pid,'SIGTERM'); } catch {} }
if(env) for(const org of ['acme','beta']) spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop',org],{env,encoding:'utf8'});
for(let i=0;i<100 && survivors().length;i++) await new Promise(r=>setTimeout(r,100));
const left=survivors();
// Stop what survived so the failure is this assertion, not a leak into the next file.
for(const p of left){ try { process.kill(p,'SIGKILL'); } catch {} }
assert.deepEqual(left,[],`processes still running after teardown: ${left.join(' ')}`);
} finally { rmSync(root,{recursive:true,force:true}); }
});
const f=fixture(root); const home=join(root,'home'), configDir=join(home,'.config','mosaic-test');
mkdirSync(join(home,'.config'),{recursive:true}); renameSync(join(root,'config'),configDir); chmodSync(configDir, 0o700);
writeFileSync(join(f.dataRoot,'instance.json'), JSON.stringify({instanceVersion:1,name:'test'}));
const env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
const ctx={configDir,dataRoot:f.dataRoot,instance:'test',principal:DEV_OWNER};
const doc=structuredClone(f.doc); doc.human=DEV_OWNER.sub; doc.projects.stack.root='/__mosaic_org__/acme/projects/stack';
rmSync(join(configDir,'businesses','acme.json')); chmodSync(join(configDir,'businesses'),0o700);
@@ -128,13 +149,14 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
betaStore.close();
let betaOut=''; const betaBus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','beta','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
betaBus.stdout.on('data',c=>betaOut+=c); betaBus.stderr.on('data',c=>betaOut+=c);
t.after(()=>{ spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','beta'],{env,encoding:'utf8'}); if(betaBus.exitCode===null)betaBus.kill('SIGTERM'); });
track(betaBus.pid);
let bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
let busOut=''; bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
t.after(()=>{ spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); if(bus.exitCode===null)bus.kill('SIGTERM'); });
track(bus.pid);
const wait=async (fn) => { for(let i=0;i<300;i++){ const v=fn(); if(v)return v; await new Promise(r=>setTimeout(r,100)); } throw Error(`startup timeout: ${busOut}`); };
await wait(()=>busOut.includes('\"org\":\"acme\"'));
await wait(()=>betaOut.includes('\"org\":\"beta\"'));
track(hostOf(busOut,'acme')); track(hostOf(betaOut,'beta'));
const hostLog=()=>readFileSync(join(org.root,'bus-host','host.log'),'utf8');
assert.match(hostLog(),/DEV LOCAL OWNER MODE: console owner local-dev accepted \(named instance test\)/);
assert.match(hostLog(),/launches disabled/);
@@ -143,8 +165,8 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
const launch=spawnSync(process.execPath,['-e',`const {spawn}=require('node:child_process');const p=spawn(process.execPath,[require('node:path').resolve('packages/webui/src/cli.mjs'),'serve','--port','0','--board','http://127.0.0.1:9','--business','acme','--send','--dev-local-owner'],{detached:true,stdio:['ignore',1,2]});console.log('PID '+p.pid);p.unref();`],{env,stdio:['ignore',fd,fd]});
closeSync(fd); assert.equal(launch.status,0);
const output=()=>readFileSync(log,'utf8');
const pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
t.after(()=>{ try { process.kill(pid,'SIGTERM'); } catch {} });
pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
track(pid);
const port=Number((await wait(()=>/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(output())))[1]);
assert.match(output(),/DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner \(named instance test\)/);
const post=async (path,body,extra={})=>{
@@ -167,7 +189,9 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
const stop = spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); assert.equal(stop.status,0,stop.stderr); await stopped;
busOut=''; bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
track(bus.pid);
await wait(()=>busOut.includes('\"org\":\"acme\"'));
track(hostOf(busOut,'acme'));
const refused=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'});
assert.equal(refused.status,403,JSON.stringify(refused)); assert.equal(refused.body.error,'human-required');
});
@@ -0,0 +1,397 @@
{
"business": {
"id": "acme",
"human": "jason",
"arbiters": {
"delivery": "pm",
"technical": "cto"
},
"projects": [
"stack"
],
"actions": [
"task.create",
"project.bootstrap",
"task.assign",
"task.schedule",
"task.update.assigned",
"task.close",
"task.reassign",
"task.scope.change",
"task.priority.change",
"git.push.working",
"git.push.protected",
"git.merge.protected",
"git.push.remote",
"host.sudo",
"review.request",
"review.verdict",
"record.write",
"message.send",
"message.external",
"role.launch",
"role.revoke",
"role.end",
"organization.change",
"instance.name",
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
"data.delete",
"network.expose",
"account.create",
"policy.change",
"prd.approve",
"decision.resolve.technical",
"library.write"
],
"vars": {
"tracker.baseUrl": "http://127.0.0.1:3456",
"tracker.pollSeconds": 60,
"gitea.baseUrl": "https://git.example"
},
"instances": [
{
"instance": "pm",
"definition": "pm",
"holder": "sage",
"vars": {
"harness": "pi",
"model": "claude-opus-5-5"
},
"authority": {
"task.create": "within",
"project.bootstrap": "gated",
"task.assign": "within",
"task.schedule": "within",
"task.update.assigned": "within",
"task.close": "within",
"task.reassign": "within",
"task.scope.change": "cross",
"task.priority.change": "cross",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "cto",
"definition": "cto",
"holder": "darkwing",
"vars": {
"harness": "claude-code"
},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "cross",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "within",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "within",
"library.write": "gated"
},
"refused": null
},
{
"instance": "coder",
"definition": "coder",
"holder": null,
"vars": {
"harness": "pi",
"limits.network": "none"
},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "cross",
"task.priority.change": "gated",
"git.push.working": "within",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "within",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "reviewer",
"definition": "reviewer",
"holder": null,
"vars": {},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "gated",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "within",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "chief",
"definition": "chief-of-staff",
"holder": null,
"vars": {},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "gated",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "gated",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "within",
"role.revoke": "gated",
"role.end": "within",
"organization.change": "within",
"instance.name": "within",
"role.hire": "within",
"role.fire": "within",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
}
],
"launch": {
"by": "chief",
"instances": [
"coder",
"reviewer"
],
"models": {
"claude-opus-5-5": {
"provider": "zai",
"model": "claude-opus-5-5"
},
"claude-sonnet-5-5": {
"provider": "zai",
"model": "claude-sonnet-5-5"
},
"glm-5.3-flash": {
"provider": "zai",
"model": "glm-5.3-flash"
}
},
"limits": {
"models": {
"zai": {
"claude-opus-5-5": 2,
"claude-sonnet-5-5": 4
}
}
},
"threadIdleSeconds": 900
},
"credentials": [
{
"service": "gitea",
"account": null,
"role": "pm",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-pm",
"role": "pm",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "cto",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-cto",
"role": "cto",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "coder",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-coder",
"role": "coder",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "reviewer",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-reviewer",
"role": "reviewer",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-sync",
"role": "tracker sync",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
}
],
"appearance": null
},
"at": "2026-10-10T12:00:00.000Z"
}
@@ -0,0 +1,67 @@
// The organization selector on `cli.mjs serve` (rename design r2, "Selector scopes and parser
// matrix"): --org and --business are one option in serve's scope. serve.mjs and bus.mjs are
// stand-ins loaded through a hook, so nothing listens and no bus is called; the bus stand-in
// records the organization the Console would bind.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
function serve(t, args) {
const root = tmp(t, 'sel-'), f = fixture(root), log = join(root, 'bound.jsonl'), hook = join(root, 'hooks.mjs');
writeFileSync(log, '');
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(){return {address:()=>({port:0}),close:()=>{}};}";
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({business:o.business})+'\\n');return async()=>[];};export const organizationConsole=()=>{throw Error('organizationConsole outside dev-owner')};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
return next(url,context);
}});`);
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...args], { env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
return { ...r, bound: readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).business) };
}
const accepted = (r, name) => {
assert.equal(r.status, 0, `${name}: ${r.stderr}`);
assert.deepEqual(r.bound, ['acme'], name);
assert.match(r.stdout, /^Bus: acme$/m, name);
};
const refused = (r, pattern, name) => {
assert.equal(r.status, 2, `${name}: ${r.stdout}${r.stderr}`);
assert.match(r.stderr, pattern, name);
assert.deepEqual(r.bound, [], `${name} bound nothing`);
};
test('serve takes --org or --business, alone or matching in either order', t => {
for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) accepted(serve(t, args), args.join(' '));
});
test('serve refuses unequal, repeated, missing, flag-shaped and invalid selectors before any read', t => {
for (const [args, pattern] of [
[['--business', 'acme', '--org', 'beta'], /^refused: --business acme and --org beta conflict/],
[['--org', 'acme', '--business', 'beta'], /^refused: --org acme and --business beta conflict/],
[['--business', 'acme', '--business', 'acme'], /^refused: --business given twice/],
[['--org', 'acme', '--org', 'acme'], /^refused: --org given twice/],
[['--org'], /^refused: --org needs one value/],
[['--business'], /^refused: --business needs one value/],
[['--org', ''], /^refused: --org needs one value/],
[['--org', '--send'], /^refused: business must be a business id/],
[['--business', '-x'], /^refused: business must be a business id/],
[['--org', 'Acme'], /^refused: business must be a business id/],
[['--org', 'acme/../x'], /^refused: business must be a business id/],
]) refused(serve(t, args), pattern, args.join(' '));
});
test('a selector spelling taken as another option\'s value is that option\'s text', t => {
const r = serve(t, ['--board', '--org', '--business', 'acme']);
accepted(r, 'board');
assert.match(r.stdout, /^Board: --org$/m);
// Without a selector the Console keeps the running host's business.
const none = serve(t, []);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(none.bound, [null]);
});
+4 -1
View File
@@ -30,10 +30,13 @@ function tmp(t, prefix) {
// A canonical checkout (a .git directory) on branch refactor, with this
// tree's files, the uncommitted ones included, and a RELEASE of its own.
// A deploy tree links node_modules; .gitignore's node_modules/ matches only a
// directory, so the link is listed as an untracked file. The copy never takes
// node_modules from git (npm ci makes it), so the fixture leaves the link out.
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation', ':(exclude,glob)**/node_modules'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, REPO, REPO_ROLES, writeJson } from '../../business/tests/helpers.mjs';
import { consoleReads } from '../src/reads.mjs';
import { startServer } from '../src/serve.mjs';
import { humanCall, consoleCall } from '../src/bus.mjs';
const fixed = Date.parse('2026-10-10T12:00:00.000Z');
const golden = new URL('./fixtures/rename-a/business-response.json', import.meta.url);
test('v1 /api/business response preserves its complete JSON shape and redaction', async t => {
const root = mkdtempSync(join(tmpdir(), 'wire-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
const doc = businessDoc(root);
writeJson(join(configDir, 'businesses/acme.json'), doc);
const system = { configVersion: 1, environment: 'development', dataRoot, execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash', adapter: 'mock' } };
const reads = consoleReads({ root: REPO, configDir, rolesDir: REPO_ROLES, system, business: 'acme', now: () => fixed });
const server = await startServer({ port: 0, reads });
t.after(() => server.close());
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/business`);
assert.equal(response.status, 200);
const body = await response.text();
assert.doesNotMatch(body, /placeholder-not-a-token|secret|"file"|"botId"/);
assert.deepEqual(JSON.parse(body), JSON.parse(readFileSync(golden, 'utf8')));
});
test('v1 Console human and owner reads send their exact business-bound IPC envelopes', async t => {
const root = mkdtempSync(join(tmpdir(), 'ipc-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const socket = join(root, 's');
const received = [];
const server = createServer(conn => {
let input = '';
conn.on('data', chunk => {
input += chunk;
if (!input.includes('\n')) return;
received.push(JSON.parse(input));
conn.end('{"ok":true,"result":{"answer":"fixture"}}\n');
});
});
await new Promise((resolve, reject) => server.listen(socket, resolve).once('error', reject));
t.after(() => server.close());
const env = { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR };
const human = humanCall({ socket, business: 'acme', env });
assert.deepEqual(await human('instances', {}), { answer: 'fixture' });
assert.deepEqual(await human('limits', {}), { answer: 'fixture' });
const owner = consoleCall({ socket, dataRoot: root, business: 'acme', env });
assert.deepEqual(await owner('request.draft.console', { request: 'r1' }, { subject: { provider: 'local', iss: 'fixture', sub: 'owner' } }), { answer: 'fixture' });
for (const [i, verb] of ['instances', 'limits'].entries()) {
const { human: proof, ...rest } = received[i];
assert.deepEqual(rest, { verb, args: {} });
assert.deepEqual(Object.keys(proof), ['business', 'pid', 'startTime', 'nonce']);
assert.equal(proof.business, 'acme');
assert.ok(Number.isInteger(proof.pid));
assert.match(String(proof.startTime), /^\d+$/);
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
}
const { console: proof, ...rest } = received[2];
assert.deepEqual(rest, { verb: 'request.draft.console', args: { request: 'r1' } });
assert.deepEqual(Object.keys(proof), ['business', 'provider', 'iss', 'sub', 'pid', 'startTime', 'nonce']);
assert.deepEqual([proof.business, proof.provider, proof.iss, proof.sub], ['acme', 'local', 'fixture', 'owner']);
assert.ok(Number.isInteger(proof.pid));
assert.match(String(proof.startTime), /^\d+$/);
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
});
+46
View File
@@ -258,6 +258,52 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
}
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one selector in
// the script's flags, and the unit always carries the canonical --business.
test('render takes --org or --business, alone or matching in either order, and writes --business', async (t) => {
const dir = tmp(t);
const want = `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332 --business acme`;
for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`);
assert.equal(execStart(r.stdout), want, args.join(' '));
}
// A selector spelling another flag consumes is that flag's value, so the board check refuses it.
const board = await run(dir, ['render', '--board', '--org', '--business', 'acme']);
assert.equal(board.status, 1);
assert.match(board.stderr, /board/);
});
test('render, install, uninstall and status refuse a bad selector before anything is read or written', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const usageText = (await run(dir, [])).stderr;
for (const [args, status, stderr] of [
[['--business', 'acme', '--org', 'beta'], 4, 'webui-service: --business acme and --org beta conflict\n'],
[['--org', 'acme', '--business', 'beta'], 4, 'webui-service: --org acme and --business beta conflict\n'],
[['--business', 'acme', '--business', 'acme'], 4, 'webui-service: --business given twice\n'],
[['--org', 'acme', '--org', 'acme'], 4, 'webui-service: --org given twice\n'],
[['--org'], 4, usageText],
[['--business'], 4, usageText],
[['--org', ''], 4, usageText],
[['--org', '--send'], 1, 'webui-service: business must be a business id: --send\n'],
[['--business', '-x'], 1, 'webui-service: business must be a business id: -x\n'],
[['--org', 'Acme'], 1, 'webui-service: business must be a business id: Acme\n'],
[['--org', 'a'.repeat(65)], 1, `webui-service: business must be a business id: ${'a'.repeat(65)}\n`],
[['--org', 'acme/../x'], 1, 'webui-service: business must be a business id: acme/../x\n'],
]) {
for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload'], ['uninstall', '--dir', unitDir, '--no-reload'], ['status', '--dir', unitDir]]) {
const r = await run(dir, [...verb, ...args]);
const label = `${verb[0]} ${args.join(' ')}`;
assert.equal(r.status, status, `${label}: ${r.stderr}`);
assert.equal(r.stdout, '', label);
assert.equal(r.stderr, stderr, label);
}
}
assert.equal(existsSync(unitDir), false, 'nothing was installed');
assert.equal(existsSync(join(dir, '.quiet', 'calls.txt')), false, 'systemctl was never called');
});
test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => {
const dir = tmp(t);
const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8');
+4 -1
View File
@@ -30,7 +30,10 @@ test('tester-port validates range tunnel main port and requires configured bus b
assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/);
});
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
// P19 #876: about 20 Console reads and chat calls in a row, each one TESTER_CLI round trip of two
// node processes (the shim re-executes itself to carry its nonce). That is about 3 s idle and
// 10 s at load 20 on 16 cores; 30 s left too little room on a busy machine, so the guard is 60 s.
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 60000 }, async t => {
const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl');
await new Promise(r=>f.web.close(r));
const hook = join(f.root, 'spawn-hook.mjs');
+20 -4
View File
@@ -32,6 +32,12 @@
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
# --root misused). A P check never changes the exit.
#
# --org ID is the same option as --business ID, in three scopes: this script's own arguments, the args
# file's unit arguments before --, and its serve arguments after it. In each scope a spelling may appear
# once, and the other spelling must carry the same value; the args file needs a selector in both of its
# scopes, and all three must name the same business. The talk and precheck calls still pass --business,
# which every copy takes.
#
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
@@ -39,10 +45,15 @@ set -uo pipefail
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
# selector.sh defines functions only, from this script's own root-owned checkout; parsing runs no program.
case "${BASH_SOURCE[0]}" in */*) SELECTOR_SH="${BASH_SOURCE[0]%/*}/selector.sh" ;; *) SELECTOR_SH=./selector.sh ;; esac
. "$SELECTOR_SH" || refuse "cannot read $SELECTOR_SH"
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
invocation_refused() { case "$1" in missing|flag|invalid) usage ;; *) refuse "$2" ;; esac; }
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) selector_take invocation id invocation_refused business_id_ok "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
@@ -67,14 +78,18 @@ st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
unit_refused() { refuse "$ARGS_FILE before --: $2"; }
serve_refused() { refuse "$ARGS_FILE after --: $2"; }
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_SOCKET=0
copies=0 past=""
copies=0 past="" served=""
for ((i = 0; i < ${#ARGS[@]}; i++)); do
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
if [ -n "$past" ]; then
case "$a" in
--port) PORT="$v"; i=$((i + 1)) ;;
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
--business|--org)
selector_take serve id serve_refused business_id_ok "$a" "$v"
[ "$v" = "$BUSINESS" ] || refuse "serve business differs"; served=1; i=$((i + 1)) ;;
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
*) refuse "unknown serve flag $a" ;;
@@ -86,7 +101,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
--pm) ;;
--user) S_USER="$v"; i=$((i + 1)) ;;
--home) S_HOME="$v"; i=$((i + 1)) ;;
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
--business|--org) selector_take unit id unit_refused business_id_ok "$a" "$v"; A_BUSINESS="$v"; i=$((i + 1)) ;;
--node) NODE="$v"; i=$((i + 1)) ;;
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
--profile) PROFILE="$v"; i=$((i + 1)) ;;
@@ -95,6 +110,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
esac
done
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
[ -n "$served" ] || refuse "$ARGS_FILE needs --business or --org after --"
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
+25 -2
View File
@@ -5,6 +5,10 @@
# case2-install.sh --business ID --business-file FILE --commit SHA
# [--profile factory|library] [--personas DIR] [--home DIR]
#
# --org ID is the same option as --business ID, and --org-file FILE the same
# as --business-file FILE. They are two families: each spelling once, and a
# second spelling of a family must carry the same text.
#
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
# SHA, whose npm ci installs the pinned harness; for library, the persona
# import's install from the staging DIR; the business file at 0600; for
@@ -39,11 +43,30 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh" || { echo "case2-install: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
# The selectors (rename design r2): the id family and the file family are separate scopes. A
# missing value is a usage error; a flag-shaped or invalid id is the business-id refusal below; a
# flag-shaped file, a repeated spelling and a conflict say why. All exit 4, as before.
id_refused() {
case "$1" in
missing) usage ;;
flag|invalid) echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
*) echo "case2-install: $2" >&2; exit 4 ;;
esac
}
file_refused() {
case "$1" in
missing) usage ;;
*) echo "case2-install: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; }
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
--business|--org) BUSINESS="${2-}"; selector_take id id id_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--business-file|--org-file) selector_take file file file_refused - "$1" "${@:2:1}"; BFILE="$2"; shift 2 ;;
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
+15 -1
View File
@@ -5,6 +5,7 @@
// case2-persona-import.mjs stage --hermes-root DIR --pair PROFILE=NAME --pair PROFILE=NAME
// (--staging DIR | --dry-run)
// case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID
// (--org ID is the same option)
//
// stage runs as whoever can read her Hermes home. Per profile it reads SOUL.md, the persona Hermes
// loads (agent/prompt_builder.py:1829-1855 at df5700ebe), and config.yaml for the per-channel prompt
@@ -31,6 +32,7 @@ import { createHash } from "node:crypto";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { PERSONA_CHARS, personaTextFault } from "../packages/harness/src/bundle.mjs";
import { selectorScope } from "../packages/business/src/selector.mjs";
// The tool names registered in hermes-agent's tools/*.py at df5700ebe (literal `name="..."` in
// registry.register calls; the MCP and plugin tools get their names at run time), plus Hermes and
@@ -67,9 +69,21 @@ class Usage extends Error {}
function args(argv) {
const out = { pairs: [] };
// --org and --business are one selector in these options (rename design r2). A missing value or
// one starting "--" is a usage error, as before; an id outside this script's grammar is its
// refusal, now while parsing; a repeated or conflicting selector is a usage error.
let given;
const selector = selectorScope({
valid: (id) => BUSINESS_ID.test(id),
refuse: (kind, why) => {
if (kind === "invalid" || (kind === "flag" && !given.startsWith("--"))) throw new Refusal(`business ${given} isn't a business id`);
throw new Usage(why);
},
});
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === "--dry-run") { out.dryRun = true; continue; }
if (selector.owns(flag)) { given = argv[i + 1]; selector.take(flag, given); i++; continue; }
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) throw new Usage(`${flag} needs a value`);
i++;
@@ -77,9 +91,9 @@ function args(argv) {
else if (flag === "--hermes-root") out.hermesRoot = value;
else if (flag === "--staging") out.staging = value;
else if (flag === "--config-dir") out.configDir = value;
else if (flag === "--business") out.business = value;
else throw new Usage(`unknown option ${flag}`);
}
if (selector.given) out.business = selector.value;
return out;
}
+16 -1
View File
@@ -7,6 +7,8 @@
# case2-precheck.sh --business ID [--profile factory|library]
# case2-precheck.sh --business ID --library-status
#
# --org ID is the same option as --business ID.
#
# Checks: the config and business file validate; `sudo -n true` fails and
# `id -G` prints one group; bus.tester is off for library and on for factory,
# the explicit notifier config is valid at 0600 in a private 0700 directory;
@@ -53,11 +55,24 @@ WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service"
MARKER_TOKEN_CEILING=2000
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status' >&2; exit 4; }
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh" || { echo "case2-precheck: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
# --org and --business are one selector here (rename design r2). A missing value is a usage error,
# a flag-shaped or invalid one the business-id refusal below, and a repeated or conflicting one
# says why; all exit 4, as before.
selector_refused() {
case "$1" in
missing) usage ;;
flag|invalid) echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
*) echo "case2-precheck: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
PROFILE=library PROFILE_SET=0 BUSINESS='' STATUS=0
while [ $# -gt 0 ]; do
case "$1" in
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; PROFILE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) BUSINESS="${2-}"; selector_take args id selector_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--library-status) STATUS=1; shift ;;
*) usage ;;
esac
+43 -6
View File
@@ -9,6 +9,12 @@
# [--profile factory|library] [--proxy] [--pm] [--claude PATH]
# -- SERVE FLAGS
#
# --org ID is the same option as --business ID (rename design r2), in each of
# two scopes: this script's own flags before --, and the serve flags after it.
# Each scope takes each spelling once, and a second spelling the same value;
# nothing is counted across --. The serve flags must name the selector, it must
# be the unit's business, and the unit renders it as --business ID.
#
# Root runs it from a root-owned checkout of the reviewed commit outside
# <home>, never from the release, because <user> can write the release. It
# writes the bus and Console units (and the proxy unit under --proxy) under
@@ -77,14 +83,35 @@ abs_plain() {
[ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1"
}
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh"
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
# The outer selector: a missing value is a usage error, a flag-shaped or invalid one the
# business-id refusal, and a repeated or conflicting one a usage error that says why.
outer_refused() {
case "$1" in
missing) usage ;;
flag|invalid) die "--business must be a business id: $BUSINESS" ;;
*) echo "case2-unit: $2" >&2; exit 4 ;;
esac
}
# The serve selector: every refusal names the serve flag, as the other serve flags do.
serve_refused() {
case "$1" in
missing) die "serve flag $SERVE_NAME needs a value" ;;
flag|invalid) die "serve $SERVE_NAME $SERVE_VALUE is not the unit's business $BUSINESS" ;;
*) die "serve $2" ;;
esac
}
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0
while [ $# -gt 0 ]; do
case "$1" in
--profile|--user|--home|--business|--copy|--node|--claude)
--business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--profile|--user|--home|--copy|--node|--claude)
[ $# -ge 2 ] || usage
case "$1" in
--profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;;
--business) BUSINESS="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
--copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
--claude) CLAUDE="$2"; CLAUDE_SET=1 ;;
esac
shift 2 ;;
@@ -346,7 +373,7 @@ tester_location() {
serve_flags() {
local -A seen=()
local flags='' name value
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0 selected=0
local i=0 n=${#SERVE[@]}
while [ "$i" -lt "$n" ]; do
name="${SERVE[$i]}"
@@ -357,7 +384,7 @@ serve_flags() {
--board) die "refusing --board: Case 2 runs no control board" ;;
--sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
--send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
--port|--business) ;;
--port|--business|--org) ;;
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
*) die "unknown serve flag: $name" ;;
@@ -369,10 +396,20 @@ serve_flags() {
if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
[ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
value="${SERVE[$((i + 1))]}"
if [ "$name" = --business ] || [ "$name" = --org ]; then
# One selector in the serve scope, bound to the unit's business, rendered once
# as --business where it first appears.
SERVE_NAME="$name" SERVE_VALUE="$value"
selector_take serve id serve_refused business_id_ok "$name" "$value"
[ "$value" = "$BUSINESS" ] || die "serve $name $value is not the unit's business $BUSINESS"
[ "$selected" = 1 ] || flags="$flags --business $value"
selected=1
i=$((i + 2))
continue
fi
case "$name" in
--port) port="$value"
[[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;;
--business) [ "$value" = "$BUSINESS" ] || die "serve --business $value is not the unit's business $BUSINESS" ;;
--public-host) public_host="$value"
case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac
case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;;
@@ -392,7 +429,7 @@ serve_flags() {
local required=(--port --business)
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
for name in "${required[@]}"; do
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
[ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required"
done
if [ "$PROFILE" = factory ]; then
# #415: only the paired owner sign-in and writes. No preview header or
+8 -1
View File
@@ -6,6 +6,10 @@
# [--subject TEXT] [--attribution LINE] [--business ID]
# [--project ID]
#
# --org ID is --business ID (rename r2): each spelling once, both only with one value. A missing,
# empty or flag-shaped value exits 4 before the config is read. The id keeps its old grammar: any
# other text names businesses/<ID>.json, and a missing file exits 3.
#
# Checks, in order; each refusal names its reason on stderr and exits 2:
# bus-schema the bus database's schema isn't the Store's
# decision-not-found no decision with that id and one raise in the business
@@ -48,6 +52,9 @@ REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
usage() { echo "usage: land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT] [--attribution LINE] [--business ID] [--project ID]" >&2; exit 4; }
fail() { echo "land-work: $1" >&2; exit "${2:-1}"; }
refuse() { echo "land-work: refused: $1${2:+: $2}" >&2; exit 2; }
# shellcheck source=scripts/selector.sh
. "$SCRIPT_DIR/selector.sh" || fail "cannot read $SCRIPT_DIR/selector.sh" 4
lw_selector_refused() { fail "$2" 4; }
DECISION=""; BASE="refactor"; DRY_RUN="no"; SUBJECT=""; ATTRIBUTION=""
BUSINESS=""; PROJECT=""
@@ -57,7 +64,7 @@ while [ $# -gt 0 ]; do
--dry-run) DRY_RUN="yes"; shift ;;
--subject) [ $# -ge 2 ] || usage; SUBJECT="$2"; shift 2 ;;
--attribution) [ $# -ge 2 ] || usage; ATTRIBUTION="$2"; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) selector_take args id lw_selector_refused - "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
--project) [ $# -ge 2 ] || usage; PROJECT="$2"; shift 2 ;;
-*) usage ;;
*) [ -z "$DECISION" ] || usage; DECISION="$1"; shift ;;
+52
View File
@@ -0,0 +1,52 @@
# Sourced, not run: organization selector options, the shell half of
# packages/business/src/selector.mjs (rename design r2, "Selector scopes and parser matrix"). A
# script in the same checkout sources it; it defines functions only and runs no other program.
#
# --org and --business are one option family (id), and --org-file and --business-file are another
# (file). A scope is one parser's own options, so the caller's own loop decides which tokens are
# selectors; text after a separator or a value another option consumes is never passed here. In a
# scope each spelling may appear once, and a second spelling must carry exactly the same value.
#
# selector_take SCOPE FAMILY REFUSE VALID FLAG [VALUE]
# SCOPE [a-z0-9_]+, the scope's name; each keeps its own spellings and value
# FAMILY id or file
# REFUSE a function called as REFUSE KIND MESSAGE that must exit; KIND is missing, flag,
# repeated, invalid or differ, so each script keeps its exit codes and wording
# VALID a predicate called as VALID VALUE, or - when the caller checks the value itself
# FLAG the spelling given
# VALUE absent when the argument list ended there; pass "${@:2:1}" from a loop on "$@"
# selector_value SCOPE prints the scope's value, nothing when none was given
#
# A missing or empty value, one starting with "-", one VALID refuses, a repeated spelling and two
# spellings with different values all refuse. File values are compared as given, not as paths.
selector__bug() { echo "selector.sh: $*" >&2; exit 70; }
selector_take() {
[ $# -ge 5 ] && [ $# -le 6 ] || selector__bug "selector_take takes 5 or 6 arguments, not $#"
local scope="$1" family="$2" refuse="$3" valid="$4" flag="$5"
[[ "$scope" =~ ^[a-z0-9_]+$ ]] || selector__bug "bad scope name $scope"
case "$family:$flag" in
id:--business|id:--org|file:--business-file|file:--org-file) ;;
*) selector__bug "$flag is not a $family selector" ;;
esac
local seen_name="SELECTOR_SEEN_$scope" value_name="SELECTOR_VALUE_$scope"
local seen="${!seen_name-}" current="${!value_name-}" first
case " $seen " in *" $flag "*) "$refuse" repeated "$flag given twice"; selector__bug "$refuse returned" ;; esac
if [ $# -lt 6 ] || [ -z "$6" ]; then "$refuse" missing "$flag needs one value"; selector__bug "$refuse returned"; fi
local value="$6"
case "$value" in -*) "$refuse" flag "$flag needs one value, not $value"; selector__bug "$refuse returned" ;; esac
if [ "$valid" != - ] && ! "$valid" "$value"; then "$refuse" invalid "$flag $value is not valid"; selector__bug "$refuse returned"; fi
first="${seen%% *}"
if [ -n "$seen" ] && [ "$current" != "$value" ]; then
"$refuse" differ "$first $current and $flag $value conflict"; selector__bug "$refuse returned"
fi
printf -v "$seen_name" '%s' "${seen:+$seen }$flag"
printf -v "$value_name" '%s' "$value"
}
selector_value() {
[[ "${1-}" =~ ^[a-z0-9_]+$ ]] || selector__bug "bad scope name ${1-}"
local value_name="SELECTOR_VALUE_$1"
printf '%s' "${!value_name-}"
}
+6 -1
View File
@@ -3,6 +3,7 @@
# copy creates files only; wrapper is a separately Mos-gated live step.
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
# check takes --org as --business (rename r2): each spelling once, and both only with one value.
# Row 232: check --units external reads root's system units. copy --bundle makes the copy from a git
# bundle on a host with no checkout, and wrapper --create writes the first wrapper on a new host.
set -euo pipefail
@@ -15,10 +16,13 @@ MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | copy --bundle FILE SHA [--copies DIR] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] [--create] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] [--units user|external]' >&2; exit "${1:-4}"; }
die() { echo "stack-copy: $*" >&2; exit 1; }
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
# Selector refusals: a bad shape is a usage error (4); an invalid id exits 1, as check's own test does.
copy_selector_refused() { case "$1" in invalid) die "$2" ;; *) echo "stack-copy: $2" >&2; exit 4 ;; esac; }
copy_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
parse() { while [ $# -gt 0 ]; do case "$1" in
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--business|--org) [ "$CMD" = check ] || usage; selector_take check id copy_selector_refused copy_business_ok "$1" "${@:2:1}"; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--allow-worktree) [ "$CMD" = copy ] && [ -z "$BUNDLE" ] || usage; ALLOW_WORKTREE=1;shift;;
--units) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; case "$2" in user|external) UNITS="$2";; *) usage;; esac; shift 2;;
--create) [ "$CMD" = wrapper ] || usage; CREATE=1;shift;;
@@ -179,6 +183,7 @@ check() {
}
# Sourced (stack-release.sh): define the functions, run nothing.
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
. "$REPO/scripts/selector.sh" || die "cannot read $REPO/scripts/selector.sh"
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
+36 -7
View File
@@ -12,10 +12,13 @@
# Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
# which I2's install writes: one case2-unit.sh argument per line, the serve flags after `--`. Only
# --copy changes, and the file changes only after confirm passes. The stack's user runs its steps
# from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's output
# but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its step
# log after a match against a fixed pattern and decides nothing on.
# --copy changes, and the file changes only after confirm passes. --org is the same option as
# --business in each of the file's two scopes, case2-unit.sh's own flags before `--` and the serve
# flags after it: each scope takes each spelling once, a second spelling only with the same value,
# and both scopes must name the business, the same one (rename design r2). The stack's user runs
# its steps from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's
# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its
# step log after a match against a fixed pattern and decides nothing on.
#
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
@@ -119,6 +122,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# --- The args file and the stack's user ---
# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is.
. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh"
args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; }
args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; }
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived
# from them.
@@ -126,8 +135,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# --profile wins, as there), and the current one refuses it. A2 would stop both units, and A4 and
# render-old would both fail on the same args, so a library file holding --send after -- refuses
# here, before anything stops. The file is never rewritten; the operator removes the line.
# Rename r2: case2-unit.sh also refuses a file whose serve flags name no selector or another
# business, so that refuses here too. The serve flags are read with case2-unit.sh's grammar, which
# says which of them take a value, so a value is never taken for a selector; a token that grammar
# lacks refuses, as the render would.
read_args() {
local f="$ETC/$INSTANCE.args" st i copies=0 v profile=library
local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
@@ -138,10 +151,13 @@ read_args() {
--) break ;;
--pm) ;;
--proxy) ARGS_PROXY=1 ;;
--user|--home|--business|--copy|--node|--profile|--claude)
--business|--org)
selector_take outer id args_outer_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"
BUSINESS="${ARGS[i + 1]}"; i=$((i + 1)) ;;
--user|--home|--copy|--node|--profile|--claude)
v="${ARGS[i + 1]:-}"
case "${ARGS[i]}" in
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --business) BUSINESS="$v" ;; --node) NODE="$v" ;;
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --node) NODE="$v" ;;
--copy) ARGS_COPY="$v"; copies=$((copies + 1)) ;;
--profile) profile="$v" ;;
esac
@@ -149,6 +165,7 @@ read_args() {
*) refuse args-unreadable "$f has ${ARGS[i]} before --, which case2-unit.sh does not take" ;;
esac
done
sep=$i
[ "$copies" = 1 ] || refuse args-unreadable "$f needs exactly one --copy before --, not $copies"
ARGS_PROFILE="$profile"
if [ "$profile" = library ]; then
@@ -160,6 +177,18 @@ read_args() {
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$ARGS_COPY" =~ ^[0-9a-f]{40}$ ]] ||
refuse args-unreadable "$f lacks a plain --user, --home, --business, --node or --copy"
for ((i = sep + 1; i < ${#ARGS[@]}; i++)); do
case "${ARGS[i]}" in
--business|--org)
selector_take serve id args_serve_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"; i=$((i + 1)) ;;
--port|--public-host|--auth-header|--allow-user|--proof-header|--proof-file) i=$((i + 1)) ;;
--tester-socket|--sso|--send) ;;
*) refuse args-unreadable "$f has ${ARGS[i]} after --, which case2-unit.sh does not take" ;;
esac
done
served="$(selector_value serve)"
[ -n "$served" ] || refuse args-unreadable "$f needs --business or --org after --, as case2-unit.sh does"
[ "$served" = "$BUSINESS" ] || refuse args-unreadable "$f names business $BUSINESS before -- and $served after it"
COPIES_DIR="$S_HOME/.local/share/mosaic-stack/copies"
CLONE="$S_HOME/src/mosaic-stack"
SR="$CLONE/scripts/stack-release.sh"
+40
View File
@@ -242,6 +242,46 @@ cp "$SANDBOX/c/args/businesses/fx.json" "$SANDBOX/c/nobus/businesses/"
sed "s#$SANDBOX/c/args/data#$SANDBOX/c/nobus/data#" "$SANDBOX/c/args/config.json" > "$SANDBOX/c/nobus/config.json"
expect nobus 3 "" "$D"
# ---- rename r2: --org is --business, each spelling once ----
# Against a missing config: exit 4 and the message mean the parse refused before any read (3 otherwise).
sel() { # want-rc want-stderr args...
local want="$1" text="$2" rc=0 err
shift 2
err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$@" 2>&1 >/dev/null)" || rc=$?
check "selector: $* exits $want: $text (got $rc: $err)" "$([ "$rc" -eq "$want" ] && [ "$err" = "$text" ]; echo $?)"
}
sel 4 "land-work: --business fx and --org other conflict" "$D" --business fx --org other
sel 4 "land-work: --org other and --business fx conflict" "$D" --org other --business fx
sel 4 "land-work: --business given twice" "$D" --business fx --business fx
sel 4 "land-work: --org given twice" "$D" --org fx --org fx
sel 4 "land-work: --org given twice" "$D" --org fx --business fx --org fx
sel 4 "land-work: --org needs one value" "$D" --org
sel 4 "land-work: --business needs one value" "$D" --business ""
sel 4 "land-work: --org needs one value, not --dry-run" "$D" --org --dry-run
sel 4 "land-work: --business needs one value, not -fx" "$D" --business -fx
USAGE="$(bash scripts/land-work.sh 2>&1)"
sel 4 "$USAGE" --org fx
sel 4 "$USAGE" "$D" --org fx extra
# A value another option takes is text, not a selector: the config read is what refuses.
rc=0; err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" --subject --org 2>&1 >/dev/null)" || rc=$?
check "selector: --subject --org is a subject, and the config read refuses (got $rc)" \
"$([ "$rc" -eq 3 ] && [ "${err##*$'\n'}" = "land-work: system config is missing or invalid" ]; echo $?)"
# Each spelling alone and both together name the business file read.
# land-work never had an id grammar and keeps none, so text that is not an id is a missing file too.
for args in "--business other" "--org other" "--org other --business other" "--org Other" "--business ../other"; do
# shellcheck disable=SC2086
expect args 3 "" "$D" $args
check "selector: $args names businesses/${args##* }.json" \
"$([ "$(tail -n1 "$SANDBOX/c/args/err")" = "land-work: no business file: $SANDBOX/c/args/businesses/${args##* }.json" ]; echo $?)"
done
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --business fx --org fx
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx --business fx
setup dry-org ok 42 "$W_OK"
expect dry-org 0 "" "$D" --dry-run --org fx
grep -q "no ref was written" "$SANDBOX/c/dry-org/out"
check "selector: --org fx runs the dry run" "$?"
# ---- the bus database ----
setup schema-table ok 42 "$W_OK"
node -e 'const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]); db.exec("CREATE TABLE extra(x)"); db.close()' \
+14 -1
View File
@@ -83,6 +83,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then
@@ -99,6 +101,17 @@ USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-workt
die() { echo "webui-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
# --org and --business are one selector in these flags (rename design r2). A missing value is a
# usage error and a flag-shaped or invalid one the business-id refusal serve_flags makes, worded
# as before; a repeated or conflicting selector is a usage error that says why.
selector_refused() {
case "$1" in
missing) usage ;;
flag|invalid) die "business must be a business id: $BUSINESS" ;;
*) echo "webui-service: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
@@ -135,7 +148,7 @@ parse_flags() {
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
--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 ;;
--business|--org) BUSINESS="${2-}"; selector_take flags id selector_refused business_id_ok "$1" "${@:2:1}"; 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 ;;
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;