docs(slice1): row 36 S1 round 2 packet (darkwing)
Answers Filbert's round 1 (#1518 comment 26724): launch.by must hold role.launch, launch is null when limits.authority drops it, a test for cross-role narrowing, and the business file checked on its open descriptor. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
2e4df0820d431eda19b07d264a55c713cc1cbf7e55cf8a1f648150161f995620 docs/TOOLS.md
|
||||
8d83d88c68813eea5fb1bd0577736bf89cfc5166f582aca290635d4f0170e477 packages/business/examples/mosaic-stack.example.json
|
||||
0e15a5be600573ff7fa0b41c425ee6e57dad788be3d42991185c3a84d6090fe9 packages/business/package.json
|
||||
e54cee19b9188def68180ced4955b1d3d6ebe719e0da6f975f3b5dbd7239161e packages/business/README.md
|
||||
3aa6f74149773dd78d0251b6a1d4d91cb2c05f7113b5aac2a649157525cb0728 packages/business/src/business.mjs
|
||||
afba0aa4b751bc079adbded8798c788b669d65c970047f78a182ff01e8c32db9 packages/business/src/cli.mjs
|
||||
0231361f3063ee841749b2486ebbaa3a521b5687b3e0c1abc0499e7df38c49fa packages/business/src/credentials.mjs
|
||||
cd4eb5960c17ce32f26d1a49196866ce04efc42c1d90b18b046af5fda8b9ff0a packages/business/src/errors.mjs
|
||||
956dfb37868b3dd002bd8245995c416cf35dbdf9f82b154a951eb19db334d584 packages/business/src/index.mjs
|
||||
9472996134c520c31971e66006d1c1683709f6df4f76fe3445bdc97a18637eff packages/business/src/project.mjs
|
||||
c50340af650aaa2d0eb15224e823525ba942a91f475ed62bd7e7ad42055f182b packages/business/src/resolve.mjs
|
||||
bfd331d97fe165b2b70512725892b27491c54347ba05233fb4c67c7c1a77e802 packages/business/src/role.mjs
|
||||
e1f0e70a4bbba8ae2ad6185ab61770b15acda403e90fc708e13027a1e9ec663c packages/business/src/util.mjs
|
||||
b831cac0224cfb5cd32e482ad7fd4cbea9e16c02d2750d5e8a757785a7c56589 packages/business/src/vars.mjs
|
||||
1fa6df30bd52b032c065839efcde022456cdc4e2b735fb73884fd842e1794743 packages/business/src/vocabulary.mjs
|
||||
79dce4db9eed72d925ca6af2d13e9e346a50fd52d53527cf475fbb1d445857ac packages/business/tests/business.test.mjs
|
||||
69280b0b8390dacf899bab8f56580edcc0cb8ef8275395cc0abe7351180c2eb2 packages/business/tests/cli.test.mjs
|
||||
820286995fd2f2f258303e6a3563f16afd64c47345e4c61e963878f9fd3794e2 packages/business/tests/credentials.test.mjs
|
||||
1ea0617318f4a2246ea96d7b44fea1b278cbb0ab2e46b3deb8db11d30bb2ef19 packages/business/tests/helpers.mjs
|
||||
6aa211be6f5067d3247acf45df2f415221c36ca37720b72dbfe2726d690b3d55 packages/business/tests/project.test.mjs
|
||||
d4f6e8e72925231464ebb5f7dedfa0db8d3c2d31ac46295f4abce1eac30253c4 packages/business/tests/resolve.test.mjs
|
||||
21fb723ccd2f8a8e734b527df0402cb08252b3e195022e78dcdafdda386704aa packages/business/tests/role.test.mjs
|
||||
6e5a57fa22d67ba1a94c91fd4ea338814fc5cace8394b2607a9b33cda3777c9a packages/business/tests/vars.test.mjs
|
||||
739bd95b73f3ada2438b7cbba745471efcab4feb949fcf79673f1c80c6edff7f roles/coder.json
|
||||
191e44e3a9868ef8b615db747d6684f32baa8ceb424e4e42c1f7a04668ac3288 roles/coder.md
|
||||
e394bf806220f5c91344f83bc1f8b18b9b23db4fc05092b88de7651ca4b9d8d1 roles/cto.json
|
||||
0b2ccc0efe5681fe5b06d37a80a0756c2a05daee2ccb8efe148cbd908a664c29 roles/cto.md
|
||||
6abb3d3fa80e0e753e7dbc73dfb2e599ad06223ae4211e221b1357996abb6046 roles/pm.json
|
||||
f27a0809d95e4a1d9b4b69ac9d612a237bb81f4c5fcd16d889c03ea3cf07e15a roles/pm.md
|
||||
268b93056579d1c64c59f66da3c4fb33e3357871f6a30a584229dd25725e32e1 roles/reviewer.json
|
||||
8492a681245b5b72c6249b9672ea5cb4752d7defccf70263ff9ba1415f4e9a3b roles/reviewer.md
|
||||
a0002e8b1f3a723dcbb62c1a3f4d512065bbf89f0771881c42192df4bdbc7756 scripts/mosaic
|
||||
ceea32f56dc607bf684baa6de4f260ae56ad70bbecc5510bf7a226d4269b6f6d scripts/mosaic-task.mjs
|
||||
ef8b105fca02283c73b616f1884ce3ae9a0cae3b8530d55584cea416d4d14b9f scripts/test-task.sh
|
||||
@@ -0,0 +1,101 @@
|
||||
# Row 36, S1, round 2
|
||||
|
||||
Darkwing, 2026-10-04. Answers Filbert's round 1 review (#1518 comment
|
||||
26724, record `agents/filbert/work/slice1-s1-review/review-r1.md`). Base is
|
||||
still `fef4b362`. Nothing in the candidate is committed, staged or pushed.
|
||||
|
||||
`build-r2.patch` (sha256
|
||||
`a27890d5c70c5dd38d6b98ce4badfff6334c1cc0c485dcee7c90b1e6438e8a15`) replaces
|
||||
round 1's `build.patch`. It covers the same 34 files.
|
||||
`build-r2-manifest.sha256` (sha256
|
||||
`869168c702c272b051bc957a4a4314df962553ac432a5c2c5210f5ba1291afc7`) pins
|
||||
them. Six files changed from round 1, and `r1-to-r2.diff` shows only
|
||||
those. In a fresh clone at `fef4b362` the patch applies, the manifest
|
||||
checks 34/34, and the business tests pass 60/60. Round 1's `build.md`
|
||||
still describes everything else.
|
||||
|
||||
## B1, `launch` and `role.launch`
|
||||
|
||||
- `checkLaunch` refuses a `launch.by` whose definition doesn't hold
|
||||
`role.launch` within-role. `launch.by: "cto"` now refuses with "launch.by
|
||||
names cto, whose role cto doesn't hold role.launch within-role".
|
||||
- `resolveInstance` sets `launch` only when the instance is `launch.by`
|
||||
and `role.launch` is still within-role after `limits.authority`.
|
||||
Otherwise `launch` is null and `role.launch` is removed from both lists.
|
||||
`launch` is set exactly when `classify(record, "role.launch")` is
|
||||
`within`. The README says so, and still tells a launcher to ask
|
||||
`classify` (your n4).
|
||||
- Tests:
|
||||
- the cto refusal, in `business.test.mjs`;
|
||||
- `limits.authority` without `role.launch` for pm, at the agent layer
|
||||
and at the project layer: `launch` is null and the verb is gated;
|
||||
- a second pm-definition instance, `pm2`, as the launcher: `pm` is gated
|
||||
with `launch` null, and `pm2` is within with `launch` set. This
|
||||
replaces round 1's `launch.by: "cto"` case, which now refuses at
|
||||
validation.
|
||||
|
||||
## B2, cross-role narrowing
|
||||
|
||||
New test: coder with `limits.authority` of `task.update.assigned`,
|
||||
`git.push.working` and `task.scope.change`. `task.reassign` (cross-role in
|
||||
the definition, left out of the allowlist) classifies as `gated`,
|
||||
`task.scope.change` stays `cross`, and `review.request` is `gated`. Your
|
||||
M3 is now caught (R3 below).
|
||||
|
||||
## n1, the business file check
|
||||
|
||||
I fixed this too, since it was in a file I was already changing.
|
||||
`readJsonFile` now opens the file once with `O_NOFOLLOW | O_NONBLOCK`, runs
|
||||
`fstat` on that descriptor, and reads from the same descriptor. The `lstat`
|
||||
is gone, so there's no second lookup to race. A caller can pass a
|
||||
`checkStat` callback, which sees the descriptor's stat before the read.
|
||||
`loadBusiness` uses it for the owner and mode checks. Role and project
|
||||
files use the same reader without a callback. The error messages and exit
|
||||
codes are unchanged:
|
||||
- missing: "not found", 4;
|
||||
- a symbolic link (`ELOOP`) or anything that isn't a regular file: "must
|
||||
be a regular, non-symbolic-link file", 4.
|
||||
|
||||
`O_NONBLOCK` keeps a FIFO from blocking the open. Tests:
|
||||
- A group-writable file with invalid JSON refuses for its mode (660), not
|
||||
for its JSON. That shows the check runs before the read.
|
||||
- A directory and a FIFO under the business name both refuse with exit 4.
|
||||
The FIFO case runs in a child process with a 5 s timeout, because an
|
||||
`openSync` that blocks would hang the test process past node:test's own
|
||||
timeout.
|
||||
|
||||
n2: left as is, as you suggested.
|
||||
|
||||
## Tests and suites
|
||||
|
||||
Node 26.8.1 on the host, output teed (`suites-r2.txt`):
|
||||
- `node --test packages/business/tests/`: 60/60, up from 57. The new
|
||||
tests are in `resolve.test.mjs` (2) and `business.test.mjs` (1), and three
|
||||
existing tests gained cases.
|
||||
- `scripts/test-config.sh` 24/24, `test-task.sh` 98/98,
|
||||
`test-conductor.sh` 17/17, `test-queue.sh` 27/27.
|
||||
|
||||
Node 24.21.0 in `node:24`, no network, the clone mounted read-only:
|
||||
`node --test "packages/business/tests/*.test.mjs"` 60/60.
|
||||
|
||||
Mutants (`mutants-r2.sh`, results in `mutants-r2.txt`), each against the
|
||||
business tests, all caught:
|
||||
|
||||
| Mutant | Failures |
|
||||
|---|---|
|
||||
| R1 `launch.by` role check off | 1 |
|
||||
| R2 `launch` ignores `limits.authority` | 1 |
|
||||
| R3 your M3: crossRole not narrowed | 1 |
|
||||
| R4 owner and mode check skipped | 1 |
|
||||
| R5 owner and mode check after the parse | 1 |
|
||||
| R6 `O_NOFOLLOW` dropped | 2 |
|
||||
| R7 a non-regular file accepted | 1 |
|
||||
| R8 `O_NONBLOCK` dropped | 1 |
|
||||
|
||||
## For S2
|
||||
|
||||
Two API changes reach Rocko's S2, both narrowing:
|
||||
- A business file whose `launch.by` lacks `role.launch` now refuses.
|
||||
- `launch` can be null for the `by` instance when `limits.authority`
|
||||
drops the verb.
|
||||
No function names, argument shapes or record keys changed.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Round 2 mutants: each edits one source file in a copy, runs the business tests, restores.
|
||||
cd ~/darkwing-scratch/s1-mut
|
||||
export TMPDIR=~/darkwing-scratch/tmp
|
||||
run() { # name file python-replace-old python-replace-new
|
||||
local name=$1 file=$2
|
||||
cp "$file" "$file.orig"
|
||||
python3 -c "import sys;p=sys.argv[1];s=open(p).read();o=sys.argv[2];n=sys.argv[3];assert o in s,'pattern missing';open(p,'w').write(s.replace(o,n,1))" "$file" "$3" "$4" || { echo "$name: PATTERN MISSING"; mv "$file.orig" "$file"; return; }
|
||||
local fails
|
||||
fails=$(node --test packages/business/tests/ 2>&1 | sed -n 's/^ℹ fail //p')
|
||||
mv "$file.orig" "$file"
|
||||
if [ "${fails:-0}" -gt 0 ]; then echo "$name: killed ($fails)"; else echo "$name: SURVIVED"; fi
|
||||
}
|
||||
run R1-launcher-role-check-off packages/business/src/business.mjs 'if (!launcher.authority.withinRole.includes("role.launch")) {' 'if (false) {'
|
||||
run R2-launch-ignores-narrowing packages/business/src/resolve.mjs ' && withinRole.includes("role.launch") ?' ' ?'
|
||||
run R3-filbert-M3-crossRole-not-narrowed packages/business/src/resolve.mjs ' crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));' ''
|
||||
run R4-stat-check-skipped packages/business/src/util.mjs ' checkStat?.(stat);' ''
|
||||
run R5-stat-check-after-parse packages/business/src/util.mjs $' checkStat?.(stat);\n text = readFileSync(fd, "utf8");\n } finally {\n closeSync(fd);\n }\n try {\n return JSON.parse(text);' $' text = readFileSync(fd, "utf8");\n } finally {\n closeSync(fd);\n }\n try {\n const parsed = JSON.parse(text); checkStat?.(fstatSync(openSync(file, "r"))); return parsed;'
|
||||
run R6-follows-links packages/business/src/util.mjs ' | constants.O_NOFOLLOW' ''
|
||||
run R7-not-regular-accepted packages/business/src/util.mjs ' if (!stat.isFile()) refuse' ' if (false) refuse'
|
||||
run R8-fifo-blocks packages/business/src/util.mjs ' | constants.O_NONBLOCK' ''
|
||||
@@ -0,0 +1,8 @@
|
||||
R1-launcher-role-check-off: killed (1)
|
||||
R2-launch-ignores-narrowing: killed (1)
|
||||
R3-filbert-M3-crossRole-not-narrowed: killed (1)
|
||||
R4-stat-check-skipped: killed (1)
|
||||
R5-stat-check-after-parse: killed (1)
|
||||
R6-follows-links: killed (2)
|
||||
R7-not-regular-accepted: killed (1)
|
||||
R8-fifo-blocks: killed (1)
|
||||
@@ -0,0 +1,248 @@
|
||||
--- r1/packages/business/README.md
|
||||
+++ r2/packages/business/README.md
|
||||
@@ -69,7 +69,9 @@
|
||||
it to `<configDir>/businesses/mosaic-stack.json`, put in the bot ids and
|
||||
token dates from `docs/guides/slice-1-identities.md`, fix the token paths
|
||||
and run `validate`. The file must belong to you and must not be writable
|
||||
-by group or other.
|
||||
+by group or other. The loader checks the owner and mode on the descriptor
|
||||
+it reads from, so the file can't be swapped between the check and the
|
||||
+read.
|
||||
|
||||
Each role instance names a `definition` (a version 2 role file), an
|
||||
optional `holder`, its Vikunja bot (`tracker`, required when the definition
|
||||
@@ -87,9 +89,10 @@
|
||||
`rotateBy`, prints a warning.
|
||||
|
||||
`launch` lets one instance (`by`) start the listed instances, up to `max`
|
||||
-sessions per model family (Opus 4, Sonnet 4 at most). Only that instance
|
||||
-keeps `role.launch` as within-role. Every other instance loses it, so for
|
||||
-them it's gated.
|
||||
+sessions per model family (Opus 4, Sonnet 4 at most). `by` must name an
|
||||
+instance whose definition holds `role.launch` within-role; otherwise the
|
||||
+file refuses. Only that instance keeps `role.launch`. Every other instance
|
||||
+loses it, so for them it's gated.
|
||||
|
||||
### Project file
|
||||
|
||||
@@ -143,7 +146,10 @@
|
||||
`authority.crossRole`), `credentials` (references only), `tracker` (`bot`,
|
||||
`botId` or null), `launch` (the launch block, or null) and `digest`, the
|
||||
SHA-256 of the record's canonical JSON. `classify` refuses an action
|
||||
-outside the vocabulary.
|
||||
+outside the vocabulary. `launch` is set exactly when `classify(record,
|
||||
+"role.launch")` is `within`. If `limits.authority` leaves out
|
||||
+`role.launch` for the launcher, `launch` is null. A launcher should
|
||||
+still ask `classify`, not read `launch` alone.
|
||||
|
||||
Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required
|
||||
file missing). The CLI adds 3 for a system config problem and 4 for usage.
|
||||
--- r1/packages/business/src/business.mjs
|
||||
+++ r2/packages/business/src/business.mjs
|
||||
@@ -3,7 +3,6 @@
|
||||
// it and never writes it. A missing or invalid file refuses (lead decision
|
||||
// 46, 6.1).
|
||||
|
||||
-import { lstatSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { refuse } from "./errors.mjs";
|
||||
@@ -112,12 +111,16 @@
|
||||
return { roles: out, definitions };
|
||||
}
|
||||
|
||||
-function checkLaunch(launch, roles, file) {
|
||||
+function checkLaunch(launch, roles, definitions, file) {
|
||||
const where = `${file} launch`;
|
||||
requireObject(launch, where);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "max"], where);
|
||||
requireId(launch.by, `${where}.by`);
|
||||
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
||||
+ const launcher = definitions[roles[launch.by].definition];
|
||||
+ if (!launcher.authority.withinRole.includes("role.launch")) {
|
||||
+ refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`);
|
||||
+ }
|
||||
const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => {
|
||||
requireId(name, `${where}.instances entry`);
|
||||
if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`);
|
||||
@@ -180,7 +183,7 @@
|
||||
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
|
||||
}
|
||||
}
|
||||
- const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, file);
|
||||
+ const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions,
|
||||
@@ -193,9 +196,9 @@
|
||||
export function loadBusiness(id, { dir = configDir(), rolesDir }) {
|
||||
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
||||
const file = businessFilePath(id, dir);
|
||||
- const document = readJsonFile(file, "business file");
|
||||
- const stat = lstatSync(file);
|
||||
- if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
||||
- if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
+ const document = readJsonFile(file, "business file", (stat) => {
|
||||
+ if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
||||
+ if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
+ });
|
||||
return validateBusinessDocument(document, file, { rolesDir });
|
||||
}
|
||||
--- r1/packages/business/src/resolve.mjs
|
||||
+++ r2/packages/business/src/resolve.mjs
|
||||
@@ -68,8 +68,10 @@
|
||||
crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));
|
||||
}
|
||||
// role.launch needs the business file's launch block naming this
|
||||
- // instance (addendum A section 8). Without it the verb is gated.
|
||||
- const launch = business.launch && business.launch.by === instance ? business.launch : null;
|
||||
+ // instance (addendum A section 8), and the verb must survive
|
||||
+ // limits.authority. Otherwise the verb is gated and `launch` is null,
|
||||
+ // so the two never disagree.
|
||||
+ const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null;
|
||||
if (!launch) {
|
||||
withinRole = withinRole.filter((a) => a !== "role.launch");
|
||||
crossRole = crossRole.filter((a) => a !== "role.launch");
|
||||
--- r1/packages/business/src/util.mjs
|
||||
+++ r2/packages/business/src/util.mjs
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { lstatSync, readFileSync } from "node:fs";
|
||||
+import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ID_PATTERN } from "./vocabulary.mjs";
|
||||
@@ -59,17 +59,29 @@
|
||||
}
|
||||
|
||||
// Read a JSON file that must be a regular file, not a symbolic link.
|
||||
-// Missing or not a regular file is 4; unparseable is 2.
|
||||
-export function readJsonFile(file, what) {
|
||||
- let stat;
|
||||
+// Missing or not a regular file is 4; unparseable is 2. One descriptor,
|
||||
+// opened without following a link, serves every check and the read.
|
||||
+// `checkStat` sees that descriptor's stat before the read, so the file
|
||||
+// can't be swapped between the check and the read.
|
||||
+export function readJsonFile(file, what, checkStat) {
|
||||
+ let fd;
|
||||
try {
|
||||
- stat = lstatSync(file);
|
||||
- } catch {
|
||||
- refuse(`${what} not found: ${file}`, 4);
|
||||
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
+ } catch (error) {
|
||||
+ if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
|
||||
+ refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
+ }
|
||||
+ let text;
|
||||
+ try {
|
||||
+ const stat = fstatSync(fd);
|
||||
+ if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
+ checkStat?.(stat);
|
||||
+ text = readFileSync(fd, "utf8");
|
||||
+ } finally {
|
||||
+ closeSync(fd);
|
||||
}
|
||||
- if (!stat.isFile() || stat.isSymbolicLink()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
try {
|
||||
- return JSON.parse(readFileSync(file, "utf8"));
|
||||
+ return JSON.parse(text);
|
||||
} catch (error) {
|
||||
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
--- r1/packages/business/tests/business.test.mjs
|
||||
+++ r2/packages/business/tests/business.test.mjs
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
-import { chmodSync, lstatSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
+import { execFileSync, spawnSync } from "node:child_process";
|
||||
+import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
+import { pathToFileURL } from "node:url";
|
||||
import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
@@ -118,6 +120,7 @@
|
||||
test("launch", () => {
|
||||
assert.equal(check((d) => { delete d.launch; }).launch, null);
|
||||
refuses(() => check((d) => { d.launch.by = "ghost"; }), /launch\.by names ghost/);
|
||||
+ refuses(() => check((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), /launch\.by names cto, whose role cto doesn't hold role\.launch within-role/);
|
||||
refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/);
|
||||
@@ -152,9 +155,31 @@
|
||||
|
||||
writeFileSync(join(dir, "businesses", "broken.json"), "{ not json");
|
||||
refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /not valid JSON/);
|
||||
+ // The owner and mode checks run on the opened file before the read, so
|
||||
+ // a group-writable file refuses for its mode even when it won't parse.
|
||||
+ chmodSync(join(dir, "businesses", "broken.json"), 0o660);
|
||||
+ refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 660\)/);
|
||||
assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/);
|
||||
});
|
||||
|
||||
+// A directory or a FIFO under the business name refuses without a read.
|
||||
+// Opening a FIFO without O_NONBLOCK would block the whole process, which
|
||||
+// node:test can't time out, so that case runs in a child with a timeout.
|
||||
+test("loadBusiness: not a regular file", () => {
|
||||
+ const dir = join(tmp(), "config");
|
||||
+ mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true });
|
||||
+ refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4);
|
||||
+ execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]);
|
||||
+ const index = pathToFileURL(join(REPO, "packages", "business", "src", "index.mjs")).href;
|
||||
+ const child = spawnSync(process.execPath, ["--input-type=module", "-e", `
|
||||
+ import { loadBusiness } from ${JSON.stringify(index)};
|
||||
+ try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); }
|
||||
+ catch (error) { console.log(error.exitCode, error.message); }
|
||||
+ `], { encoding: "utf8", timeout: 5000 });
|
||||
+ assert.equal(child.signal, null, "opening the FIFO waited for a writer");
|
||||
+ assert.match(child.stdout, /^4 .*must be a regular, non-symbolic-link file/);
|
||||
+});
|
||||
+
|
||||
test("loading writes nothing", () => {
|
||||
const root = tmp();
|
||||
const dir = join(root, "config");
|
||||
--- r1/packages/business/tests/resolve.test.mjs
|
||||
+++ r2/packages/business/tests/resolve.test.mjs
|
||||
@@ -97,8 +97,39 @@
|
||||
const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm");
|
||||
assert.equal(classify(noLaunch, "role.launch"), "gated");
|
||||
assert.equal(noLaunch.launch, null);
|
||||
- const otherLauncher = resolve(setup((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), "pm");
|
||||
- assert.equal(classify(otherLauncher, "role.launch"), "gated");
|
||||
+
|
||||
+ // A second pm instance is the launcher, so the first one may not launch.
|
||||
+ const two = setup((d) => {
|
||||
+ d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } };
|
||||
+ d.launch.by = "pm2";
|
||||
+ });
|
||||
+ assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated");
|
||||
+ assert.equal(resolve(two, "pm").launch, null);
|
||||
+ assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within");
|
||||
+ assert.equal(resolve(two, "pm2").launch.by, "pm2");
|
||||
+});
|
||||
+
|
||||
+test("limits.authority without role.launch leaves the launcher with no launch block", () => {
|
||||
+ for (const layer of ["agent", "project"]) {
|
||||
+ const authority = ["task.create", "task.assign", "message.send"];
|
||||
+ const s = layer === "agent"
|
||||
+ ? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; })
|
||||
+ : setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } });
|
||||
+ const pm = resolve(s, "pm");
|
||||
+ assert.equal(classify(pm, "role.launch"), "gated", layer);
|
||||
+ assert.equal(pm.launch, null, layer);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("limits.authority narrows cross-role actions too", () => {
|
||||
+ const s = setup((d) => {
|
||||
+ d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"];
|
||||
+ });
|
||||
+ const coder = resolve(s, "coder");
|
||||
+ assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] });
|
||||
+ assert.equal(classify(coder, "task.reassign"), "gated");
|
||||
+ assert.equal(classify(coder, "task.scope.change"), "cross");
|
||||
+ assert.equal(classify(coder, "review.request"), "gated");
|
||||
});
|
||||
|
||||
test("classify", () => {
|
||||
@@ -0,0 +1,7 @@
|
||||
v26.8.1
|
||||
business exit 0
|
||||
test-config exit 0
|
||||
test-task exit 0
|
||||
test-conductor exit 0
|
||||
test-queue exit 0
|
||||
node24 exit 0
|
||||
Reference in New Issue
Block a user