merge: combine row 219 with landed row 295

This commit is contained in:
Rocko committed 2026-10-23 05:12:02 -05:00
commit 054da642f9
112 files changed
+13616 -46

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
# Row 219 / C2-09a: one thread per creative instance
Assignment sage-2793, base aca16404c; branch rocko/row219, own worktree
/mnt/storage/scratch/rocko-r219. Claim waits for Sage's gap. No Node, tests,
HTTPS git or push while Gander's row253 gate holds the slot.
Read both C2-09a and C2-09b briefs at aca16404c and 51cc0445c: the two
sections are byte-identical. The newer brief changes C2-06's reply limits
under Q222, not C2-09a. This row introduces no M5 limit, writer or setting.
Row265 rooms chains on this row and stays separate.
## Implementation plan
Broker: replace assistant-only #threaded with THREAD_DEFINITIONS membership.
thread.create gains optional instance, validates it as a declared thread
instance, and binds both the thread and default session to it. Omitted
instance retains single-instance fallback and ambiguous/missing refusals.
User grants must still cover the selected instance and project before
project lookup; an explicit selector cannot bypass FED R3. Existing tester
ownership and per-thread/per-session holder checks remain.
Launcher: import shared THREAD_DEFINITIONS and replace its two assistant
checks (check and ending), preserving launch admission, quotas, session
isolation and stop policy. No runtime.mjs, bundle or library adapter change;
Della row224 was notified of these narrow launcher hunks.
Console: actual shipped new-thread UI is in public/chat.js, with transport
mapping in chat.mjs and HTTP validation in chat-http.mjs, not public/bus.js.
These three files need Sage's scope addition before their edits. Proposed
metadata read GET /api/chat/instances in serve.mjs uses the existing
bus.instances read, filters using shared THREAD_DEFINITIONS, and returns
only {instance,name}, with vars['persona.name'] or instance as the label.
It adds no channel operation or bus verb; send still traverses createChat
and the existing channel dispatch. The new select sends optional instance
through chat-http validation and chat.mjs. It is disabled when metadata
cannot be read, so the UI never silently chooses between agents.
## Acceptance evidence to build (runs await CLEAR)
Bus: explicit creative selection binds the requested thread and default
session; omitted selector preserves assistant compatibility, missing and
ambiguous cases. Unknown/non-thread/malformed selectors refuse without
new threads; user grants cover only the selected instance, with prior
non-disclosure order intact. Real creative role definition messaging still
holds. A creative thread claims/receives independently of its peer.
CLI: real fixture launcher starts a creative thread on pending input and
keeps peer thread/session stores separate. Direct non-thread launch of a
creative instance refuses thread-required. Existing assistant tests remain.
WebUI: metadata lists thread definitions only with persona/name labels;
selection survives refresh and reaches thread.create exactly. Malformed
instance fails before a write, old omitted instance still works. Browser
asserts two creative options, inert hostile labels and read-only disablement.
Keep all identity/origin/content/SSO fences. Named mutants cover each new
acceptance guard; no runs until Sage's announced gap/CLEAR.
Before review: grep source readers/hash pins, update only pins required by
owned changes, rebase onto row253 landing under a new branch, then rerun
touched files on the new tree. No schema, role-policy, live or canonical
product-path change. Handoff directly to Dewey, Mos and Sage when ready.
## Scope ruling and picker design before UI edits
Sage-2807 adds public/chat.js, chat.mjs and chat-http.mjs because the shipped
form is chat.js:55/235, its forwarding is chat.mjs:23, and POST validation is
chat-http.mjs:80-82. bus.js stays byte-identical. The new route sits after
existing auth/session checks and uses startServer's own bus, so the tester
socket's separately supplied testerBus is used too (Dewey U1). No new bus or
channel operation. The route projects only instance/name and keeps the
THREAD_DEFINITIONS filter server-side, rather than making the browser read
all /api/bus/instances vars (Dewey route note).
Dewey-0849 U2-U5: multiple choices start with a required placeholder; exactly
one starts chosen and is sent explicitly; none disables Create and says no
thread instances are configured. Failed metadata reads disable Create with
"Couldn't read the thread instances; reload to try again" and recover on a
good read. Labels show persona name plus instance key when different. Picks
live in form(key); a removed pick is cleared with a status and never moved
to another option, even if only one remains. Repaints retain selection.
U6 is deferred: thread-list/header still show their existing instance keys.
The shared sign-out/redirect handling and strict HTTP write key validation
remain in force. Add unauthenticated metadata and selected-instance user
grant refusal tests as Sage requires.
## Existing fixture adaptations (unrun)
C2-08 creative.test's setup previously bound creative roles without threads
and sent human input with message.send. This row makes those roles threaded:
setup now selects assistant explicitly, creates each creative instance's
thread and binds its claim there; the human uses thread.send. Existing
M6/messaging refusal and count assertions stay in place. The user-principal
fixture gains optional extra threadRoles for the selected-grant regression.
The browser fixture now answers /api/chat/instances with its one assistant.
Its existing create receipt assertion gains instance:'assistant', since the
new picker explicitly sends its single preselected choice. No existing
assertion is dropped. New tests cover multiple/empty/failed metadata,
duplicate hostile labels, selected-instance POST, refresh and disappearing
choices. No Node or test has been run under Gander's hold.
The existing launcher.test C2-08 persona-mismatch regression cannot keep
requesting a standalone creative launch: C2-09a now correctly refuses it
thread-required before bundle construction. Its trigger is therefore the
thread pass, with an injected host threadsState snapshot (the persona
mismatch occurs before any bind). The exact mismatch reason, no persona
content leak, one refusal and no running session assertions remain. The
old per-run `['session']` directory assertion changes to `[]` and gains an
assertion for the thread-owned session directory, matching row121's actual
thread-store layout. A new e.thread assertion pins the path. This is a
fixture/expected-layout adaptation, not a product exception for that test.
## Old-test approval: mos-1316
Mos approved the three adaptations in OLD-TEST-CHANGES.md (sha256
9a1e51e6b43040593a3dd517c4a0c71f5ac42b6b57d66d474cd0517f06e97ed6)
and old-persona-test.diff (sha256
6aaca9820b692ec659058e9b044f7f60872cdecb16fe3ac1321c2c0ed530701d).
The source commit must name mos-1316 and carry OLD-TEST-CHANGES.md.
Dewey checks each changed line against that record.
Keep the new thread-launch test's fromSession(chief.cap, 'bean') refusal
(thread-required), and the threads test's direct human message.send refusal
for each creative definition (thread-required-use-thread-send). The former
is sandbox-dependent: its gate record must show it ran without a skip;
otherwise report to Mos rather than claiming the pin. T06 restores the old
assistant-only launcher check; T22 restores the ending check separately.
The persona mismatch fixture wraps only threadsState; all other host ops
remain real. Under mos-1272, a first-run red in launcher.test.mjs,
creative.test.mjs or chat-browser.test.mjs goes to Mos before any fix.
No run has occurred as of this approval record.
### mos-1317: always-running standalone admission pin
Added a separate new test next to the assistant standalone refusal:
`C2-09a: fromSession refuses a creative instance outside a thread without a sandbox`.
It has no sandbox skip. The real fixture host binds/claims the chief, and
fromSession must refuse Bean with thread-required before bundle/sandbox
construction. The existing sandbox-dependent pin remains unchanged.
T23 restores both launcher assistant-only comparisons and targets this new
exact refusal assertion. T06 and T22 remain as separate launch/ending checks:
23 mutants total. No tests run; slot scope amendment sent to Sage.
Keep the Mos-approved threadsState wrap as the only persona-test host override.
## Inspection candidate under sage-3113 (2026-10-23)
Explicitly UNVERIFIED. Sage authorized a local inspection commit before
runs; no claim of review readiness or green tests. Base remains aca16404c,
branch rocko/row219. No rebase has been performed. No Node, browser, test,
mutant or suite has run; the earlier gate holds and subsequent row219 hold
were honored. Static Python exact-once preparation named 23 mutant sites;
git diff --check passes. The mutant execution runner is not yet written.
14 source/test paths: six product files and eight whole test files. The
candidate manifest excludes this packet. The new no-sandbox fromSession
Bean test and T23 record mos-1317; old test adaptations stay governed by
mos-1316 and OLD-TEST-CHANGES.md. First-run reds in launcher, creative and
chat-browser must go to Mos before any fix. Run evidence must prove the
sandbox-dependent pin ran, or report its skip to Mos.
Remaining: Sage-directed rebase to current landing base/new branch; slot
CLEAR and claim; eight whole test files and 23 mutants with process-group
cleanup; path/hash-reader sweep, acceptance evidence and final review packet.
No product behavior is verified. Push waits for Sage's named gap; reviewers
and dependent rows may inspect this commit but must not treat it as approved.
## Rebase under sage-3127 / exact resolution sage-3128
Rebased the unverified c4b4fab98 inspection candidate from aca16404c onto
062646487451993302c768c6812b827c834e952b. The only conflict was broker.mjs's
thread.create user guard: row206 added verb-sensitive #granted(s, verb,
project, instance). Sage-3128 supplied the exact resolution: retain the
selected-instance validation and call #granted(s, 'thread.create',
identifier(a.project), instance). No guessed conflict resolution. The merged
broker has no remaining three-argument #granted(s, ...) call (static balanced
argument scan). T05 now matches that four-argument call and substitutes null
only for instance; all 23 mutant old anchors, including T23's second edit,
remain exact-once. Rebuild manifest/base/patch on this base. Other auto-merges
were user-principal.test, launcher and thread-launch.test. No tests, Node,
mutants or reader audit run: CLEAR still required after 574, covering every
touched test file whole. This branch remains UNVERIFIED.
## Full gate preparation: sage-3174
Clean rebase onto 4a9c536e861f1d9f9a018019b3ac4519d65eccf0, after rows270+271.
No conflicts or manual product changes. All 23 anchors remain exact-once,
including T05 and T23's second edit. All 14 candidate hashes rebuilt.
Before first run: eight files whole, then bus/cli/webui whole, then all ten
scripts/test-*.sh, then 23 mutants, then reader audit with positive control.
Singles timeout 300s; suites 1800s; mutants 180s, each in its own process
group, TERM then KILL after 10s and restore in finally. Every stage gets a
fresh short TMPDIR under /mnt/storage/scratch/tmp/r*. DOCKER_HOST is dead for
Node runs; shell fixtures may use Docker, with project rocko-r219-gate and
owned network cleanup in finally. Live provider cases are not enabled.
Each of the ten shell files uses `timeout -k 10 3600`, comfortably above 3x
the longest entire earlier observed builder gate (roughly 25min -> 75min
would exceed 3600; use 7200 seconds instead below). To avoid relying on
old skip-fast samples, the actual shell command is timeout -k 10 7200,
more than 3x that 25-minute conservative baseline for any individual shell.
No shell run silently retries. Log each skip verbatim. Initial timings are
in historical queue114/r1/out/full/gate-results.json (old task run skipped
live cases, so not used as the Docker worst-case estimate).
Mos1316/1317 first-run launcher/creative/chat-browser reds, any old-test red
or a second red stop before fixes and go to Sage/Mos. No run evidence yet.
## First red and approved test-only correction: mos-1408 / sage-3178-3179
Run c7bf3622d stopped at launcher.test's row213 persona-mismatch assertion:
04:07:22.895-04:07:51.870Z, creative 4/0/0, threads 19/0/0,
user-principal 23/0/0, launcher 33/1/0: total 79 pass, 1 fail, 0 skip.
The approved threadsState snapshot had no session; row269's session-required
admission fired before bundle/persona reading. Original logs remain in
run-4a9c/ and derived-counts-first-stop.json reads their default spec output.
No retry or fix occurred before Mos's ruling. Slot returned after empty
Node/Chromium cwd sweep and all 14 source hashes checked.
Mos-1408 permits only an explicit SESSION in that snapshot and literal-path
assertions: the native threads/acme/<thread>/sessions/<SESSION>/session exists,
and the legacy threads/acme/<thread>/session does not. No sessionStore() call
in the assertion: it creates directories and could manufacture the evidence.
Every other assertion stays exactly as before; no second host op is wrapped.
No product change. Runner explicitly selects --test-reporter=tap (packet-only)
and uses new run-4a9c-mos1408/ output, preserving the failed first attempt.
The next CLEAR reruns from the beginning: whole files, suites, shells, all
23 mutants, reader audit. Any red or second edit must return to Sage/Mos.
Sage-3179: commit/push during the current gap; hold re-claim until our own
slot, after 292 then 294. No tests or queue writes under another seat's slot.
## Restart scope: sage-3224
Run HEAD b97a3ff97, source/test manifest a973912f, unchanged. Start every
stage from the beginning; no prior passing test carry. Sage-3224 explicitly
requires timeout -k 10 per stage, so the packet driver now wraps each Node
stage in that command too (shell stages already had it); the existing process
group timeout/cleanup remains a second bound. No source or test edit.
Singles 300s, suites 1800s, shells 7200s, mutants 180s, TERM-to-KILL 10s.
Fresh short stage TMPDIR. Explicit TAP. Separate run-4a9c-mos1408 output.
## Second old-test edit: mos-1418 / sage-3232 (no restart yet)
The b97a restart at 05:48:57.418-05:49:26.296Z stopped at row213 again:
creative4/0/0, threads19/0/0, user-principal23/0/0, launcher33/1/0.
Original TAP is run-4a9c-mos1408/single-cli-launcher.test.tap. Row270 now
records compactions before the persona refusal: captured tuple was
['compactions',undefined,2], not the full compactions object. Static inference
of count0/known true/reason null was explicitly identified as inference.
No edit or rerun occurred before Mos's approval; slot returned after empty
cwd sweep and 14-hash check. Remaining stages unrun.
Mos-1418 permits this exact second edit: move run-directory lookup above the
pin, require exactly two rows, deepEqual row0 minus at to event compactions,
instance bean, thread, SESSION, run runs[0], count0, known true, reason null;
then take e from row1 and require refused/launch-failed. Later e.thread,
e.why, content nonleak, no sessions, one run, empty run directory and native/
legacy store assertions stay unchanged. Only the specified comment changes.
No product bytes change. If inference is wrong, report red, no third edit.
Dewey must inspect this test diff before the next full restart. Local commit
only while294 holds; push waits for Sage's named gap. No runs after this edit.
## Third edit and new manifest round r2: mos-1420 / sage-3243
Full restart on7cbd839d0 began06:09:43.706Z and stopped06:10:30.695Z:
creative4/0/0, threads19/0/0, user-principal23/0/0, launcher34/0/0,
thread-launch13/1/0, total93 pass/1 fail/0 skip. Persona's exact two-row pin
passed. The new creative-history test failed ENOENT on its obsolete legacy
history path, after its real send/reply checks. Always-running Bean refusal
passed; sandbox-dependent Bean assertion followed the failed read and was
not reached. No coverage claim for that assertion. Slot returned after empty
cwd sweep and14 hashes checked. Original earlier red output was archived
byte-identically before this restart; all later stages unrun.
Mos-1420 authorizes exactly the two history-path corrections in that new
test: obtain each default session from the real runtime threadsState and
read the literal threads/acme/T/sessions/<session>/session/history path.
Contents remain exactly Bean input\nBean again\n and Chet input\n.
Add one literal legacy-path nonexistence assertion per thread, no mkdir
helper. Every other file line stays unchanged. No product change.
The new r2/ manifest/patch snapshot is this third-edit round; root manifest
is refreshed to the same digest for the next claimant/review handoff.
Local commit only; Dewey checks mos1420 items1-3 before push/restart, and
Sage names a gap/CLEAR after294 round4. No runs after this edit. Any further
red, including later lines in this test, returns to Mos through Sage.
## Rebase round r3 after row294: sage-3250 / sage-3254
Rebased five commits from4a9c536e8..5f2ef92e5 onto
80793d824e05baaef4820f4be7790aba55971b6f, new branch
rocko/row219-on-80793d824. No conflicts and no manual product edits.
Row294's shared chat-http status refusals, chat owner-only channel-settings
transport, and serve's user rebinding/route fencing/static federation assets
remain. Row219 still forwards the selected instance over the bound caller;
its metadata route follows the listener/session bus after row294 rebinding.
r3/range-diff.txt records all five pairs: four are '='; the first is '!'
only because createChat's surrounding signature now includes owner=null.
No hunk body changed. Per-path patch IDs and row294 carry delta IDs are in
r3/path-patch-ids.json and r3/294-carry-patch-ids.json; conflict list is empty.
r3/overlap-readers.json lists exact source readers and whole tests via their
test-helper import closure, refreshed on this base. Root and r3 manifest,
base and build.patch are refreshed. Old r2 packet is historical and unchanged.
Dewey's checked mos1418 and mos1420 test edits stand; this handoff requests
rebase-only static review. No tests, Node, mutants, reader gate audit or push
performed while preparing this local rebase. Full no-carry restart waits for
Sage's CLEAR. Any new red returns to Mos through Sage before fixes.
## Round r4: mos-1429 / sage-3291 old-test corrections, no runs yet
bfa421 restart began08:08:37.137Z; eight whole files137/0/0, then bus
873/2/1 stopped08:10:07.389Z. Old failures: creative ops bound without a
thread in grant-executor's sage1908 test; project-runtime exact import pin
still singular THREAD_DEFINITION. Skip X-G3: no Docker/local Gitea image,
never pulls (Node stages used a dead Docker socket). No later stages ran.
Both Bean pins ran in the green thread-launch14/14; persona pin green34/34.
Returned slot with empty cwd sweep and14 hashes intact. No edit/retry before
Mos1429 approved the old-test changes. Original logs remain recorded.
Only test changes: create a real project/thread on the restarted broker,
bind creative ops with it, preserve the host.run/refusal/request-count/sudo/
record and restart lines byte-for-byte. Exact import pin singular->plural;
add one row219 comment, preserve all doesNotMatch checks. No product edit.
r4/assertion-preservation.json stores old/new text and preserved exact lines.
No pre-existing bus test bound a creative role without a thread: condition6
requires the new test in threads.test, covering all six creative definitions.
T01 retargets the old assistant-only #threaded mutant to this direct broker
pin; T24 drops only #hostAccess's creative guard and must fail the corrected
sage1908 assertion with ERR_ASSERTION. 24 mutants total;16 source/test paths,
10 whole changed test files. Source paths are the previous14 plus the two
old test files. New manifest round r4 and root snapshots match.
Short slot plan: focused red sage1908 + exact import pin on detached bfa421,
then three whole green files (grant-executor, project-runtime, threads),
then T24 on fixed bytes (ERR_ASSERTION required), restore/hash/sweep and
push only after authorized. Estimate3-5min. No tests/mutants/Node/push until
Sage CLEAR. Dewey delta review follows; later full no-carry restart includes
all10 changed files, bus/cli/webui, all10 shells, all24 mutants and reader
control/audit. Further old-test red stops and goes to Mos through Sage.
@@ -0,0 +1,58 @@
# Old-test changes awaiting Mos under mos-1272 (sage-2822)
No test has run. Row219's product patch is uncommitted on rocko/row219,
aca16404c, /mnt/storage/scratch/rocko-r219.
## Persona-mismatch regression: launcher.test.mjs
Name: `row 213 a creative instance's persona is read from <configDir>/personas:
off its digest the launch fails with the mismatch as its reason`.
Old trigger: bind/claim a chief, then
`assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });`.
New trigger: fixture-wrap only `host.op({op:'threadsState'})` to report one
pending Bean thread, then `await launcher.tick()`. No fake grant, bind,
writer or persona reader. The same actual persona bundle check fails before
any thread run binds. All other host operations use the real fixture host.
Why: C2-08 declares companion and the shared THREAD_DEFINITIONS. Row219 now
uses that list in check(). Bean's companion definition is therefore
threaded, and fromSession's standalone request refuses thread-required
before start()/buildBundle can read the persona. Keeping that trigger would
stop testing the persona check. No product exemption is added.
Preserved assertions: exactly one refusal; event/code = refused/launch-failed;
exact persona-mismatch reason including the fixture persona path; no
PERSONA-MARKER-d2 in logs; no running sessions; exactly one failed run dir.
The direct fromSession return assertion is replaced by a thread-id assertion
on the same refusal record. Both prove the refusal at the appropriate entry.
Old layout assertion:
`assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);`
New layout assertions:
`assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);`
`assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);`
This matches row121: a thread uses its thread-owned store, never a per-run
session directory. The failed persona build still writes no bundle files.
Exact diff: old-persona-test.diff.
## Other existing-test adaptations disclosed with it
- bus creative.test setup now explicitly chooses assistant, creates Bean and
Quill threads, and binds those runs with their own thread ids. The existing
human `message.send {to:who}` input becomes `thread.send {thread:threads[who]}`.
All original M6 decision, messaging refusal and message-count assertions
remain unchanged. This lets those same restrictions run on the now-threaded
creative roles; direct human messages are correctly thread-required.
- webui chat-browser.test fixture adds /api/chat/instances metadata. Its old
expected create body `{project:P,name:'named thread'}` becomes
`{project:P,name:'named thread',instance:'assistant'}` because the one-choice
picker explicitly sends its preselected instance (Dewey U2). Every other
existing assertion remains. The additional expected key strengthens the
forwarding check and matches the requested UI behavior.
Please approve these adaptations before their results count. The builder
continues static packet preparation only until Sage's test CLEAR.
@@ -0,0 +1,9 @@
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/cwd-sweep-final.json
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/cwd-sweep.json
4c7009747c39843d6666ca54bb56d0f2bf991ab81bb514fa6d45053b8f6e4b92 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/head.json
e86cfe9d1e28b8b42e9ed47c1a5836e4f9f09e700dc892935d55092785ece429 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/network-cleanup.log
4fd1875a14b5da6e55effa090416b39529e5e00eb54660c2574727624d97ec4d agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/results.jsonl
4898d4ece63c8dcad3346c0a661087531b8b9c5471b6c49a023c4eede65726cb agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-bus-creative.test.tap
0d7e7b8a83c2d6441fe76403615839b26c0863b09e536ee77bcdafa62dd43948 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-bus-threads.test.tap
1d4bc4439cfd51773a66efd88d22fe1d6bb826c7511a2b1b92e5040ebc293448 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-bus-user-principal.test.tap
ededb8288ffdb04001aa39bc77d29f7f7ea9ff6cf57f814ea2bd743b4f9fbc8d agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-cli-launcher.test.tap
@@ -0,0 +1,10 @@
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/cwd-sweep-final.json
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/cwd-sweep.json
e74678a27192b58709b8afa57536678fe295e99b8b8572d0e95a131ae9b50467 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/head.json
e86cfe9d1e28b8b42e9ed47c1a5836e4f9f09e700dc892935d55092785ece429 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/network-cleanup.log
43cdf39a98eb06d613c9582af1e97e7af472f20e01a1220aecbac456d795e1de agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/results.jsonl
135b478b49a54826c98988b53d8f80363a94926db70862f2af1abfff51f7874b agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-bus-creative.test.tap
25d69420f68d14affbf4c82b8018af46570b6110c4918fa4847010386ffdd6b2 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-bus-threads.test.tap
742258172b7784d3d4fe3016d8917aa85d9b67c46b538bd35e75a2e7f8c4f419 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-bus-user-principal.test.tap
0cbcf144198e971d8d41e3b0a3d5a80354333043316f2f1381ccd0f79bb92ac3 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-cli-launcher.test.tap
cb8b0f9dd17acc515ad23f39c1a32e4a2fb021a8e87ccebc442e9e8d424d967e agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-cli-thread-launch.test.tap
+1
View File
@@ -0,0 +1 @@
80793d824e05baaef4820f4be7790aba55971b6f
+832
View File
@@ -0,0 +1,832 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d76085739..11061c778 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
});
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
@@ -598,7 +598,7 @@ export class Broker {
);
}
#threaded(business, role) {
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
}
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
@@ -6068,24 +6068,27 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
- // A thread belongs to the user who made it and to the business's one thread instance.
+ // A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
if (!s.tester && !s.user) this.#human(s);
- keys(a, ['project', 'name'], ['project', 'name']);
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
+ const instance = a.instance === undefined
+ ? (instances.length === 1 ? instances[0] : null)
+ : identifier(a.instance);
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
+ fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
- // The bus never picks between instances; the business file declares exactly one.
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (!instances.length) fail('thread-instance-missing');
- if (instances.length > 1) fail('thread-instance-ambiguous');
+ // The human selects an instance; omission is compatible only with one candidate.
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -6094,13 +6097,13 @@ export class Broker {
this.#now(),
s.business,
a.project,
- instances[0],
+ instance,
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
- return { id: tid, instance: instances[0] };
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
+ return { id: tid, instance };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// and the tester's lists only its own (row 175 listed it the business human's).
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
index 6cfbf1cd6..6d421ba19 100644
--- a/packages/bus/tests/creative.test.mjs
+++ b/packages/bus/tests/creative.test.mjs
@@ -44,15 +44,18 @@ function setup(t) {
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
+ const threads = { assistant: thread };
for (const role of Object.keys(roles)) {
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
+ if (['bean', 'quill'].includes(role))
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const count = (sql) => store.get(sql).n;
- return { call, count, store };
+ return { call, count, store, threads };
}
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
});
test('a creative instance sends only to the user and hears only from the user', (t) => {
- const { call, count } = setup(t);
+ const { call, count, threads } = setup(t);
for (const who of ['bean', 'quill']) {
call(who, 'message.send', { to: 'human', body: 'hello' });
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
for (const from of ['chief', 'pm']) {
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
}
- call('human', 'message.send', { to: who, body: 'hello' });
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
}
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
index e449b7d77..46235d16b 100644
--- a/packages/bus/tests/grant-executor.test.mjs
+++ b/packages/bus/tests/grant-executor.test.mjs
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
const f = await setup(t);
assert.equal((await f.approve()).end.action, 'grant.applied');
const r = await restart(t, f, creativeOps(f.business));
- const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
+ const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
+ const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
+ const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
r.broker.request(cap, { verb: 'role.claim' });
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
const before = f.requests().length;
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
index a6086e56c..7275ad902 100644
--- a/packages/bus/tests/project-runtime.test.mjs
+++ b/packages/bus/tests/project-runtime.test.mjs
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
+ // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index 8b31da9f9..280e03184 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -601,3 +601,56 @@ test('project.create keeps unrelated names with and without a repository', t =>
{ id: linked.id, name: 'work', repo: 'stack' },
]);
});
+
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
+ } }));
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
+ for (const [instance, role] of Object.entries(extra)) {
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
+ assert.equal(receipt.instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
+ ctx.launch(instance, instance, { thread: receipt.id });
+ assert.equal(ctx.claim(instance).thread, receipt.id);
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
+ const read = ctx.call(instance, 'message.receive');
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
+ { code: 'thread-required-use-thread-send' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
+});
+
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
+ const ctx = setup(t);
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ for (const instance of ['not-declared', 'pm', 'coder'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
+ assert.equal(legacy.instance, 'assistant');
+});
+
+
+test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
+ } }));
+ for (const instance of Object.keys(extra)) {
+ assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
+});
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
index 4dcd1ebeb..8471ddf85 100644
--- a/packages/bus/tests/user-principal.test.mjs
+++ b/packages/bus/tests/user-principal.test.mjs
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
});
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
-function setup(t, { grants = [grant()], processing = null } = {}) {
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
const store = new Store(root);
t.after(() => {
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
+ ...threadRoles,
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
test('R3 r4: a non-function access throws before the store is read', () => {
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
});
+
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
+ ctx.access.doc.grants = [grant({ projects: [project] })];
+ ctx.user('alice');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
+ assert.equal(allowed.instance, 'assistant');
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
+ assert.equal(bean.instance, 'bean');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
+});
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index bfe55b61e..d8248284c 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -53,7 +53,7 @@
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
-// role definition is the assistant) runs once per thread, never on its own:
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
// a launch from a session or --pm refuses thread-required. Every tick
// (threadTickMs) the host asks the broker for the business's threads
// (launch op threadsState) and:
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
import { createLoader } from "../../plugins/src/loader.mjs";
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// How often the host asks for the threads' state; a poll period, not a limit.
const THREAD_TICK_MS = 2000;
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
-const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
@@ -331,7 +330,7 @@ export function createLauncher({
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
@@ -997,7 +996,7 @@ export function createLauncher({
function ending(r) {
const runs = r.runs ?? [];
const at = Date.parse(r.at);
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
}
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 2202c7753..baabd843e 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const dir = join(given.root, "config", "personas", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
- const { f, host, launcher } = await setup(t, undefined, given);
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
- const [e] = log(f);
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
+ const thread = 'persona-thread';
+ const SESSION = 'persona-session';
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
+ ...host,
+ op: message => message.op === 'threadsState'
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
+ : host.op(message),
+ }) });
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
+ await launcher.tick();
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
+ const entries = log(f);
+ assert.equal(entries.length, 2);
+ const { at, ...compactions } = entries[0];
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
+ const e = entries[1];
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
+ assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
assert.equal(runs.length, 1);
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
});
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 0d207a0a0..fa3adb866 100644
--- a/packages/cli/tests/thread-launch.test.mjs
+++ b/packages/cli/tests/thread-launch.test.mjs
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
+// Admission refuses before bundle construction or a sandbox is needed.
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
+ const prepared = prepare(t, { edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ doc.launch.instances.push('bean');
+ return doc;
+ } });
+ const { f } = prepared;
+ const host = await start(t, prepared);
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
+});
+
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
// bus's plug-in loader.
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
assert.deepEqual(threadRuns(f, A), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
});
+
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ const vars = { ...doc.roles.assistant.vars };
+ delete doc.roles.assistant;
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
+ doc.launch.instances = ['coder', 'bean', 'chet'];
+ return doc;
+ } });
+ const host = await start(t, prepared), { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
+ const send = async (thread, instance, body) => {
+ const message = host.call('thread.send', { thread, body });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ const reply = outcome;
+ assert.equal(reply.from_role, instance);
+ assert.equal(reply.to_role, 'human');
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
+ };
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
+ const launches = launchLog(f).filter(row => row.event === 'launch');
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+});
+
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ delete doc.roles.assistant;
+ doc.launch.instances = ['coder', 'bean'];
+ return doc;
+ } });
+ let requests = [];
+ const host = await start(t, prepared, { ops: () => ({
+ lifecycleRequests: () => requests,
+ lifecycleTake: () => ({ ...requests[0], go: true }),
+ lifecycleDone: () => { requests = []; return {}; },
+ }) });
+ const { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
+ for (const thread of threads) {
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ }
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
+ await host.launcher.tick();
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
+});
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index 7980c1442..63d3ca974 100644
--- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') {
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
+ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
+ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 1b0a41dce..5a1ed8bf1 100644
--- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
+ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
open: ({ thread }) => channel('thread.open', { thread }),
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
index a6b805c1b..15066bdc6 100644
--- a/packages/webui/src/public/chat.js
+++ b/packages/webui/src/public/chat.js
@@ -14,7 +14,7 @@
return states.get(id);
};
const form = key => {
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
return forms.get(key);
};
class ChatError extends Error {
@@ -58,7 +58,11 @@
function creation(kind, key) {
const f = form(key), project = kind === 'project';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
+ const choices = current.instances ?? [];
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
@@ -106,7 +110,7 @@
const f = document.getElementById(keep.field.id);
if (f && !f.disabled) {
f.focus({ preventScroll: true });
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
}
@@ -126,7 +130,7 @@
paint(); return;
}
const old = current;
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
+ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
const s = kind === 'thread' ? state(id) : null;
@@ -147,6 +151,25 @@
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
}
}
+ if (kind === 'project') {
+ try {
+ const listed = await request('instances', undefined, signal);
+ if (g !== generation || out()) return;
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
+ throw new ChatError('invalid-response');
+ current.instances = listed.rows; current.instanceError = '';
+ const f = form(id);
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
+ f.instance = ''; f.instanceChosen = true;
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
+ }
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
+ } catch (err) {
+ if (err.name === 'AbortError' || out() || g !== generation) return;
+ current.instances = [];
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
+ }
+ }
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
if (kind === 'list') {
try {
@@ -179,7 +202,11 @@
if (!current || out()) return;
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
+ if (f && event.target.dataset.chatField) {
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
+ draft[field] = event.target.value;
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
+ }
});
document.addEventListener('scroll', event => {
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
@@ -232,7 +259,8 @@
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
}
});
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index cd51a0b6e..56fcdd4e1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
}
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
+ if (path === '/api/chat/instances') {
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
+ if (search) return json(res, 400, { error: 'invalid-request' });
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
+ try {
+ const value = await bus.instances();
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
+ throw new BusReadError('invalid-response');
+ const rows = Object.entries(value.instances)
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
+ return json(res, 200, { rows, at: new Date().toISOString() });
+ } catch (err) {
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
+ return json(res, busStatus(code), { error: code });
+ }
+ }
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index f3eeeca96..cc3ec093d 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
const stub = `(() => {
const real = fetch.bind(window);
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
+ window.__instances=[{instance:'assistant',name:'assistant'}];
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
window.fetch=async (url,init={})=>{
@@ -33,6 +34,7 @@ const stub = `(() => {
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
+ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
if(window.__pages.length)return data(window.__pages.shift());
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
await click('#chat-thread-form button[type=submit]');
await wait('document.querySelector("#chat-send-form")');
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
assert.equal(await b.evaluate('window.__writes.length'), 2);
});
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
+
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
+ const { b, wait, set, click, refresh } = await setup(t);
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
+ assert.match(labels[1], /\(bean\)/);
+ assert.match(labels[2], /\(chet\)/);
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
+ assert.equal(await b.evaluate('window.injected'), undefined);
+ await set('#chat-thread-name', 'Bean discussion');
+ await click('#chat-thread-form button');
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+ await set('#chat-instance', 'bean');
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
+ await click('#chat-thread-form button');
+ await wait('window.__writes.length===1');
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
+});
+
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
+ const { b, wait, set, refresh } = await setup(t);
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
+ await refresh();
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ await set('#chat-instance', 'bean');
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
+ await set('#chat-instance', 'chet');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+});
+
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
+ const { b, wait } = await setup(t, '?readonly');
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+});
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
new file mode 100644
index 000000000..893c38f80
--- /dev/null
+++ b/packages/webui/tests/chat-instances.test.mjs
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { request } from 'node:http';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { startServer } from '../src/serve.mjs';
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
+import { close } from './fixture.mjs';
+
+const path = '/api/chat/instances';
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
+const snapshot = {
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
+ instances: {
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
+ },
+};
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
+ const address = server.address(), unix = typeof address === 'string';
+ return new Promise((resolve, reject) => {
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
+ let text = '';
+ res.on('data', chunk => text += chunk);
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
+ });
+ req.on('error', reject); req.end();
+ });
+}
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
+ let calls = 0;
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
+ t.after(() => close(server));
+ const refused = await get(server);
+ assert.equal(refused.status, 401);
+ assert.equal(calls, 0);
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
+ assert.equal(calls, 1);
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
+ assert.ok([400, 405].includes(result.status));
+ }
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
+});
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
+ let reads = 0;
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
+ t.after(() => close(server));
+ const answer = await get(server);
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
+ assert.equal(reads, 1);
+});
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.ok(answer.status >= 400);
+ assert.doesNotMatch(answer.text, /private-path/);
+ }
+});
+
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
+ let session = null, reads = 0;
+ const touches = [];
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
+ t.after(() => close(server));
+ const headers = { host: sso.publicHost };
+ assert.equal((await get(server, { headers })).status, 401);
+ assert.equal(reads, 0);
+ session = { name: 'jason' };
+ assert.equal((await get(server, { headers })).status, 200);
+ assert.equal(reads, 1);
+ assert.deepEqual(touches, [false, false]);
+});
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 8c11611cf..b47ecf1a7 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
});
+
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
+ const c = await setup(t);
+ c.state.answer = { id: T, instance: 'bean' };
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
+ assert.equal(answer.data.instance, 'bean');
+});
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
+ const c = await setup(t);
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
+ assert.equal(answer.status, 400, JSON.stringify(value));
+ }
+ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
+ assert.deepEqual(c.calls, []);
+});
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
import os,json
busy=[]
for p in Path('/proc').glob('[0-9]*'):
try:
if int(p.name)==os.getpid():continue
comm=(p/'comm').read_text().strip()
if not any(x in comm for x in ['node','python','bash']):continue
args=(p/'cmdline').read_bytes().split(b'\0')
if (comm.startswith('node') and b'--test' in args) or any(x.rsplit(b'/',1)[-1] in (b'run-mutants.py',b'gate-run.sh') for x in args):
busy.append(dict(pid=int(p.name),cwd=str((p/'cwd').resolve()),name=comm))
except (PermissionError,FileNotFoundError,ProcessLookupError):pass
print('BUSY_CHECK',json.dumps(busy))
raise SystemExit(bool(busy))
@@ -0,0 +1,16 @@
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
3efd0238da60e0bfcd3b25e5256b9c213085119a86a7b852b13e7a60340d3a24 packages/bus/tests/grant-executor.test.mjs
b7ea857637947c7a9ca45c56cea47e37e06f0fd8446ea91316990d9fa7299a7a packages/bus/tests/project-runtime.test.mjs
264d82944c53b638bf6a14cc0706cf9ba184d668211c2b9c1031cdbe88e84bb9 packages/bus/tests/threads.test.mjs
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,16 @@
packages/bus/src/broker.mjs
packages/bus/tests/creative.test.mjs
packages/bus/tests/grant-executor.test.mjs
packages/bus/tests/project-runtime.test.mjs
packages/bus/tests/threads.test.mjs
packages/bus/tests/user-principal.test.mjs
packages/cli/src/launcher.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/thread-launch.test.mjs
packages/webui/src/chat-http.mjs
packages/webui/src/chat.mjs
packages/webui/src/public/chat.js
packages/webui/src/serve.mjs
packages/webui/tests/chat-browser.test.mjs
packages/webui/tests/chat-instances.test.mjs
packages/webui/tests/chat.test.mjs
+201
View File
@@ -0,0 +1,201 @@
[
{
"id": "T01",
"file": "packages/bus/src/broker.mjs",
"old": "return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);",
"new": "return this.#business(business).roles[role]?.definition === 'assistant';",
"test": "packages/bus/tests/threads.test.mjs",
"pattern": "C2-09a: creative bindLaunch without a thread"
},
{
"id": "T02",
"file": "packages/bus/src/broker.mjs",
"old": ": identifier(a.instance);",
"new": ": instances[0];",
"test": "packages/bus/tests/threads.test.mjs",
"pattern": "C2-09a: explicit creative"
},
{
"id": "T03",
"file": "packages/bus/src/broker.mjs",
"old": "if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');",
"new": "if (!instances.length) fail('thread-instance-missing');",
"test": "packages/bus/tests/threads.test.mjs",
"pattern": "C2-09a: explicit missing"
},
{
"id": "T04",
"file": "packages/bus/src/broker.mjs",
"old": "if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');",
"new": "if (false) fail('thread-instance-ambiguous');",
"test": "packages/bus/tests/threads.test.mjs",
"pattern": "C2-09a: explicit creative"
},
{
"id": "T05",
"file": "packages/bus/src/broker.mjs",
"old": "!this.#granted(s, 'thread.create', identifier(a.project), instance)",
"new": "!this.#granted(s, 'thread.create', identifier(a.project), null)",
"test": "packages/bus/tests/user-principal.test.mjs",
"pattern": "C2-09a: naming an instance"
},
{
"id": "T06",
"file": "packages/cli/src/launcher.mjs",
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);",
"new": "const threaded = b.roles[instance]?.definition === \"assistant\";",
"test": "packages/cli/tests/thread-launch.test.mjs",
"pattern": "C2-09a: creative thread messages"
},
{
"id": "T07",
"file": "packages/webui/src/serve.mjs",
"old": ".filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))",
"new": ".filter(() => true)",
"test": "packages/webui/tests/chat-instances.test.mjs",
"pattern": "C2-09a metadata uses the human"
},
{
"id": "T08",
"file": "packages/webui/src/serve.mjs",
"old": "name: entry.vars?.['persona.name'] || instance",
"new": "name: instance",
"test": "packages/webui/tests/chat-instances.test.mjs",
"pattern": "C2-09a metadata uses the human"
},
{
"id": "T09",
"file": "packages/webui/src/chat-http.mjs",
"old": "(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))",
"new": "false",
"test": "packages/webui/tests/chat.test.mjs",
"pattern": "C2-09a malformed instance"
},
{
"id": "T10",
"file": "packages/webui/src/chat.mjs",
"old": "createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),",
"new": "createThread: ({ project, name, instance }) => channel('thread.create', { project, name }),",
"test": "packages/webui/tests/chat.test.mjs",
"pattern": "C2-09a createThread forwards"
},
{
"id": "T11",
"file": "packages/webui/src/public/chat.js",
"old": ": { project: key, name: f.name, instance: f.instance };",
"new": ": { project: key, name: f.name };",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: no silent choice"
},
{
"id": "T12",
"file": "packages/webui/src/public/chat.js",
"old": "!f.instance && listed.rows.length === 1",
"new": "!f.instance && listed.rows.length >= 1",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: no silent choice"
},
{
"id": "T13",
"file": "packages/webui/src/public/chat.js",
"old": "current.instanceError = \"Couldn't read the thread instances; reload to try again\";",
"new": "current.instanceError = '';",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: metadata failure"
},
{
"id": "T14",
"file": "packages/webui/src/public/chat.js",
"old": "txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)",
"new": "txt(row.name)",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: no silent choice"
},
{
"id": "T15",
"file": "packages/webui/src/public/chat.js",
"old": "if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {",
"new": "if (false) {",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: metadata failure"
},
{
"id": "T16",
"file": "packages/webui/src/public/chat.js",
"old": "f.instance = ''; f.instanceChosen = true;",
"new": "f.instance = ''; f.instanceChosen = false;",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: metadata failure"
},
{
"id": "T17",
"file": "packages/webui/src/public/chat.js",
"old": "const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));",
"new": "const unavailable = false;",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: no silent choice"
},
{
"id": "T18",
"file": "packages/webui/src/serve.mjs",
"old": "if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });",
"new": "if (false) return json(res, 405, { error: 'method-not-allowed' });",
"test": "packages/webui/tests/chat-instances.test.mjs",
"pattern": "C2-09a metadata uses the human"
},
{
"id": "T19",
"file": "packages/webui/src/serve.mjs",
"old": "if (search) return json(res, 400, { error: 'invalid-request' });",
"new": "if (false) return json(res, 400, { error: 'invalid-request' });",
"test": "packages/webui/tests/chat-instances.test.mjs",
"pattern": "C2-09a metadata uses the human"
},
{
"id": "T20",
"file": "packages/webui/src/public/chat.js",
"old": "current.instances = listed.rows; current.instanceError = '';",
"new": "current.instances = listed.rows;",
"test": "packages/webui/tests/chat-browser.test.mjs",
"pattern": "C2-09a picker: metadata failure"
},
{
"id": "T21",
"file": "packages/webui/src/serve.mjs",
"old": "({ instance, name: entry.vars?.['persona.name'] || instance })",
"new": "({ instance, name: entry.vars?.['persona.name'] || instance, vars: entry.vars })",
"test": "packages/webui/tests/chat-instances.test.mjs",
"pattern": "C2-09a metadata uses the human"
},
{
"id": "T22",
"file": "packages/cli/src/launcher.mjs",
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);",
"new": "const threaded = b.roles[r.instance]?.definition === \"assistant\";",
"test": "packages/cli/tests/thread-launch.test.mjs",
"pattern": "C2-09a: terminating one creative thread"
},
{
"id": "T23",
"file": "packages/cli/src/launcher.mjs",
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);",
"new": "const threaded = b.roles[instance]?.definition === \"assistant\";",
"test": "packages/cli/tests/thread-launch.test.mjs",
"pattern": "C2-09a: fromSession refuses a creative instance outside a thread without a sandbox",
"additional_edits": [
{
"file": "packages/cli/src/launcher.mjs",
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);",
"new": "const threaded = b.roles[r.instance]?.definition === \"assistant\";"
}
]
},
{
"id": "T24",
"file": "packages/bus/src/broker.mjs",
"old": "if (CREATIVE_DEFINITIONS.includes(this.#business(s.business).roles[s.role]?.definition)) fail('host-creative-role');",
"new": "void 0;",
"test": "packages/bus/tests/grant-executor.test.mjs",
"pattern": "sage-1908: once the agent of an applied grant is a creative role"
}
]
@@ -0,0 +1,40 @@
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 2202c7753..0a34fe27a 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -773,21 +773,28 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const dir = join(given.root, "config", "personas", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
- const { f, host, launcher } = await setup(t, undefined, given);
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
+ const thread = 'persona-thread';
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
+ ...host,
+ op: message => message.op === 'threadsState'
+ ? Promise.resolve([{ thread, instance: 'bean', holder: null, pending: 1 }])
+ : host.op(message),
+ }) });
+ // The bundle's refusal remains launch-failed with the persona mismatch as its reason.
+ await launcher.tick();
const [e] = log(f);
assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
+ assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
assert.equal(runs.length, 1);
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);
});
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
@@ -0,0 +1,40 @@
"""Static preparation only. Executing tests requires Sage CLEAR."""
from pathlib import Path
import json
root=Path(__file__).resolve().parents[4]
p=Path(__file__).resolve().parent
specs=[]
def site(id,file,old,new,test,pattern):
text=(root/file).read_text();assert text.count(old)==1,(id,text.count(old))
specs.append(dict(id=id,file=file,old=old,new=new,test=test,pattern=pattern))
bus='packages/bus/src/broker.mjs';bt='packages/bus/tests/threads.test.mjs';cli='packages/cli/src/launcher.mjs';ct='packages/cli/tests/thread-launch.test.mjs';web='packages/webui/src/serve.mjs';wt='packages/webui/tests/chat-instances.test.mjs';browser='packages/webui/src/public/chat.js';brtest='packages/webui/tests/chat-browser.test.mjs'
site('T01',bus,'return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);',"return this.#business(business).roles[role]?.definition === 'assistant';",bt,'C2-09a: creative bindLaunch without a thread')
site('T02',bus,': identifier(a.instance);',': instances[0];',bt,'C2-09a: explicit creative')
site('T03',bus,"if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');","if (!instances.length) fail('thread-instance-missing');",bt,'C2-09a: explicit missing')
site('T04',bus,"if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');","if (false) fail('thread-instance-ambiguous');",bt,'C2-09a: explicit creative')
site('T05',bus,"!this.#granted(s, 'thread.create', identifier(a.project), instance)","!this.#granted(s, 'thread.create', identifier(a.project), null)",'packages/bus/tests/user-principal.test.mjs','C2-09a: naming an instance')
site('T06',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);','const threaded = b.roles[instance]?.definition === "assistant";',ct,'C2-09a: creative thread messages')
site('T07',web,'.filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))','.filter(() => true)',wt,'C2-09a metadata uses the human')
site('T08',web,"name: entry.vars?.['persona.name'] || instance",'name: instance',wt,'C2-09a metadata uses the human')
site('T09','packages/webui/src/chat-http.mjs',"(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))",'false','packages/webui/tests/chat.test.mjs','C2-09a malformed instance')
site('T10','packages/webui/src/chat.mjs',"createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),","createThread: ({ project, name, instance }) => channel('thread.create', { project, name }),",'packages/webui/tests/chat.test.mjs','C2-09a createThread forwards')
site('T11',browser,": { project: key, name: f.name, instance: f.instance };",": { project: key, name: f.name };",brtest,'C2-09a picker: no silent choice')
site('T12',browser,'!f.instance && listed.rows.length === 1','!f.instance && listed.rows.length >= 1',brtest,'C2-09a picker: no silent choice')
site('T13',browser,'current.instanceError = "Couldn\'t read the thread instances; reload to try again";',"current.instanceError = '';",brtest,'C2-09a picker: metadata failure')
site('T14',browser,'txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)','txt(row.name)',brtest,'C2-09a picker: no silent choice')
site('T15',browser,'if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {','if (false) {',brtest,'C2-09a picker: metadata failure')
site('T16',browser,"f.instance = ''; f.instanceChosen = true;","f.instance = ''; f.instanceChosen = false;",brtest,'C2-09a picker: metadata failure')
site('T17',browser,"const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));",'const unavailable = false;',brtest,'C2-09a picker: no silent choice')
# Route-local guards include their full unique response text to avoid touching other routes.
site('T18',web,"if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });","if (false) return json(res, 405, { error: 'method-not-allowed' });",wt,'C2-09a metadata uses the human')
site('T19',web,"if (search) return json(res, 400, { error: 'invalid-request' });","if (false) return json(res, 400, { error: 'invalid-request' });",wt,'C2-09a metadata uses the human')
site('T20',browser,"current.instances = listed.rows; current.instanceError = '';","current.instances = listed.rows;",brtest,'C2-09a picker: metadata failure')
site('T21',web,"({ instance, name: entry.vars?.['persona.name'] || instance })","({ instance, name: entry.vars?.['persona.name'] || instance, vars: entry.vars })",wt,'C2-09a metadata uses the human')
site('T22',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);','const threaded = b.roles[r.instance]?.definition === "assistant";',ct,'C2-09a: terminating one creative thread')
site('T23',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);','const threaded = b.roles[instance]?.definition === "assistant";',ct,'C2-09a: fromSession refuses a creative instance outside a thread without a sandbox')
ending_old='const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);'
assert (root/cli).read_text().count(ending_old)==1
specs[-1]['additional_edits']=[dict(file=cli,old=ending_old,new='const threaded = b.roles[r.instance]?.definition === "assistant";')]
site('T24',bus,"if (CREATIVE_DEFINITIONS.includes(this.#business(s.business).roles[s.role]?.definition)) fail('host-creative-role');",'void 0;','packages/bus/tests/grant-executor.test.mjs','sage-1908: once the agent of an applied grant is a creative role')
(p/'mutants.json').write_text(json.dumps(specs,indent=2)+'\n')
print('STATIC_PREFLIGHT',len(specs),'exact-once sites; no tests run')
+6
View File
@@ -0,0 +1,6 @@
# Row219 r2 / mos1420 third edit
See ../BUILD.md for the full unchanged scope and preserved red chronology.
This round changes only the new thread-launch native-history paths and adds
the two legacy-path absence assertions approved by mos1420. No run yet.
Dewey pre-restart diff check and Sage push/run CLEAR are required.
+1
View File
@@ -0,0 +1 @@
4a9c536e861f1d9f9a018019b3ac4519d65eccf0
+788
View File
@@ -0,0 +1,788 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d76085739..11061c778 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
});
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
@@ -598,7 +598,7 @@ export class Broker {
);
}
#threaded(business, role) {
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
}
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
@@ -6068,24 +6068,27 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
- // A thread belongs to the user who made it and to the business's one thread instance.
+ // A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
if (!s.tester && !s.user) this.#human(s);
- keys(a, ['project', 'name'], ['project', 'name']);
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
+ const instance = a.instance === undefined
+ ? (instances.length === 1 ? instances[0] : null)
+ : identifier(a.instance);
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
+ fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
- // The bus never picks between instances; the business file declares exactly one.
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (!instances.length) fail('thread-instance-missing');
- if (instances.length > 1) fail('thread-instance-ambiguous');
+ // The human selects an instance; omission is compatible only with one candidate.
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -6094,13 +6097,13 @@ export class Broker {
this.#now(),
s.business,
a.project,
- instances[0],
+ instance,
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
- return { id: tid, instance: instances[0] };
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
+ return { id: tid, instance };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// and the tester's lists only its own (row 175 listed it the business human's).
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
index 6cfbf1cd6..6d421ba19 100644
--- a/packages/bus/tests/creative.test.mjs
+++ b/packages/bus/tests/creative.test.mjs
@@ -44,15 +44,18 @@ function setup(t) {
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
+ const threads = { assistant: thread };
for (const role of Object.keys(roles)) {
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
+ if (['bean', 'quill'].includes(role))
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const count = (sql) => store.get(sql).n;
- return { call, count, store };
+ return { call, count, store, threads };
}
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
});
test('a creative instance sends only to the user and hears only from the user', (t) => {
- const { call, count } = setup(t);
+ const { call, count, threads } = setup(t);
for (const who of ['bean', 'quill']) {
call(who, 'message.send', { to: 'human', body: 'hello' });
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
for (const from of ['chief', 'pm']) {
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
}
- call('human', 'message.send', { to: who, body: 'hello' });
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
}
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index 8b31da9f9..1410ec3fa 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t =>
{ id: linked.id, name: 'work', repo: 'stack' },
]);
});
+
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
+ } }));
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
+ for (const [instance, role] of Object.entries(extra)) {
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
+ assert.equal(receipt.instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
+ ctx.launch(instance, instance, { thread: receipt.id });
+ assert.equal(ctx.claim(instance).thread, receipt.id);
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
+ const read = ctx.call(instance, 'message.receive');
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
+ { code: 'thread-required-use-thread-send' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
+});
+
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
+ const ctx = setup(t);
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ for (const instance of ['not-declared', 'pm', 'coder'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
+ assert.equal(legacy.instance, 'assistant');
+});
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
index 4dcd1ebeb..8471ddf85 100644
--- a/packages/bus/tests/user-principal.test.mjs
+++ b/packages/bus/tests/user-principal.test.mjs
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
});
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
-function setup(t, { grants = [grant()], processing = null } = {}) {
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
const store = new Store(root);
t.after(() => {
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
+ ...threadRoles,
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
test('R3 r4: a non-function access throws before the store is read', () => {
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
});
+
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
+ ctx.access.doc.grants = [grant({ projects: [project] })];
+ ctx.user('alice');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
+ assert.equal(allowed.instance, 'assistant');
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
+ assert.equal(bean.instance, 'bean');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
+});
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index bfe55b61e..d8248284c 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -53,7 +53,7 @@
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
-// role definition is the assistant) runs once per thread, never on its own:
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
// a launch from a session or --pm refuses thread-required. Every tick
// (threadTickMs) the host asks the broker for the business's threads
// (launch op threadsState) and:
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
import { createLoader } from "../../plugins/src/loader.mjs";
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// How often the host asks for the threads' state; a poll period, not a limit.
const THREAD_TICK_MS = 2000;
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
-const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
@@ -331,7 +330,7 @@ export function createLauncher({
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
@@ -997,7 +996,7 @@ export function createLauncher({
function ending(r) {
const runs = r.runs ?? [];
const at = Date.parse(r.at);
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
}
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 2202c7753..baabd843e 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const dir = join(given.root, "config", "personas", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
- const { f, host, launcher } = await setup(t, undefined, given);
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
- const [e] = log(f);
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
+ const thread = 'persona-thread';
+ const SESSION = 'persona-session';
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
+ ...host,
+ op: message => message.op === 'threadsState'
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
+ : host.op(message),
+ }) });
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
+ await launcher.tick();
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
+ const entries = log(f);
+ assert.equal(entries.length, 2);
+ const { at, ...compactions } = entries[0];
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
+ const e = entries[1];
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
+ assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
assert.equal(runs.length, 1);
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
});
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 0d207a0a0..fa3adb866 100644
--- a/packages/cli/tests/thread-launch.test.mjs
+++ b/packages/cli/tests/thread-launch.test.mjs
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
+// Admission refuses before bundle construction or a sandbox is needed.
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
+ const prepared = prepare(t, { edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ doc.launch.instances.push('bean');
+ return doc;
+ } });
+ const { f } = prepared;
+ const host = await start(t, prepared);
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
+});
+
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
// bus's plug-in loader.
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
assert.deepEqual(threadRuns(f, A), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
});
+
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ const vars = { ...doc.roles.assistant.vars };
+ delete doc.roles.assistant;
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
+ doc.launch.instances = ['coder', 'bean', 'chet'];
+ return doc;
+ } });
+ const host = await start(t, prepared), { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
+ const send = async (thread, instance, body) => {
+ const message = host.call('thread.send', { thread, body });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ const reply = outcome;
+ assert.equal(reply.from_role, instance);
+ assert.equal(reply.to_role, 'human');
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
+ };
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
+ const launches = launchLog(f).filter(row => row.event === 'launch');
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+});
+
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ delete doc.roles.assistant;
+ doc.launch.instances = ['coder', 'bean'];
+ return doc;
+ } });
+ let requests = [];
+ const host = await start(t, prepared, { ops: () => ({
+ lifecycleRequests: () => requests,
+ lifecycleTake: () => ({ ...requests[0], go: true }),
+ lifecycleDone: () => { requests = []; return {}; },
+ }) });
+ const { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
+ for (const thread of threads) {
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ }
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
+ await host.launcher.tick();
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
+});
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index c7770726d..396b42f3c 100644
--- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs
@@ -78,7 +78,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') {
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
+ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
+ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 018574ad4..65b89dab3 100644
--- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs
@@ -20,7 +20,7 @@ export function createChat({ call }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
+ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
open: ({ thread }) => channel('thread.open', { thread }),
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
index a6b805c1b..15066bdc6 100644
--- a/packages/webui/src/public/chat.js
+++ b/packages/webui/src/public/chat.js
@@ -14,7 +14,7 @@
return states.get(id);
};
const form = key => {
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
return forms.get(key);
};
class ChatError extends Error {
@@ -58,7 +58,11 @@
function creation(kind, key) {
const f = form(key), project = kind === 'project';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
+ const choices = current.instances ?? [];
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
@@ -106,7 +110,7 @@
const f = document.getElementById(keep.field.id);
if (f && !f.disabled) {
f.focus({ preventScroll: true });
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
}
@@ -126,7 +130,7 @@
paint(); return;
}
const old = current;
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
+ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
const s = kind === 'thread' ? state(id) : null;
@@ -147,6 +151,25 @@
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
}
}
+ if (kind === 'project') {
+ try {
+ const listed = await request('instances', undefined, signal);
+ if (g !== generation || out()) return;
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
+ throw new ChatError('invalid-response');
+ current.instances = listed.rows; current.instanceError = '';
+ const f = form(id);
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
+ f.instance = ''; f.instanceChosen = true;
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
+ }
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
+ } catch (err) {
+ if (err.name === 'AbortError' || out() || g !== generation) return;
+ current.instances = [];
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
+ }
+ }
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
if (kind === 'list') {
try {
@@ -179,7 +202,11 @@
if (!current || out()) return;
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
+ if (f && event.target.dataset.chatField) {
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
+ draft[field] = event.target.value;
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
+ }
});
document.addEventListener('scroll', event => {
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
@@ -232,7 +259,8 @@
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
}
});
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 288871d0c..d9d27a062 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
@@ -705,6 +706,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
}
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
+ if (path === '/api/chat/instances') {
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
+ if (search) return json(res, 400, { error: 'invalid-request' });
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
+ try {
+ const value = await bus.instances();
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
+ throw new BusReadError('invalid-response');
+ const rows = Object.entries(value.instances)
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
+ return json(res, 200, { rows, at: new Date().toISOString() });
+ } catch (err) {
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
+ return json(res, busStatus(code), { error: code });
+ }
+ }
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index f3eeeca96..cc3ec093d 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
const stub = `(() => {
const real = fetch.bind(window);
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
+ window.__instances=[{instance:'assistant',name:'assistant'}];
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
window.fetch=async (url,init={})=>{
@@ -33,6 +34,7 @@ const stub = `(() => {
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
+ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
if(window.__pages.length)return data(window.__pages.shift());
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
await click('#chat-thread-form button[type=submit]');
await wait('document.querySelector("#chat-send-form")');
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
assert.equal(await b.evaluate('window.__writes.length'), 2);
});
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
+
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
+ const { b, wait, set, click, refresh } = await setup(t);
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
+ assert.match(labels[1], /\(bean\)/);
+ assert.match(labels[2], /\(chet\)/);
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
+ assert.equal(await b.evaluate('window.injected'), undefined);
+ await set('#chat-thread-name', 'Bean discussion');
+ await click('#chat-thread-form button');
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+ await set('#chat-instance', 'bean');
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
+ await click('#chat-thread-form button');
+ await wait('window.__writes.length===1');
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
+});
+
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
+ const { b, wait, set, refresh } = await setup(t);
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
+ await refresh();
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ await set('#chat-instance', 'bean');
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
+ await set('#chat-instance', 'chet');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+});
+
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
+ const { b, wait } = await setup(t, '?readonly');
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+});
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
new file mode 100644
index 000000000..893c38f80
--- /dev/null
+++ b/packages/webui/tests/chat-instances.test.mjs
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { request } from 'node:http';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { startServer } from '../src/serve.mjs';
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
+import { close } from './fixture.mjs';
+
+const path = '/api/chat/instances';
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
+const snapshot = {
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
+ instances: {
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
+ },
+};
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
+ const address = server.address(), unix = typeof address === 'string';
+ return new Promise((resolve, reject) => {
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
+ let text = '';
+ res.on('data', chunk => text += chunk);
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
+ });
+ req.on('error', reject); req.end();
+ });
+}
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
+ let calls = 0;
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
+ t.after(() => close(server));
+ const refused = await get(server);
+ assert.equal(refused.status, 401);
+ assert.equal(calls, 0);
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
+ assert.equal(calls, 1);
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
+ assert.ok([400, 405].includes(result.status));
+ }
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
+});
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
+ let reads = 0;
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
+ t.after(() => close(server));
+ const answer = await get(server);
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
+ assert.equal(reads, 1);
+});
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.ok(answer.status >= 400);
+ assert.doesNotMatch(answer.text, /private-path/);
+ }
+});
+
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
+ let session = null, reads = 0;
+ const touches = [];
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
+ t.after(() => close(server));
+ const headers = { host: sso.publicHost };
+ assert.equal((await get(server, { headers })).status, 401);
+ assert.equal(reads, 0);
+ session = { name: 'jason' };
+ assert.equal((await get(server, { headers })).status, 200);
+ assert.equal(reads, 1);
+ assert.deepEqual(touches, [false, false]);
+});
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 8c11611cf..b47ecf1a7 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
});
+
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
+ const c = await setup(t);
+ c.state.answer = { id: T, instance: 'bean' };
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
+ assert.equal(answer.data.instance, 'bean');
+});
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
+ const c = await setup(t);
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
+ assert.equal(answer.status, 400, JSON.stringify(value));
+ }
+ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
+ assert.deepEqual(c.calls, []);
+});
@@ -0,0 +1,14 @@
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
cbfd54e6b375561d1e4679ee8d78a3285f970d9a7198cec9d22a06510a36afba packages/webui/src/chat-http.mjs
f49a49c6ec69783190d3f5eaf34316cb4d2c44d10d2ee9336fe1383207afefff packages/webui/src/chat.mjs
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
a597cc4902a873fe15650dacfce14dbc897db26310a7b6a24c293287add946c8 packages/webui/src/serve.mjs
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,14 @@
packages/bus/src/broker.mjs
packages/bus/tests/creative.test.mjs
packages/bus/tests/threads.test.mjs
packages/bus/tests/user-principal.test.mjs
packages/cli/src/launcher.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/thread-launch.test.mjs
packages/webui/src/chat-http.mjs
packages/webui/src/chat.mjs
packages/webui/src/public/chat.js
packages/webui/src/serve.mjs
packages/webui/tests/chat-browser.test.mjs
packages/webui/tests/chat-instances.test.mjs
packages/webui/tests/chat.test.mjs
@@ -0,0 +1,17 @@
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 84f8ed088..fa3adb866 100644
--- a/packages/cli/tests/thread-launch.test.mjs
+++ b/packages/cli/tests/thread-launch.test.mjs
@@ -486,8 +486,10 @@ test('C2-09a: creative thread messages start distinct runs and keep each instanc
assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
- assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
- assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
const launches = launchLog(f).filter(row => row.event === 'launch');
assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
@@ -0,0 +1,17 @@
[
{
"path": "packages/webui/src/chat-http.mjs",
"baseDelta": "463a78d259c86d16e4c081bd2336f666477463b8",
"tipDelta": "463a78d259c86d16e4c081bd2336f666477463b8"
},
{
"path": "packages/webui/src/chat.mjs",
"baseDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948",
"tipDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948"
},
{
"path": "packages/webui/src/serve.mjs",
"baseDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928",
"tipDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928"
}
]
+5
View File
@@ -0,0 +1,5 @@
# Row219 rebase round r3
See ../BUILD.md rebase round section. Base80793d824; no conflicts.
Range-diff, per-path identities, row294 carry identities and exact test
reader inventory are adjacent. No tests run. Dewey checks rebase only.
+1
View File
@@ -0,0 +1 @@
80793d824e05baaef4820f4be7790aba55971b6f
+788
View File
@@ -0,0 +1,788 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d76085739..11061c778 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
});
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
@@ -598,7 +598,7 @@ export class Broker {
);
}
#threaded(business, role) {
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
}
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
@@ -6068,24 +6068,27 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
- // A thread belongs to the user who made it and to the business's one thread instance.
+ // A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
if (!s.tester && !s.user) this.#human(s);
- keys(a, ['project', 'name'], ['project', 'name']);
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
+ const instance = a.instance === undefined
+ ? (instances.length === 1 ? instances[0] : null)
+ : identifier(a.instance);
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
+ fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
- // The bus never picks between instances; the business file declares exactly one.
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (!instances.length) fail('thread-instance-missing');
- if (instances.length > 1) fail('thread-instance-ambiguous');
+ // The human selects an instance; omission is compatible only with one candidate.
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -6094,13 +6097,13 @@ export class Broker {
this.#now(),
s.business,
a.project,
- instances[0],
+ instance,
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
- return { id: tid, instance: instances[0] };
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
+ return { id: tid, instance };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// and the tester's lists only its own (row 175 listed it the business human's).
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
index 6cfbf1cd6..6d421ba19 100644
--- a/packages/bus/tests/creative.test.mjs
+++ b/packages/bus/tests/creative.test.mjs
@@ -44,15 +44,18 @@ function setup(t) {
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
+ const threads = { assistant: thread };
for (const role of Object.keys(roles)) {
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
+ if (['bean', 'quill'].includes(role))
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const count = (sql) => store.get(sql).n;
- return { call, count, store };
+ return { call, count, store, threads };
}
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
});
test('a creative instance sends only to the user and hears only from the user', (t) => {
- const { call, count } = setup(t);
+ const { call, count, threads } = setup(t);
for (const who of ['bean', 'quill']) {
call(who, 'message.send', { to: 'human', body: 'hello' });
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
for (const from of ['chief', 'pm']) {
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
}
- call('human', 'message.send', { to: who, body: 'hello' });
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
}
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index 8b31da9f9..1410ec3fa 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t =>
{ id: linked.id, name: 'work', repo: 'stack' },
]);
});
+
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
+ } }));
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
+ for (const [instance, role] of Object.entries(extra)) {
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
+ assert.equal(receipt.instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
+ ctx.launch(instance, instance, { thread: receipt.id });
+ assert.equal(ctx.claim(instance).thread, receipt.id);
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
+ const read = ctx.call(instance, 'message.receive');
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
+ { code: 'thread-required-use-thread-send' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
+});
+
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
+ const ctx = setup(t);
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ for (const instance of ['not-declared', 'pm', 'coder'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
+ assert.equal(legacy.instance, 'assistant');
+});
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
index 4dcd1ebeb..8471ddf85 100644
--- a/packages/bus/tests/user-principal.test.mjs
+++ b/packages/bus/tests/user-principal.test.mjs
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
});
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
-function setup(t, { grants = [grant()], processing = null } = {}) {
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
const store = new Store(root);
t.after(() => {
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
+ ...threadRoles,
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
test('R3 r4: a non-function access throws before the store is read', () => {
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
});
+
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
+ ctx.access.doc.grants = [grant({ projects: [project] })];
+ ctx.user('alice');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
+ assert.equal(allowed.instance, 'assistant');
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
+ assert.equal(bean.instance, 'bean');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
+});
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index bfe55b61e..d8248284c 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -53,7 +53,7 @@
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
-// role definition is the assistant) runs once per thread, never on its own:
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
// a launch from a session or --pm refuses thread-required. Every tick
// (threadTickMs) the host asks the broker for the business's threads
// (launch op threadsState) and:
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
import { createLoader } from "../../plugins/src/loader.mjs";
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// How often the host asks for the threads' state; a poll period, not a limit.
const THREAD_TICK_MS = 2000;
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
-const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
@@ -331,7 +330,7 @@ export function createLauncher({
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
@@ -997,7 +996,7 @@ export function createLauncher({
function ending(r) {
const runs = r.runs ?? [];
const at = Date.parse(r.at);
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
}
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 2202c7753..baabd843e 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const dir = join(given.root, "config", "personas", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
- const { f, host, launcher } = await setup(t, undefined, given);
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
- const [e] = log(f);
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
+ const thread = 'persona-thread';
+ const SESSION = 'persona-session';
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
+ ...host,
+ op: message => message.op === 'threadsState'
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
+ : host.op(message),
+ }) });
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
+ await launcher.tick();
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
+ const entries = log(f);
+ assert.equal(entries.length, 2);
+ const { at, ...compactions } = entries[0];
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
+ const e = entries[1];
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
+ assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
assert.equal(runs.length, 1);
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
});
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 0d207a0a0..fa3adb866 100644
--- a/packages/cli/tests/thread-launch.test.mjs
+++ b/packages/cli/tests/thread-launch.test.mjs
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
+// Admission refuses before bundle construction or a sandbox is needed.
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
+ const prepared = prepare(t, { edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ doc.launch.instances.push('bean');
+ return doc;
+ } });
+ const { f } = prepared;
+ const host = await start(t, prepared);
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
+});
+
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
// bus's plug-in loader.
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
assert.deepEqual(threadRuns(f, A), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
});
+
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ const vars = { ...doc.roles.assistant.vars };
+ delete doc.roles.assistant;
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
+ doc.launch.instances = ['coder', 'bean', 'chet'];
+ return doc;
+ } });
+ const host = await start(t, prepared), { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
+ const send = async (thread, instance, body) => {
+ const message = host.call('thread.send', { thread, body });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ const reply = outcome;
+ assert.equal(reply.from_role, instance);
+ assert.equal(reply.to_role, 'human');
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
+ };
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
+ const launches = launchLog(f).filter(row => row.event === 'launch');
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+});
+
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ delete doc.roles.assistant;
+ doc.launch.instances = ['coder', 'bean'];
+ return doc;
+ } });
+ let requests = [];
+ const host = await start(t, prepared, { ops: () => ({
+ lifecycleRequests: () => requests,
+ lifecycleTake: () => ({ ...requests[0], go: true }),
+ lifecycleDone: () => { requests = []; return {}; },
+ }) });
+ const { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
+ for (const thread of threads) {
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ }
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
+ await host.launcher.tick();
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
+});
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index 7980c1442..63d3ca974 100644
--- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') {
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
+ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
+ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 1b0a41dce..5a1ed8bf1 100644
--- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
+ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
open: ({ thread }) => channel('thread.open', { thread }),
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
index a6b805c1b..15066bdc6 100644
--- a/packages/webui/src/public/chat.js
+++ b/packages/webui/src/public/chat.js
@@ -14,7 +14,7 @@
return states.get(id);
};
const form = key => {
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
return forms.get(key);
};
class ChatError extends Error {
@@ -58,7 +58,11 @@
function creation(kind, key) {
const f = form(key), project = kind === 'project';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
+ const choices = current.instances ?? [];
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
@@ -106,7 +110,7 @@
const f = document.getElementById(keep.field.id);
if (f && !f.disabled) {
f.focus({ preventScroll: true });
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
}
@@ -126,7 +130,7 @@
paint(); return;
}
const old = current;
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
+ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
const s = kind === 'thread' ? state(id) : null;
@@ -147,6 +151,25 @@
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
}
}
+ if (kind === 'project') {
+ try {
+ const listed = await request('instances', undefined, signal);
+ if (g !== generation || out()) return;
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
+ throw new ChatError('invalid-response');
+ current.instances = listed.rows; current.instanceError = '';
+ const f = form(id);
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
+ f.instance = ''; f.instanceChosen = true;
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
+ }
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
+ } catch (err) {
+ if (err.name === 'AbortError' || out() || g !== generation) return;
+ current.instances = [];
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
+ }
+ }
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
if (kind === 'list') {
try {
@@ -179,7 +202,11 @@
if (!current || out()) return;
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
+ if (f && event.target.dataset.chatField) {
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
+ draft[field] = event.target.value;
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
+ }
});
document.addEventListener('scroll', event => {
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
@@ -232,7 +259,8 @@
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
}
});
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index cd51a0b6e..56fcdd4e1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
}
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
+ if (path === '/api/chat/instances') {
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
+ if (search) return json(res, 400, { error: 'invalid-request' });
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
+ try {
+ const value = await bus.instances();
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
+ throw new BusReadError('invalid-response');
+ const rows = Object.entries(value.instances)
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
+ return json(res, 200, { rows, at: new Date().toISOString() });
+ } catch (err) {
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
+ return json(res, busStatus(code), { error: code });
+ }
+ }
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index f3eeeca96..cc3ec093d 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
const stub = `(() => {
const real = fetch.bind(window);
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
+ window.__instances=[{instance:'assistant',name:'assistant'}];
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
window.fetch=async (url,init={})=>{
@@ -33,6 +34,7 @@ const stub = `(() => {
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
+ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
if(window.__pages.length)return data(window.__pages.shift());
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
await click('#chat-thread-form button[type=submit]');
await wait('document.querySelector("#chat-send-form")');
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
assert.equal(await b.evaluate('window.__writes.length'), 2);
});
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
+
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
+ const { b, wait, set, click, refresh } = await setup(t);
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
+ assert.match(labels[1], /\(bean\)/);
+ assert.match(labels[2], /\(chet\)/);
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
+ assert.equal(await b.evaluate('window.injected'), undefined);
+ await set('#chat-thread-name', 'Bean discussion');
+ await click('#chat-thread-form button');
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+ await set('#chat-instance', 'bean');
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
+ await click('#chat-thread-form button');
+ await wait('window.__writes.length===1');
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
+});
+
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
+ const { b, wait, set, refresh } = await setup(t);
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
+ await refresh();
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ await set('#chat-instance', 'bean');
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
+ await set('#chat-instance', 'chet');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+});
+
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
+ const { b, wait } = await setup(t, '?readonly');
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+});
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
new file mode 100644
index 000000000..893c38f80
--- /dev/null
+++ b/packages/webui/tests/chat-instances.test.mjs
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { request } from 'node:http';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { startServer } from '../src/serve.mjs';
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
+import { close } from './fixture.mjs';
+
+const path = '/api/chat/instances';
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
+const snapshot = {
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
+ instances: {
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
+ },
+};
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
+ const address = server.address(), unix = typeof address === 'string';
+ return new Promise((resolve, reject) => {
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
+ let text = '';
+ res.on('data', chunk => text += chunk);
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
+ });
+ req.on('error', reject); req.end();
+ });
+}
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
+ let calls = 0;
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
+ t.after(() => close(server));
+ const refused = await get(server);
+ assert.equal(refused.status, 401);
+ assert.equal(calls, 0);
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
+ assert.equal(calls, 1);
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
+ assert.ok([400, 405].includes(result.status));
+ }
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
+});
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
+ let reads = 0;
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
+ t.after(() => close(server));
+ const answer = await get(server);
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
+ assert.equal(reads, 1);
+});
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.ok(answer.status >= 400);
+ assert.doesNotMatch(answer.text, /private-path/);
+ }
+});
+
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
+ let session = null, reads = 0;
+ const touches = [];
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
+ t.after(() => close(server));
+ const headers = { host: sso.publicHost };
+ assert.equal((await get(server, { headers })).status, 401);
+ assert.equal(reads, 0);
+ session = { name: 'jason' };
+ assert.equal((await get(server, { headers })).status, 200);
+ assert.equal(reads, 1);
+ assert.deepEqual(touches, [false, false]);
+});
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 8c11611cf..b47ecf1a7 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
});
+
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
+ const c = await setup(t);
+ c.state.answer = { id: T, instance: 'bean' };
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
+ assert.equal(answer.data.instance, 'bean');
+});
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
+ const c = await setup(t);
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
+ assert.equal(answer.status, 400, JSON.stringify(value));
+ }
+ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
+ assert.deepEqual(c.calls, []);
+});
@@ -0,0 +1,14 @@
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,14 @@
packages/bus/src/broker.mjs
packages/bus/tests/creative.test.mjs
packages/bus/tests/threads.test.mjs
packages/bus/tests/user-principal.test.mjs
packages/cli/src/launcher.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/thread-launch.test.mjs
packages/webui/src/chat-http.mjs
packages/webui/src/chat.mjs
packages/webui/src/public/chat.js
packages/webui/src/serve.mjs
packages/webui/tests/chat-browser.test.mjs
packages/webui/tests/chat-instances.test.mjs
packages/webui/tests/chat.test.mjs
@@ -0,0 +1 @@
No conflicts. No manual resolutions or product edits. All five commits auto-rebased.
@@ -0,0 +1,131 @@
{
"method": "resolve literal relative import/URL/file paths in packages/*/tests/**/*.mjs; reverse test-helper dependency closure; no tests run",
"readers": {
"packages/webui/src/chat-http.mjs": {
"directModules": [
"packages/webui/tests/chat.test.mjs"
],
"wholeTestReaders": [
"packages/webui/tests/chat.test.mjs"
]
},
"packages/webui/src/chat.mjs": {
"directModules": [
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/tester-fixture.mjs"
],
"wholeTestReaders": [
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/proof-cli.test.mjs",
"packages/webui/tests/tester-browser.test.mjs",
"packages/webui/tests/tester-cli.test.mjs",
"packages/webui/tests/tester-listener.test.mjs"
]
},
"packages/webui/src/serve.mjs": {
"directModules": [
"packages/webui/tests/agents-browser.test.mjs",
"packages/webui/tests/agents.test.mjs",
"packages/webui/tests/assistant.test.mjs",
"packages/webui/tests/board-held.test.mjs",
"packages/webui/tests/browser-edge.test.mjs",
"packages/webui/tests/bus-browser.test.mjs",
"packages/webui/tests/bus.test.mjs",
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat-browser.test.mjs",
"packages/webui/tests/chat-instances.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/discord.test.mjs",
"packages/webui/tests/docs-browser.test.mjs",
"packages/webui/tests/editor-browser.test.mjs",
"packages/webui/tests/fixture.mjs",
"packages/webui/tests/granted-user.test.mjs",
"packages/webui/tests/history-fixture.mjs",
"packages/webui/tests/instances-browser.test.mjs",
"packages/webui/tests/instances.test.mjs",
"packages/webui/tests/limits-browser.test.mjs",
"packages/webui/tests/limits-route.test.mjs",
"packages/webui/tests/messages.test.mjs",
"packages/webui/tests/preview.test.mjs",
"packages/webui/tests/processing-browser.test.mjs",
"packages/webui/tests/processing.test.mjs",
"packages/webui/tests/project-file.test.mjs",
"packages/webui/tests/proof.test.mjs",
"packages/webui/tests/reads.test.mjs",
"packages/webui/tests/relaunch.test.mjs",
"packages/webui/tests/return-flow.test.mjs",
"packages/webui/tests/routes-browser.test.mjs",
"packages/webui/tests/routes.test.mjs",
"packages/webui/tests/send-browser.test.mjs",
"packages/webui/tests/send.test.mjs",
"packages/webui/tests/serve.test.mjs",
"packages/webui/tests/settings-form.test.mjs",
"packages/webui/tests/settings-write.test.mjs",
"packages/webui/tests/shell.test.mjs",
"packages/webui/tests/sso-browser.test.mjs",
"packages/webui/tests/sso.test.mjs",
"packages/webui/tests/tester-listener.test.mjs",
"packages/webui/tests/thread-claims-browser.test.mjs",
"packages/webui/tests/thread-claims.test.mjs",
"packages/webui/tests/usage-browser.test.mjs",
"packages/webui/tests/usage.test.mjs",
"packages/webui/tests/views.test.mjs"
],
"wholeTestReaders": [
"packages/webui/tests/agents-browser.test.mjs",
"packages/webui/tests/agents.test.mjs",
"packages/webui/tests/assistant.test.mjs",
"packages/webui/tests/board-held.test.mjs",
"packages/webui/tests/browser-edge.test.mjs",
"packages/webui/tests/browser.test.mjs",
"packages/webui/tests/bus-browser.test.mjs",
"packages/webui/tests/bus.test.mjs",
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat-browser.test.mjs",
"packages/webui/tests/chat-instances.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/conversation.test.mjs",
"packages/webui/tests/discord.test.mjs",
"packages/webui/tests/docs-browser.test.mjs",
"packages/webui/tests/editor-browser.test.mjs",
"packages/webui/tests/editor.test.mjs",
"packages/webui/tests/granted-user.test.mjs",
"packages/webui/tests/history-return-flow.test.mjs",
"packages/webui/tests/instances-browser.test.mjs",
"packages/webui/tests/instances.test.mjs",
"packages/webui/tests/limits-browser.test.mjs",
"packages/webui/tests/limits-route.test.mjs",
"packages/webui/tests/messages.test.mjs",
"packages/webui/tests/preview.test.mjs",
"packages/webui/tests/processing-browser.test.mjs",
"packages/webui/tests/processing.test.mjs",
"packages/webui/tests/project-file.test.mjs",
"packages/webui/tests/proof-cli.test.mjs",
"packages/webui/tests/proof.test.mjs",
"packages/webui/tests/reads.test.mjs",
"packages/webui/tests/relaunch.test.mjs",
"packages/webui/tests/return-flow.test.mjs",
"packages/webui/tests/routes-browser.test.mjs",
"packages/webui/tests/routes.test.mjs",
"packages/webui/tests/send-browser.test.mjs",
"packages/webui/tests/send.test.mjs",
"packages/webui/tests/serve.test.mjs",
"packages/webui/tests/settings-form.test.mjs",
"packages/webui/tests/settings-write.test.mjs",
"packages/webui/tests/shell.test.mjs",
"packages/webui/tests/sso-browser.test.mjs",
"packages/webui/tests/sso.test.mjs",
"packages/webui/tests/tester-browser.test.mjs",
"packages/webui/tests/tester-cli.test.mjs",
"packages/webui/tests/tester-listener.test.mjs",
"packages/webui/tests/thread-claims-browser.test.mjs",
"packages/webui/tests/thread-claims.test.mjs",
"packages/webui/tests/usage-browser.test.mjs",
"packages/webui/tests/usage.test.mjs",
"packages/webui/tests/views.test.mjs"
]
}
}
}
@@ -0,0 +1,72 @@
[
{
"path": "packages/bus/src/broker.mjs",
"old": "34f42b312c3d4ae9a975c5d27214414606cb9154",
"new": "34f42b312c3d4ae9a975c5d27214414606cb9154"
},
{
"path": "packages/bus/tests/creative.test.mjs",
"old": "69deed0c277da57ef36d09097f4164180e744960",
"new": "69deed0c277da57ef36d09097f4164180e744960"
},
{
"path": "packages/bus/tests/threads.test.mjs",
"old": "83597aea2f53a587b338b32a8e25e9ddac427ca1",
"new": "83597aea2f53a587b338b32a8e25e9ddac427ca1"
},
{
"path": "packages/bus/tests/user-principal.test.mjs",
"old": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3",
"new": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3"
},
{
"path": "packages/cli/src/launcher.mjs",
"old": "ef04082e6e0ce574ca3af546871838ac34a5e5ee",
"new": "ef04082e6e0ce574ca3af546871838ac34a5e5ee"
},
{
"path": "packages/cli/tests/launcher.test.mjs",
"old": "4449e824038428b8dd4e5c299cc126ab1e842b58",
"new": "4449e824038428b8dd4e5c299cc126ab1e842b58"
},
{
"path": "packages/cli/tests/thread-launch.test.mjs",
"old": "0fef61471873c35652605168dedd7dcd22cb4944",
"new": "0fef61471873c35652605168dedd7dcd22cb4944"
},
{
"path": "packages/webui/src/chat-http.mjs",
"old": "ccf9e56879a90656c7061ce0d614bfaaf04987ba",
"new": "ccf9e56879a90656c7061ce0d614bfaaf04987ba"
},
{
"path": "packages/webui/src/chat.mjs",
"old": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1",
"new": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1"
},
{
"path": "packages/webui/src/public/chat.js",
"old": "f114af96936122f620ebf0a561e3a41a0ff6c955",
"new": "f114af96936122f620ebf0a561e3a41a0ff6c955"
},
{
"path": "packages/webui/src/serve.mjs",
"old": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a",
"new": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a"
},
{
"path": "packages/webui/tests/chat-browser.test.mjs",
"old": "8cb7a6270bc8c0f2191caae713842609df0deb92",
"new": "8cb7a6270bc8c0f2191caae713842609df0deb92"
},
{
"path": "packages/webui/tests/chat-instances.test.mjs",
"old": "8018efd8ad48108293e41b77fc6c786500ef2471",
"new": "8018efd8ad48108293e41b77fc6c786500ef2471"
},
{
"path": "packages/webui/tests/chat.test.mjs",
"old": "acb49d2d659373b56409cf144f56fe6c29ffdac5",
"new": "acb49d2d659373b56409cf144f56fe6c29ffdac5"
}
]
@@ -0,0 +1,14 @@
1: d5bf46ecf ! 1: 268835a99 wip: publish unverified row 219 inspection candidate
@@ packages/webui/src/chat-http.mjs: export async function chatHTTP(req, res, { pat
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
## packages/webui/src/chat.mjs ##
-@@ packages/webui/src/chat.mjs: export function createChat({ call }) {
+@@ packages/webui/src/chat.mjs: export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
2: c7bf3622d = 2: 994860e70 docs: rebase row 219 gate packet and record run bounds
3: b97a3ff97 = 3: 0e040cf94 test: give row 219 persona fixture its explicit session
4: 7cbd839d0 = 4: 30cf10301 test: pin the compaction audit before the persona refusal
5: 5f2ef92e5 = 5: 28bf34f27 test: assert creative history in its native session store
@@ -0,0 +1 @@
28bf34f272ec8e0ad0f7155556d2c92cdb3dd5be
+22
View File
@@ -0,0 +1,22 @@
# Row219 r4 / mos1429
Approved setup-only creative ops thread binding, exact import pin update,
and new direct broker bindLaunch regression. See root BUILD.md and the
assertion-preservation JSON/diff. Red-first evidence awaits Sage CLEAR.
No product changes; root and this round list16 paths. T24 is new; T01 now
targets the direct creative bind refusal. 24 mutants in the full run.
## Short slot receipt, sage-3302
2026-10-23 08:41:20Z-08:41:45Z, HEAD 7e70de9e6, manifest
451d3fcbeb6a41c3a505600898ba2025985f44d8391e2cd823ab84f1d4b50621.
Detached bfa421b6c reds: sage-1908 thread-required at bindLaunch;
project-runtime ERR_ASSERTION on exact singular/plural import pin.
Whole green files: grant-executor 42/0/0, project-runtime 3/0/0,
threads 20/0/0, total 65/0/0, no cancellations or skips.
T24 assertion-killed. Its exact failure was "Expected values to be strictly
equal", actual 'decision-required', expected 'host-creative-role', code
ERR_ASSERTION. Removing the creative guard reaches the later decision-required
fence; this is a code-mismatch kill, not proof that host.run executed.
All 16 hashes restored, cwd node/chromium sweep empty. Raw TAP and per-stage
exit/duration/counts are in short-run/. No unexpected red and no retry.
@@ -0,0 +1,26 @@
# mos-1429 short slot (not run)
Wait for Sage CLEAR. Estimate 3-5 minutes. No Docker, browser or suites.
Use a fresh short TMPDIR under /mnt/storage/scratch/tmp for each stage,
a dead DOCKER_HOST, explicit TAP, timeout -k 10 300 and a process group.
Check the green HEAD and 16-path manifest before any stage; check the red
worktree HEAD is bfa421b6cd9b2b54d8da119e73b004aeafe606c3.
1. Red worktree /mnt/storage/scratch/rocko-r219-red-mos1429:
grant-executor.test.mjs, pattern `sage-1908: once the agent of an applied grant is a creative role`.
Require the named test to fail with thread-required at bindLaunch.
2. Same red worktree: project-runtime.test.mjs, pattern
`Broker keeps the sandbox client import closure`.
Require ERR_ASSERTION on the singular/plural import pin.
3. Green worktree: grant-executor.test.mjs, project-runtime.test.mjs,
threads.test.mjs, each whole, sequential. Require zero failed/cancelled.
4. T24: remove only the host.run creative guard using the table's exact-once
anchor. Run the sage-1908 pattern; require its named not-ok and
ERR_ASSERTION. Syntax errors and timeouts are invalid, never kills.
5. Restore exact bytes in finally, verify all 16 hashes, sweep owned
process groups and cwd node/chromium processes, return slot with counts.
Preserve raw TAP, exits, durations and skip lines. Push only as granted.
Any unexpected red stops without retry or edit and goes to Sage for Mos.
The later full restart uses 10 touched whole files, bus/cli/webui, all shell
suites, 24 mutants and the reader audit, on a separate CLEAR after review.
@@ -0,0 +1,52 @@
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
index e449b7d77..46235d16b 100644
--- a/packages/bus/tests/grant-executor.test.mjs
+++ b/packages/bus/tests/grant-executor.test.mjs
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
const f = await setup(t);
assert.equal((await f.approve()).end.action, 'grant.applied');
const r = await restart(t, f, creativeOps(f.business));
- const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
+ const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
+ const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
+ const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
r.broker.request(cap, { verb: 'role.claim' });
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
const before = f.requests().length;
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
index a6086e56c..7275ad902 100644
--- a/packages/bus/tests/project-runtime.test.mjs
+++ b/packages/bus/tests/project-runtime.test.mjs
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
+ // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index 1410ec3fa..280e03184 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -640,3 +640,17 @@ test('C2-09a: explicit missing, non-thread and malformed selectors create no thr
const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
assert.equal(legacy.instance, 'assistant');
});
+
+
+test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
+ } }));
+ for (const instance of Object.keys(extra)) {
+ assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
+});
@@ -0,0 +1,46 @@
{
"baseHead": "bfa421b6cd9b2b54d8da119e73b004aeafe606c3",
"ruling": "mos-1429 / sage-3291",
"grantTest": {
"old": "test('sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record', async (t) => {\n const f = await setup(t);\n assert.equal((await f.approve()).end.action, 'grant.applied');\n const r = await restart(t, f, creativeOps(f.business));\n const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });\n r.broker.request(cap, { verb: 'role.claim' });\n const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });\n const before = f.requests().length;\n await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');\n assert.equal(f.requests().length, before);\n assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);\n assert.notEqual(f.record(), undefined);\n});\n",
"new": "test('sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record', async (t) => {\n const f = await setup(t);\n assert.equal((await f.approve()).end.action, 'grant.applied');\n const r = await restart(t, f, creativeOps(f.business));\n const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });\n const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;\n const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;\n const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });\n r.broker.request(cap, { verb: 'role.claim' });\n const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });\n const before = f.requests().length;\n await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');\n assert.equal(f.requests().length, before);\n assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);\n assert.notEqual(f.record(), undefined);\n});\n",
"preservedOldLines": [
" assert.equal((await f.approve()).end.action, 'grant.applied');",
" const r = await restart(t, f, creativeOps(f.business));",
" const before = f.requests().length;",
" await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');",
" assert.equal(f.requests().length, before);",
" assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);",
" assert.notEqual(f.record(), undefined);"
],
"preservedNewLines": [
" assert.equal((await f.approve()).end.action, 'grant.applied');",
" const r = await restart(t, f, creativeOps(f.business));",
" const before = f.requests().length;",
" await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');",
" assert.equal(f.requests().length, before);",
" assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);",
" assert.notEqual(f.record(), undefined);"
]
},
"projectRuntime": {
"oldPin": [
" \"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';\","
],
"newPin": [
" \"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';\","
],
"oldDoesNotMatch": [
" assert.doesNotMatch(source, /\\bimport\\s*\\(/);",
" assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
" assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
" assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);"
],
"newDoesNotMatch": [
" assert.doesNotMatch(source, /\\bimport\\s*\\(/);",
" assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
" assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
" assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);"
]
}
}
+1
View File
@@ -0,0 +1 @@
80793d824e05baaef4820f4be7790aba55971b6f
+832
View File
@@ -0,0 +1,832 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d76085739..11061c778 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
});
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
@@ -598,7 +598,7 @@ export class Broker {
);
}
#threaded(business, role) {
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
}
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
@@ -6068,24 +6068,27 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
- // A thread belongs to the user who made it and to the business's one thread instance.
+ // A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
if (!s.tester && !s.user) this.#human(s);
- keys(a, ['project', 'name'], ['project', 'name']);
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
+ const instance = a.instance === undefined
+ ? (instances.length === 1 ? instances[0] : null)
+ : identifier(a.instance);
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
+ fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
- // The bus never picks between instances; the business file declares exactly one.
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (!instances.length) fail('thread-instance-missing');
- if (instances.length > 1) fail('thread-instance-ambiguous');
+ // The human selects an instance; omission is compatible only with one candidate.
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -6094,13 +6097,13 @@ export class Broker {
this.#now(),
s.business,
a.project,
- instances[0],
+ instance,
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
- return { id: tid, instance: instances[0] };
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
+ return { id: tid, instance };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// and the tester's lists only its own (row 175 listed it the business human's).
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
index 6cfbf1cd6..6d421ba19 100644
--- a/packages/bus/tests/creative.test.mjs
+++ b/packages/bus/tests/creative.test.mjs
@@ -44,15 +44,18 @@ function setup(t) {
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
+ const threads = { assistant: thread };
for (const role of Object.keys(roles)) {
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
+ if (['bean', 'quill'].includes(role))
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const count = (sql) => store.get(sql).n;
- return { call, count, store };
+ return { call, count, store, threads };
}
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
});
test('a creative instance sends only to the user and hears only from the user', (t) => {
- const { call, count } = setup(t);
+ const { call, count, threads } = setup(t);
for (const who of ['bean', 'quill']) {
call(who, 'message.send', { to: 'human', body: 'hello' });
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
for (const from of ['chief', 'pm']) {
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
}
- call('human', 'message.send', { to: who, body: 'hello' });
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
}
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
index e449b7d77..46235d16b 100644
--- a/packages/bus/tests/grant-executor.test.mjs
+++ b/packages/bus/tests/grant-executor.test.mjs
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
const f = await setup(t);
assert.equal((await f.approve()).end.action, 'grant.applied');
const r = await restart(t, f, creativeOps(f.business));
- const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
+ const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
+ const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
+ const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
r.broker.request(cap, { verb: 'role.claim' });
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
const before = f.requests().length;
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
index a6086e56c..7275ad902 100644
--- a/packages/bus/tests/project-runtime.test.mjs
+++ b/packages/bus/tests/project-runtime.test.mjs
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
+ // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index 8b31da9f9..280e03184 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -601,3 +601,56 @@ test('project.create keeps unrelated names with and without a repository', t =>
{ id: linked.id, name: 'work', repo: 'stack' },
]);
});
+
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
+ } }));
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
+ for (const [instance, role] of Object.entries(extra)) {
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
+ assert.equal(receipt.instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
+ ctx.launch(instance, instance, { thread: receipt.id });
+ assert.equal(ctx.claim(instance).thread, receipt.id);
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
+ const read = ctx.call(instance, 'message.receive');
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
+ { code: 'thread-required-use-thread-send' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
+});
+
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
+ const ctx = setup(t);
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ for (const instance of ['not-declared', 'pm', 'coder'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
+ assert.equal(legacy.instance, 'assistant');
+});
+
+
+test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
+ } }));
+ for (const instance of Object.keys(extra)) {
+ assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
+});
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
index 4dcd1ebeb..8471ddf85 100644
--- a/packages/bus/tests/user-principal.test.mjs
+++ b/packages/bus/tests/user-principal.test.mjs
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
});
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
-function setup(t, { grants = [grant()], processing = null } = {}) {
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
const store = new Store(root);
t.after(() => {
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
+ ...threadRoles,
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
test('R3 r4: a non-function access throws before the store is read', () => {
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
});
+
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
+ ctx.access.doc.grants = [grant({ projects: [project] })];
+ ctx.user('alice');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
+ assert.equal(allowed.instance, 'assistant');
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
+ assert.equal(bean.instance, 'bean');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
+});
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index bfe55b61e..d8248284c 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -53,7 +53,7 @@
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
-// role definition is the assistant) runs once per thread, never on its own:
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
// a launch from a session or --pm refuses thread-required. Every tick
// (threadTickMs) the host asks the broker for the business's threads
// (launch op threadsState) and:
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
import { createLoader } from "../../plugins/src/loader.mjs";
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// How often the host asks for the threads' state; a poll period, not a limit.
const THREAD_TICK_MS = 2000;
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
-const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
@@ -331,7 +330,7 @@ export function createLauncher({
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
@@ -997,7 +996,7 @@ export function createLauncher({
function ending(r) {
const runs = r.runs ?? [];
const at = Date.parse(r.at);
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
}
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 2202c7753..baabd843e 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const dir = join(given.root, "config", "personas", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
- const { f, host, launcher } = await setup(t, undefined, given);
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
- const [e] = log(f);
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
+ const thread = 'persona-thread';
+ const SESSION = 'persona-session';
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
+ ...host,
+ op: message => message.op === 'threadsState'
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
+ : host.op(message),
+ }) });
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
+ await launcher.tick();
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
+ const entries = log(f);
+ assert.equal(entries.length, 2);
+ const { at, ...compactions } = entries[0];
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
+ const e = entries[1];
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
+ assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
assert.equal(runs.length, 1);
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
});
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 0d207a0a0..fa3adb866 100644
--- a/packages/cli/tests/thread-launch.test.mjs
+++ b/packages/cli/tests/thread-launch.test.mjs
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
+// Admission refuses before bundle construction or a sandbox is needed.
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
+ const prepared = prepare(t, { edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ doc.launch.instances.push('bean');
+ return doc;
+ } });
+ const { f } = prepared;
+ const host = await start(t, prepared);
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
+});
+
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
// bus's plug-in loader.
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
assert.deepEqual(threadRuns(f, A), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
});
+
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ const vars = { ...doc.roles.assistant.vars };
+ delete doc.roles.assistant;
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
+ doc.launch.instances = ['coder', 'bean', 'chet'];
+ return doc;
+ } });
+ const host = await start(t, prepared), { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
+ const send = async (thread, instance, body) => {
+ const message = host.call('thread.send', { thread, body });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ const reply = outcome;
+ assert.equal(reply.from_role, instance);
+ assert.equal(reply.to_role, 'human');
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
+ };
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
+ const launches = launchLog(f).filter(row => row.event === 'launch');
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+});
+
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ delete doc.roles.assistant;
+ doc.launch.instances = ['coder', 'bean'];
+ return doc;
+ } });
+ let requests = [];
+ const host = await start(t, prepared, { ops: () => ({
+ lifecycleRequests: () => requests,
+ lifecycleTake: () => ({ ...requests[0], go: true }),
+ lifecycleDone: () => { requests = []; return {}; },
+ }) });
+ const { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
+ for (const thread of threads) {
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ }
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
+ await host.launcher.tick();
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
+});
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index 7980c1442..63d3ca974 100644
--- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') {
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
+ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
+ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 1b0a41dce..5a1ed8bf1 100644
--- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
+ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
open: ({ thread }) => channel('thread.open', { thread }),
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
index a6b805c1b..15066bdc6 100644
--- a/packages/webui/src/public/chat.js
+++ b/packages/webui/src/public/chat.js
@@ -14,7 +14,7 @@
return states.get(id);
};
const form = key => {
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
return forms.get(key);
};
class ChatError extends Error {
@@ -58,7 +58,11 @@
function creation(kind, key) {
const f = form(key), project = kind === 'project';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
+ const choices = current.instances ?? [];
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
@@ -106,7 +110,7 @@
const f = document.getElementById(keep.field.id);
if (f && !f.disabled) {
f.focus({ preventScroll: true });
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
}
@@ -126,7 +130,7 @@
paint(); return;
}
const old = current;
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
+ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
const s = kind === 'thread' ? state(id) : null;
@@ -147,6 +151,25 @@
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
}
}
+ if (kind === 'project') {
+ try {
+ const listed = await request('instances', undefined, signal);
+ if (g !== generation || out()) return;
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
+ throw new ChatError('invalid-response');
+ current.instances = listed.rows; current.instanceError = '';
+ const f = form(id);
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
+ f.instance = ''; f.instanceChosen = true;
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
+ }
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
+ } catch (err) {
+ if (err.name === 'AbortError' || out() || g !== generation) return;
+ current.instances = [];
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
+ }
+ }
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
if (kind === 'list') {
try {
@@ -179,7 +202,11 @@
if (!current || out()) return;
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
+ if (f && event.target.dataset.chatField) {
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
+ draft[field] = event.target.value;
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
+ }
});
document.addEventListener('scroll', event => {
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
@@ -232,7 +259,8 @@
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
}
});
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index cd51a0b6e..56fcdd4e1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
}
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
+ if (path === '/api/chat/instances') {
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
+ if (search) return json(res, 400, { error: 'invalid-request' });
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
+ try {
+ const value = await bus.instances();
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
+ throw new BusReadError('invalid-response');
+ const rows = Object.entries(value.instances)
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
+ return json(res, 200, { rows, at: new Date().toISOString() });
+ } catch (err) {
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
+ return json(res, busStatus(code), { error: code });
+ }
+ }
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index f3eeeca96..cc3ec093d 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
const stub = `(() => {
const real = fetch.bind(window);
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
+ window.__instances=[{instance:'assistant',name:'assistant'}];
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
window.fetch=async (url,init={})=>{
@@ -33,6 +34,7 @@ const stub = `(() => {
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
+ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
if(window.__pages.length)return data(window.__pages.shift());
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
await click('#chat-thread-form button[type=submit]');
await wait('document.querySelector("#chat-send-form")');
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
assert.equal(await b.evaluate('window.__writes.length'), 2);
});
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
+
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
+ const { b, wait, set, click, refresh } = await setup(t);
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
+ assert.match(labels[1], /\(bean\)/);
+ assert.match(labels[2], /\(chet\)/);
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
+ assert.equal(await b.evaluate('window.injected'), undefined);
+ await set('#chat-thread-name', 'Bean discussion');
+ await click('#chat-thread-form button');
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+ await set('#chat-instance', 'bean');
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
+ await click('#chat-thread-form button');
+ await wait('window.__writes.length===1');
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
+});
+
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
+ const { b, wait, set, refresh } = await setup(t);
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
+ await refresh();
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ await set('#chat-instance', 'bean');
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
+ await set('#chat-instance', 'chet');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+});
+
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
+ const { b, wait } = await setup(t, '?readonly');
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+});
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
new file mode 100644
index 000000000..893c38f80
--- /dev/null
+++ b/packages/webui/tests/chat-instances.test.mjs
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { request } from 'node:http';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { startServer } from '../src/serve.mjs';
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
+import { close } from './fixture.mjs';
+
+const path = '/api/chat/instances';
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
+const snapshot = {
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
+ instances: {
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
+ },
+};
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
+ const address = server.address(), unix = typeof address === 'string';
+ return new Promise((resolve, reject) => {
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
+ let text = '';
+ res.on('data', chunk => text += chunk);
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
+ });
+ req.on('error', reject); req.end();
+ });
+}
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
+ let calls = 0;
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
+ t.after(() => close(server));
+ const refused = await get(server);
+ assert.equal(refused.status, 401);
+ assert.equal(calls, 0);
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
+ assert.equal(calls, 1);
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
+ assert.ok([400, 405].includes(result.status));
+ }
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
+});
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
+ let reads = 0;
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
+ t.after(() => close(server));
+ const answer = await get(server);
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
+ assert.equal(reads, 1);
+});
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.ok(answer.status >= 400);
+ assert.doesNotMatch(answer.text, /private-path/);
+ }
+});
+
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
+ let session = null, reads = 0;
+ const touches = [];
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
+ t.after(() => close(server));
+ const headers = { host: sso.publicHost };
+ assert.equal((await get(server, { headers })).status, 401);
+ assert.equal(reads, 0);
+ session = { name: 'jason' };
+ assert.equal((await get(server, { headers })).status, 200);
+ assert.equal(reads, 1);
+ assert.deepEqual(touches, [false, false]);
+});
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 8c11611cf..b47ecf1a7 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
});
+
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
+ const c = await setup(t);
+ c.state.answer = { id: T, instance: 'bean' };
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
+ assert.equal(answer.data.instance, 'bean');
+});
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
+ const c = await setup(t);
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
+ assert.equal(answer.status, 400, JSON.stringify(value));
+ }
+ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
+ assert.deepEqual(c.calls, []);
+});
@@ -0,0 +1,16 @@
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
3efd0238da60e0bfcd3b25e5256b9c213085119a86a7b852b13e7a60340d3a24 packages/bus/tests/grant-executor.test.mjs
b7ea857637947c7a9ca45c56cea47e37e06f0fd8446ea91316990d9fa7299a7a packages/bus/tests/project-runtime.test.mjs
264d82944c53b638bf6a14cc0706cf9ba184d668211c2b9c1031cdbe88e84bb9 packages/bus/tests/threads.test.mjs
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,16 @@
packages/bus/src/broker.mjs
packages/bus/tests/creative.test.mjs
packages/bus/tests/grant-executor.test.mjs
packages/bus/tests/project-runtime.test.mjs
packages/bus/tests/threads.test.mjs
packages/bus/tests/user-principal.test.mjs
packages/cli/src/launcher.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/thread-launch.test.mjs
packages/webui/src/chat-http.mjs
packages/webui/src/chat.mjs
packages/webui/src/public/chat.js
packages/webui/src/serve.mjs
packages/webui/tests/chat-browser.test.mjs
packages/webui/tests/chat-instances.test.mjs
packages/webui/tests/chat.test.mjs
@@ -0,0 +1,21 @@
cc98e830b5f8d0a34149e602775f154642e3f5e09cd42427e9deb49c84663fca BUILD.md
2ce65e27aeb4cc563dbe179efbf5a120d6ac48f597e477c3648e58d28d829082 SHORT-RUN-PLAN.md
5209e39f588a709962baae627fa0e66a3bf6ed7d6fe66ca3ec4dce16850ddc47 approved-tests.diff
b05b00cd9f352219b88772e78c055c329fddf5e6b3ad4d4b1067e81ab235e418 assertion-preservation.json
d7e1b464a314c7b079385f0a14a53189fe839d72d7666729026ffcb9d16a0faa base.txt
deb5e2ff721c3b5adc1390c4aaef2d8aaf4f792edbd4e6b2d4766421222c406a build.patch
451d3fcbeb6a41c3a505600898ba2025985f44d8391e2cd823ab84f1d4b50621 candidate-manifest.sha256
5222c9f6d7c1da26f5b9447626ba8e005a7ab36006183ed36df40190b9133fb4 changed-paths.txt
0cd14e6b9ea80f71ede6757e8430052246bb1f5c96c165133b1fa9b467d05262 short-run/T24-receipt.txt
0cd14e6b9ea80f71ede6757e8430052246bb1f5c96c165133b1fa9b467d05262 short-run/T24.tap
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 short-run/cwd-sweep-final.json
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 short-run/cwd-sweep.json
37c331032ac90730c9a3cabae0c467fb0dd5c1991cda5ff98b0fc5d1a553e7b4 short-run/green-grant-executor.tap
d47f011e509107304b01c7f9037af7dfe1d06a45959309d21066c9755a3353b5 short-run/green-project-runtime.tap
f52f3afbd058dd2c6dcf84ed1f3eb69bd4040df599c6f80b30fc60e7837de85b short-run/green-threads.tap
b85b47bbd1cea535817b3077f109be91c703fbaacee4a1fd8d1d6f08acf24c8c short-run/head.json
fdc8c9854f7dbed2d7ebb3da403dac0c88bc2ae6596fb1fa0d9fc5f865f244a0 short-run/red-grant.tap
3033a3f85b738ff2a4c06e90973348fadfa6fc7c16c8192f6c4407bccffdb587 short-run/red-pin.tap
1294cda16078dc1800b800fcf4d4410c89bce9687bd510068d22c3b1eed32c16 short-run/restored.json
72675fa1a56c9e567bea82b4f6897ee3766472b8545f69b3b9e3909532a24500 short-run/results.jsonl
835ab90ef7f52aca6260e13017795cc4102c6dd3267f5ce6d5ca0684aa606029 short-run/runner.py
@@ -0,0 +1,38 @@
TAP version 13
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
not ok 1 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
---
duration_ms: 541.339182
type: 'test'
location: '/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1046:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ 'decision-required'
- 'host-creative-role'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'host-creative-role'
actual: 'decision-required'
operator: 'strictEqual'
stack: |-
Object.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:703:103)
expectedException (node:assert:547:17)
expectsError (node:assert:672:21)
strict.rejects (node:assert:738:3)
async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1057:3)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 619.444691
@@ -0,0 +1,38 @@
TAP version 13
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
not ok 1 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
---
duration_ms: 541.339182
type: 'test'
location: '/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1046:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ 'decision-required'
- 'host-creative-role'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'host-creative-role'
actual: 'decision-required'
operator: 'strictEqual'
stack: |-
Object.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:703:103)
expectedException (node:assert:547:17)
expectsError (node:assert:672:21)
strict.rejects (node:assert:738:3)
async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1057:3)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 619.444691
@@ -0,0 +1 @@
[]
@@ -0,0 +1 @@
[]
@@ -0,0 +1,262 @@
TAP version 13
# Subtest: row 280: an occupied TCP port is attempted, refused, and retried with our sshd
ok 1 - row 280: an occupied TCP port is attempted, refused, and retried with our sshd
---
duration_ms: 84.725551
type: 'test'
...
# Subtest: row 280: every injected picker sample is unprivileged and outside the ephemeral range
ok 2 - row 280: every injected picker sample is unprivileged and outside the ephemeral range
---
duration_ms: 0.343983
type: 'test'
...
# Subtest: G7: an Approve installs the tagged line, logs in with the new key, and writes the key file, record and grant.applied
ok 3 - G7: an Approve installs the tagged line, logs in with the new key, and writes the key file, record and grant.applied
---
duration_ms: 657.889711
type: 'test'
...
# Subtest: G7: a commands change on a live grant updates the record in one transaction and keeps the key and line
ok 4 - G7: a commands change on a live grant updates the record in one transaction and keeps the key and line
---
duration_ms: 531.188801
type: 'test'
...
# Subtest: G8: a failure at install or verify removes the line and leaves no record, key file or reference
ok 5 - G8: a failure at install or verify removes the line and leaves no record, key file or reference
---
duration_ms: 1502.744543
type: 'test'
...
# Subtest: G8: a failed remove adds hostLineLeft and tells the human to sweep
ok 6 - G8: a failed remove adds hostLineLeft and tells the human to sweep
---
duration_ms: 645.757376
type: 'test'
...
# Subtest: G8: a failure in step 5 before commit unlinks the key file, drops the reference and removes the line
ok 7 - G8: a failure in step 5 before commit unlinks the key file, drops the reference and removes the line
---
duration_ms: 632.923531
type: 'test'
...
# Subtest: G8: a store fault in the commit transaction ends host-failed with no record, key file, reference or line
ok 8 - G8: a store fault in the commit transaction ends host-failed with no record, key file, reference or line
---
duration_ms: 648.03379
type: 'test'
...
# Subtest: G8: a failed step 1 check spends the decision and runs nothing remote
ok 9 - G8: a failed step 1 check spends the decision and runs nothing remote
---
duration_ms: 82.428566
type: 'test'
...
# Subtest: G9: a registry, definition or commands change between raise and Approve ends decision-mismatch before any remote step
ok 10 - G9: a registry, definition or commands change between raise and Approve ends decision-mismatch before any remote step
---
duration_ms: 519.400918
type: 'test'
...
# Subtest: G9: an agent no longer a role or no longer declaring host ends with its code, spent
ok 11 - G9: an agent no longer a role or no longer declaring host ends with its code, spent
---
duration_ms: 69.103063
type: 'test'
...
# Subtest: G10: a second start refuses decision-consumed, and an unresolved or agent-resolved decision not-authorized, writing nothing
ok 12 - G10: a second start refuses decision-consumed, and an unresolved or agent-resolved decision not-authorized, writing nothing
---
duration_ms: 511.259562
type: 'test'
...
# Subtest: G15: no event, message or decision holds the private key
ok 13 - G15: no event, message or decision holds the private key
---
duration_ms: 490.757332
type: 'test'
...
# Subtest: G23: a revoke during install ends grant.failed revoked with no key file, no grant and no line
ok 14 - G23: a revoke during install ends grant.failed revoked with no key file, no grant and no line
---
duration_ms: 526.257653
type: 'test'
...
# Subtest: G23 (row 249): an add that writes after an abort's remove leaves hostLineLeft until the sweep
ok 15 - G23 (row 249): an add that writes after an abort's remove leaves hostLineLeft until the sweep
---
duration_ms: 871.678557
type: 'test'
...
# Subtest: G23 (row 249): an add that writes after a timed-out install's remove leaves hostLineLeft until the sweep
ok 16 - G23 (row 249): an add that writes after a timed-out install's remove leaves hostLineLeft until the sweep
---
duration_ms: 864.787135
type: 'test'
...
# Subtest: G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy
ok 17 - G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy
---
duration_ms: 523.685406
type: 'test'
...
# Subtest: G24: names at 64 characters with . and _ give distinct tags of the fixed length
ok 18 - G24: names at 64 characters with . and _ give distinct tags of the fixed length
---
duration_ms: 0.44828
type: 'test'
...
# Subtest: G11: a restart after install removes the line, then ends interrupted with the tag, holding the slot until then
ok 19 - G11: a restart after install removes the line, then ends interrupted with the tag, holding the slot until then
---
duration_ms: 217.311491
type: 'test'
...
# Subtest: G11: a restart after a new key's commit, with the key file written, ends with no key file, record, reference or line
ok 20 - G11: a restart after a new key's commit, with the key file written, ends with no key file, record, reference or line
---
duration_ms: 213.431823
type: 'test'
...
# Subtest: G11: a restart after a commands change's commit leaves the record as it was and asks nothing of the host
ok 21 - G11: a restart after a commands change's commit leaves the record as it was and asks nothing of the host
---
duration_ms: 514.743857
type: 'test'
...
# Subtest: G11 (B2): a restart before step 1, spent or not yet spent, ends interrupted and nothing runs later
ok 22 - G11 (B2): a restart before step 1, spent or not yet spent, ends interrupted and nothing runs later
---
duration_ms: 67.778309
type: 'test'
...
# Subtest: G11: a remote undo that fails ends interrupted with hostLineLeft and the sweep hint
ok 23 - G11: a remote undo that fails ends interrupted with hostLineLeft and the sweep hint
---
duration_ms: 283.505936
type: 'test'
...
# Subtest: G11: a restart leaves a finished grant alone, and the reconcile adds nothing for it
ok 24 - G11: a restart leaves a finished grant alone, and the reconcile adds nothing for it
---
duration_ms: 532.803771
type: 'test'
...
# Subtest: N1 (G27): the start sweep unlinks host.<id> files with no record in loaded businesses only, and refuses a store dir that is not private
ok 25 - N1 (G27): the start sweep unlinks host.<id> files with no record in loaded businesses only, and refuses a store dir that is not private
---
duration_ms: 10.401722
type: 'test'
...
# Subtest: G12: the human's hosts.revoke deletes the record, the key file, the reference and the line, with a closed marker
ok 26 - G12: the human's hosts.revoke deletes the record, the key file, the reference and the line, with a closed marker
---
duration_ms: 680.673474
type: 'test'
...
# Subtest: G12 (N4): a revoke with no record still removes the computed tag's line, with granted false; bad args refuse
ok 27 - G12 (N4): a revoke with no record still removes the computed tag's line, with granted false; bad args refuse
---
duration_ms: 210.775767
type: 'test'
...
# Subtest: G13: a revoke during install aborts the executor, which ends revoked, and the line goes
ok 28 - G13: a revoke during install aborts the executor, which ends revoked, and the line goes
---
duration_ms: 539.460127
type: 'test'
...
# Subtest: G26: the human, the assistant and the tester revoke; the chief and a pm refuse; sweep is the human's or the tester's
ok 29 - G26: the human, the assistant and the tester revoke; the chief and a pm refuse; sweep is the human's or the tester's
---
duration_ms: 509.503577
type: 'test'
...
# Subtest: row 281 T8: a tester hosts.revoke refuses tester-dev-only once bus.tester is off, and a human revoke writes no job records
ok 30 - row 281 T8: a tester hosts.revoke refuses tester-dev-only once bus.tester is off, and a human revoke writes no job records
---
duration_ms: 651.604822
type: 'test'
...
# Subtest: G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then
ok 31 - G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then
---
duration_ms: 660.602104
type: 'test'
...
# Subtest: G27: after step 2 with the host down, the restart closes the marker with hostLineLeft; a marker without a revoke id is not reopened
ok 32 - G27: after step 2 with the host down, the restart closes the marker with hostLineLeft; a marker without a revoke id is not reopened
---
duration_ms: 960.616547
type: 'test'
...
# Subtest: G27: a key file a revoke could not unlink is reported left and goes at the next start
ok 33 - G27: a key file a revoke could not unlink is reported left and goes at the next start
---
duration_ms: 659.246715
type: 'test'
...
# Subtest: G28: a commands change, then a revoke, then a restart leave no record, key, reference or line
ok 34 - G28: a commands change, then a revoke, then a restart leave no record, key, reference or line
---
duration_ms: 679.404671
type: 'test'
...
# Subtest: G24 (B4): hosts.sweep removes only this business's recorded tags no live grant holds, and records what it left
ok 35 - G24 (B4): hosts.sweep removes only this business's recorded tags no live grant holds, and records what it left
---
duration_ms: 957.711463
type: 'test'
...
# Subtest: G12: the local steps finish before the host step, so a host that hangs leaves the agent no access
ok 36 - G12: the local steps finish before the host step, so a host that hangs leaves the agent no access
---
duration_ms: 707.712192
type: 'test'
...
# Subtest: G23 (X5): a revoke right after the spend reaches the executor's claim, so the grant ends revoked
ok 37 - G23 (X5): a revoke right after the spend reaches the executor's claim, so the grant ends revoked
---
duration_ms: 213.272659
type: 'test'
...
# Subtest: G23 (X5): a revoke that lands as verify returns ends revoked with no record, key file or line
ok 38 - G23 (X5): a revoke that lands as verify returns ends revoked with no record, key file or line
---
duration_ms: 650.086388
type: 'test'
...
# Subtest: G13: a revoke whose host remove fails still drops the record and key, and reports the line left for a sweep
ok 39 - G13: a revoke whose host remove fails still drops the record and key, and reports the line left for a sweep
---
duration_ms: 656.97494
type: 'test'
...
# Subtest: G22: enroll, check, grant, host.run, a commands change the next host.run uses, and a revoke that kills a run in flight
ok 40 - G22: enroll, check, grant, host.run, a commands change the next host.run uses, and a revoke that kills a run in flight
---
duration_ms: 1161.98276
type: 'test'
...
# Subtest: mos-1163: a grant whose agent became a creative role after the raise ends host-creative-role at step 1, spent, with nothing remote
ok 41 - mos-1163: a grant whose agent became a creative role after the raise ends host-creative-role at step 1, spent, with nothing remote
---
duration_ms: 64.270391
type: 'test'
...
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
ok 42 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
---
duration_ms: 513.54277
type: 'test'
...
1..42
# tests 42
# suites 0
# pass 42
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 21560.094474
@@ -0,0 +1,28 @@
TAP version 13
# Subtest: Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
ok 1 - Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
---
duration_ms: 2.134902
type: 'test'
...
# Subtest: without a trusted project adapter all three async paths refuse project-not-configured
ok 2 - without a trusted project adapter all three async paths refuse project-not-configured
---
duration_ms: 40.098809
type: 'test'
...
# Subtest: real startBroker injects the project adapter for socket reads and bound-author record writes
ok 3 - real startBroker injects the project adapter for socket reads and bound-author record writes
---
duration_ms: 195.816084
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 346.858932
@@ -0,0 +1,130 @@
TAP version 13
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
---
duration_ms: 37.881537
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
---
duration_ms: 66.86806
type: 'test'
...
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
---
duration_ms: 31.783414
type: 'test'
...
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
---
duration_ms: 612.146473
type: 'test'
...
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
---
duration_ms: 25.313669
type: 'test'
...
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
---
duration_ms: 29.786654
type: 'test'
...
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
---
duration_ms: 32.078895
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
---
duration_ms: 36.18567
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
---
duration_ms: 30.574839
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
---
duration_ms: 28.11449
type: 'test'
...
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
---
duration_ms: 40.031837
type: 'test'
...
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
---
duration_ms: 28.629823
type: 'test'
...
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
---
duration_ms: 23.227988
type: 'test'
...
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
---
duration_ms: 24.842387
type: 'test'
...
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
---
duration_ms: 90.053907
type: 'test'
...
# Subtest: project.create refuses a repository name without its matching repo before any write
ok 16 - project.create refuses a repository name without its matching repo before any write
---
duration_ms: 16.171096
type: 'test'
...
# Subtest: project.create keeps unrelated names with and without a repository
ok 17 - project.create keeps unrelated names with and without a repository
---
duration_ms: 12.013106
type: 'test'
...
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
---
duration_ms: 48.182262
type: 'test'
...
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
---
duration_ms: 21.103961
type: 'test'
...
# Subtest: C2-09a: creative bindLaunch without a thread refuses thread-required
ok 20 - C2-09a: creative bindLaunch without a thread refuses thread-required
---
duration_ms: 10.557239
type: 'test'
...
1..20
# tests 20
# suites 0
# pass 20
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1330.269155
@@ -0,0 +1 @@
{"head": "7e70de9e68ba48d761a45894d037239b5d576a38", "red": "bfa421b6cd9b2b54d8da119e73b004aeafe606c3", "manifest": "451d3fcbeb6a41c3a505600898ba2025985f44d8391e2cd823ab84f1d4b50621", "start": "2026-10-23T08:41:20.461140+00:00"}
@@ -0,0 +1,26 @@
TAP version 13
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
not ok 1 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
---
duration_ms: 526.66272
type: 'test'
location: '/mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/grant-executor.test.mjs:1046:1'
failureType: 'testCodeFailure'
error: 'thread-required'
code: 'thread-required'
stack: |-
fail (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/src/broker.mjs:209:9)
Broker.bindLaunch (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/src/broker.mjs:1258:40)
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/grant-executor.test.mjs:1050:24)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 610.366811
@@ -0,0 +1,58 @@
TAP version 13
# Subtest: Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
not ok 1 - Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
---
duration_ms: 2.490892
type: 'test'
location: '/mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/project-runtime.test.mjs:12:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
[
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
"import { isAbsolute, dirname } from 'node:path';",
"import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';",
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0: "import { randomUUID, randomBytes, createHash } from 'node:crypto';"
1: "import { isAbsolute, dirname } from 'node:path';"
2: "import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';"
3: "import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';"
4: "import { loadRequirements } from '../../tasks/src/requirements.mjs';"
5: "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';"
6: "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
actual:
0: "import { randomUUID, randomBytes, createHash } from 'node:crypto';"
1: "import { isAbsolute, dirname } from 'node:path';"
2: "import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';"
3: "import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';"
4: "import { loadRequirements } from '../../tasks/src/requirements.mjs';"
5: "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';"
6: "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/project-runtime.test.mjs:14:10)
Test.runInAsyncScope (node:async_hooks:226:14)
Test.run (node:internal/test_runner/test:1402:25)
Test.start (node:internal/test_runner/test:1262:17)
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 119.366042
@@ -0,0 +1 @@
{"paths": 16, "drift": [], "head": "7e70de9e68ba48d761a45894d037239b5d576a38", "end": "2026-10-23T08:41:45.374077+00:00"}
@@ -0,0 +1,6 @@
{"label": "red-grant", "start": "2026-10-23T08:41:20.461387+00:00", "end": "2026-10-23T08:41:21.126907+00:00", "seconds": 0.666, "exit": 1, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/red-grant.tap"}
{"label": "red-pin", "start": "2026-10-23T08:41:21.130846+00:00", "end": "2026-10-23T08:41:21.295380+00:00", "seconds": 0.165, "exit": 1, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/red-pin.tap"}
{"label": "green-grant-executor", "start": "2026-10-23T08:41:21.299531+00:00", "end": "2026-10-23T08:41:42.908967+00:00", "seconds": 21.609, "exit": 0, "counts": {"tests": 42, "pass": 42, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/green-grant-executor.tap"}
{"label": "green-project-runtime", "start": "2026-10-23T08:41:42.913276+00:00", "end": "2026-10-23T08:41:43.327996+00:00", "seconds": 0.415, "exit": 0, "counts": {"tests": 3, "pass": 3, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/green-project-runtime.tap"}
{"label": "green-threads", "start": "2026-10-23T08:41:43.331886+00:00", "end": "2026-10-23T08:41:44.698643+00:00", "seconds": 1.367, "exit": 0, "counts": {"tests": 20, "pass": 20, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/green-threads.tap"}
{"label": "T24", "start": "2026-10-23T08:41:44.704293+00:00", "end": "2026-10-23T08:41:45.369841+00:00", "seconds": 0.666, "exit": 1, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/T24.tap"}
@@ -0,0 +1,55 @@
from pathlib import Path
import os,subprocess,signal,tempfile,json,re,time,hashlib,shlex
from datetime import datetime,timezone
ROOT=Path('/mnt/storage/scratch/rocko-r219'); RED=Path('/mnt/storage/scratch/rocko-r219-red-mos1429')
P=ROOT/'agents/rocko/work/queue-219';OUT=P/'r4/short-run';OUT.mkdir(exist_ok=True)
HEAD='7e70de9e68ba48d761a45894d037239b5d576a38';OLD='bfa421b6cd9b2b54d8da119e73b004aeafe606c3'
def stamp():return datetime.now(timezone.utc).isoformat()
def verify():
assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()==HEAD
assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=RED,text=True).strip()==OLD
for line in (P/'candidate-manifest.sha256').read_text().splitlines():
sha,path=line.split(' ',1);assert hashlib.sha256((ROOT/path).read_bytes()).hexdigest()==sha,path
def execute(label,root,file,pattern=None):
args=['node','--test','--test-reporter=tap']
if pattern:args+=['--test-name-pattern',pattern]
args+=[file]
env=os.environ.copy();env.update(TMPDIR=tempfile.mkdtemp(prefix='r',dir='/mnt/storage/scratch/tmp'),DOCKER_HOST='unix:///mnt/storage/scratch/rocko-r219-dead.sock',NO_COLOR='1')
env.pop('MOSAIC_GITEA_CREDENTIAL_FILE',None);env.pop('MOSAIC_LIVE_PROVIDER',None)
log=OUT/(label+'.tap');start=stamp();begin=time.monotonic()
cmd=shlex.join(['timeout','-k','10','300',*args])+' | tee '+shlex.quote(str(log))
child=subprocess.Popen(['bash','-o','pipefail','-c',cmd],cwd=root,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.STDOUT,start_new_session=True)
try:rc=child.wait(timeout=315)
except subprocess.TimeoutExpired:
os.killpg(child.pid,signal.SIGKILL);child.wait();rc=124
finally:
try:os.killpg(child.pid,signal.SIGTERM)
except ProcessLookupError:pass
text=log.read_text(errors='replace');counts={k:int(m[-1]) if (m:=re.findall(r'^# '+k+r' (\d+)\s*$',text,re.M)) else None for k in ['tests','pass','fail','cancelled','skipped']}
row=dict(label=label,start=start,end=stamp(),seconds=round(time.monotonic()-begin,3),exit=rc,counts=counts,skips=[x for x in text.splitlines() if re.search(r'^skip\b|# SKIP\b',x,re.I)],log=str(log.relative_to(ROOT)))
with (OUT/'results.jsonl').open('a') as f:f.write(json.dumps(row)+'\n')
print(json.dumps(row),flush=True)
assert rc not in (124,137,-9) and 'SyntaxError:' not in text, 'timeout/syntax '+label
return row,text
def green(label,file):
verify();r,t=execute(label,ROOT,file)
assert r['exit']==0 and r['counts']['fail']==0 and r['counts']['cancelled']==0 and not re.search(r'^\s*not ok\b',t,re.M),'unexpected red '+label
verify();(OUT/'head.json').write_text(json.dumps(dict(head=HEAD,red=OLD,manifest=hashlib.sha256((P/'candidate-manifest.sha256').read_bytes()).hexdigest(),start=stamp())))
pattern='sage-1908: once the agent of an applied grant is a creative role'
r,t=execute('red-grant',RED,'packages/bus/tests/grant-executor.test.mjs',pattern)
assert r['exit']!=0 and r['counts']['fail']==1 and "code: 'thread-required'" in t and 'bindLaunch' in t,'wrong grant red'
verify();r,t=execute('red-pin',RED,'packages/bus/tests/project-runtime.test.mjs','Broker keeps the sandbox client import closure')
assert r['exit']!=0 and r['counts']['fail']==1 and 'ERR_ASSERTION' in t and 'THREAD_DEFINITION' in t,'wrong pin red'
for name in ['grant-executor','project-runtime','threads']:green('green-'+name,'packages/bus/tests/'+name+'.test.mjs')
verify();s=next(x for x in json.loads((P/'mutants.json').read_text()) if x['id']=='T24');path=ROOT/s['file'];saved=path.read_bytes()
try:
text=path.read_text();assert text.count(s['old'])==1;path.write_text(text.replace(s['old'],s['new'],1))
r,t=execute('T24',ROOT,s['test'],s['pattern'])
assert r['exit']!=0 and r['counts']['fail']==1 and 'ERR_ASSERTION' in t and re.search(r'^\s*not ok \d+ - '+re.escape(pattern),t,re.M),'T24 invalid or survivor'
(OUT/'T24-receipt.txt').write_text(t)
finally:path.write_bytes(saved)
verify();(OUT/'restored.json').write_text(json.dumps(dict(paths=16,drift=[],head=HEAD,end=stamp())))
print('SHORT_RUN_GREEN',stamp(),flush=True)
@@ -0,0 +1,64 @@
{
"status": "preliminary on 5f2ef92e5; rerun on new base before static handoff",
"readers": {
"chat-http.mjs": [
"packages/webui/tests/chat.test.mjs"
],
"chat.mjs": [
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/tester-fixture.mjs",
"packages/webui/tests/channel-chat.test.mjs"
],
"serve.mjs": [
"packages/control-board/tests/discord.test.mjs",
"packages/webui/tests/views.test.mjs",
"packages/webui/tests/usage-browser.test.mjs",
"packages/control-board/tests/serve.test.mjs",
"packages/webui/tests/bus.test.mjs",
"packages/webui/tests/thread-claims-browser.test.mjs",
"packages/webui/tests/history-fixture.mjs",
"packages/webui/tests/instances-browser.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/chat-browser.test.mjs",
"packages/webui/tests/library-cli.test.mjs",
"packages/webui/tests/instances.test.mjs",
"packages/webui/tests/settings-form.test.mjs",
"packages/webui/tests/assistant.test.mjs",
"packages/webui/tests/editor-browser.test.mjs",
"packages/webui/tests/limits-route.test.mjs",
"packages/webui/tests/limits-browser.test.mjs",
"packages/webui/tests/sso-browser.test.mjs",
"packages/webui/tests/shell.test.mjs",
"packages/webui/tests/proof.test.mjs",
"packages/webui/tests/docs-browser.test.mjs",
"packages/webui/tests/usage.test.mjs",
"packages/webui/tests/messages.test.mjs",
"packages/webui/tests/sso.test.mjs",
"packages/webui/tests/processing.test.mjs",
"packages/webui/tests/send.test.mjs",
"packages/webui/tests/fixture.mjs",
"packages/webui/tests/project-file.test.mjs",
"packages/webui/tests/agents.test.mjs",
"packages/webui/tests/return-flow.test.mjs",
"packages/webui/tests/browser-edge.test.mjs",
"packages/webui/tests/agents-browser.test.mjs",
"packages/webui/tests/send-browser.test.mjs",
"packages/webui/tests/discord.test.mjs",
"packages/webui/tests/bus-browser.test.mjs",
"packages/webui/tests/reads.test.mjs",
"packages/webui/tests/routes-browser.test.mjs",
"packages/webui/tests/preview.test.mjs",
"packages/webui/tests/board-held.test.mjs",
"packages/webui/tests/tester-listener.test.mjs",
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/processing-browser.test.mjs",
"packages/webui/tests/serve.test.mjs",
"packages/webui/tests/chat-instances.test.mjs",
"packages/webui/tests/routes.test.mjs",
"packages/webui/tests/settings-write.test.mjs",
"packages/webui/tests/thread-claims.test.mjs",
"packages/webui/tests/relaunch.test.mjs",
"packages/conversation/tests/flows.test.mjs"
]
}
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Row219 serial gate. Requires Sage CLEAR; all children use a process group."""
import os,sys,time,json,signal,subprocess,tempfile,hashlib,re,glob
from pathlib import Path
from datetime import datetime,timezone
ROOT=Path(__file__).resolve().parents[4]
PACKET=Path(__file__).resolve().parent
OUT=PACKET/'run-4a9c-mos1408';OUT.mkdir(exist_ok=True)
def stamp():return datetime.now(timezone.utc).isoformat()
def verify():
for line in (PACKET/'candidate-manifest.sha256').read_text().splitlines():
sha,path=line.split(' ',1)
if hashlib.sha256((ROOT/path).read_bytes()).hexdigest()!=sha:raise RuntimeError('candidate drift: '+path)
def sweep():
found=[]
for proc in Path('/proc').glob('[0-9]*'):
try:
cwd=(proc/'cwd').resolve();name=(proc/'comm').read_text().strip()
if (str(cwd).startswith(str(ROOT)) or str(cwd).startswith('/mnt/storage/scratch/tmp/')) and ('node' in name or 'chrom' in name):found.append(dict(pid=int(proc.name),name=name,cwd=str(cwd)))
except (FileNotFoundError,PermissionError,ProcessLookupError):pass
return found
def execute(label,args,cwd=ROOT,deadline=300,docker=False):
if args[0]=='node' and '--test' in args and '--test-reporter=tap' not in args:args=[args[0],'--test-reporter=tap',*args[1:]]
if args[0]!='timeout':args=['timeout','-k','10',str(deadline),*args]
verify();short=tempfile.mkdtemp(prefix='r',dir='/mnt/storage/scratch/tmp')
env=os.environ.copy();env.update(TMPDIR=short,NO_COLOR='1',COMPOSE_PROJECT_NAME='rocko-r219-gate')
env.pop('MOSAIC_GITEA_CREDENTIAL_FILE',None);env.pop('MOSAIC_LIVE_PROVIDER',None)
if docker:env.pop('DOCKER_HOST',None)
else:env['DOCKER_HOST']='unix:///mnt/storage/scratch/rocko-r219-dead.sock'
log=OUT/(label+'.tap');begin=stamp();t=time.monotonic();timeout=False
print('START',label,begin,flush=True)
with log.open('wb') as f:
child=subprocess.Popen(args,cwd=cwd,env=env,stdout=f,stderr=subprocess.STDOUT,start_new_session=True)
try:rc=child.wait(timeout=deadline+15)
except subprocess.TimeoutExpired:
timeout=True
try:os.killpg(child.pid,signal.SIGTERM)
except ProcessLookupError:pass
try:child.wait(timeout=10)
except subprocess.TimeoutExpired:
try:os.killpg(child.pid,signal.SIGKILL)
except ProcessLookupError:pass
child.wait()
rc=child.returncode
finally:
try:os.killpg(child.pid,signal.SIGTERM)
except ProcessLookupError:pass
text=log.read_text(errors='replace')
counts={k:int(m[-1]) if (m:=re.findall(r'^# '+k+r' (\d+)\s*$',text,re.M)) else None for k in ['tests','pass','fail','cancelled','skipped']}
row=dict(label=label,args=args,start=begin,end=stamp(),seconds=round(time.monotonic()-t,3),exit=rc,timeout=timeout,counts=counts,skips=[l for l in text.splitlines() if re.search(r'^skip|# SKIP',l,re.I)],log=str(log.relative_to(ROOT)),tmpdir=short)
with (OUT/'results.jsonl').open('a') as f:f.write(json.dumps(row)+'\n')
print('END',json.dumps(row),flush=True)
if rc or timeout or re.search(r'^\s*not ok\b',text,re.M):raise RuntimeError('RED '+label+' '+str(log))
return row
singles=[line for line in (PACKET/'changed-paths.txt').read_text().splitlines() if '/tests/' in line]
def stages():
for path in singles:execute('single-'+Path(path).parent.parent.name+'-'+Path(path).stem,['node','--test',path],deadline=300)
for package in ['bus','cli','webui']:
files=sorted(glob.glob(str(ROOT/'packages'/package/'tests'/'*.test.mjs')))
execute('suite-'+package,['node','--test',*files],cwd=ROOT/'packages'/package,deadline=1800)
for path in sorted(ROOT.glob('scripts/test-*.sh')):
# timeout itself implements TERM then KILL, inside the dedicated child group.
execute(path.stem,['timeout','-k','10','7200','bash',str(path)],deadline=7200,docker=True)
# Every mutant starts from verified candidate bytes and restores in finally.
for spec in json.loads((PACKET/'mutants.json').read_text()):
verify();edits=[spec]+spec.get('additional_edits',[]);saved={}
try:
for e in edits:
path=ROOT/e['file'];saved.setdefault(path,path.read_bytes());text=path.read_text()
if text.count(e['old'])!=1:raise RuntimeError('anchor '+spec['id'])
path.write_text(text.replace(e['old'],e['new'],1))
# Mutation intentionally changes candidate bytes; execute has its own verify.
oldverify=globals()['verify'];globals()['verify']=lambda:None
try:
execute('mutant-'+spec['id'],['node','--test','--test-name-pattern',spec['pattern'],spec['test']],deadline=180)
status='survived'
except RuntimeError:
record=json.loads((OUT/'results.jsonl').read_text().splitlines()[-1]);text=(ROOT/record['log']).read_text(errors='replace')
failing=re.findall(r'^\s*not ok \d+ - (.*)$',text,re.M)
status='killed' if record['exit'] and not record['timeout'] and any(re.search(spec['pattern'],name) for name in failing) and 'SyntaxError:' not in text else 'invalid-failure'
finally:globals()['verify']=oldverify
finally:
for path,data in saved.items():path.write_bytes(data)
verify()
with (OUT/'mutants.jsonl').open('a') as f:f.write(json.dumps(dict(id=spec['id'],status=status,test=spec['test'],pattern=spec['pattern']))+'\n')
if status!='killed':raise RuntimeError(spec['id']+' '+status)
if __name__=='__main__':
if Path.cwd()!=ROOT:raise SystemExit('wrong cwd')
head=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()
expected=(PACKET/'run-head.txt').read_text().strip()
if head!=expected:raise SystemExit('wrong HEAD')
(OUT/'head.json').write_text(json.dumps(dict(head=head,manifest=hashlib.sha256((PACKET/'candidate-manifest.sha256').read_bytes()).hexdigest(),start=stamp())))
verify()
try:stages()
finally:
verify();(OUT/'cwd-sweep.json').write_text(json.dumps(sweep(),indent=2))
# Remove only the owned fixture compose network, if any; never daemon/volumes.
subprocess.run(['docker','network','rm','rocko-r219-gate_default'],stdout=(OUT/'network-cleanup.log').open('w'),stderr=subprocess.STDOUT)
@@ -0,0 +1 @@
{"head": "b97a3ff9757ec2ac39d9ab4cebb9769e471df932", "manifest": "a973912fe2fb9c64606cd99a385305b8f51498a096511b3a980bca6b0a5b98aa", "start": "2026-10-23T05:48:57.416807+00:00"}
@@ -0,0 +1,2 @@
Error response from daemon: network rocko-r219-gate_default not found
exit status 1
@@ -0,0 +1,4 @@
{"label": "single-bus-creative.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T05:48:57.418927+00:00", "end": "2026-10-23T05:48:57.834246+00:00", "seconds": 0.415, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rs156uowe"}
{"label": "single-bus-threads.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T05:48:57.835837+00:00", "end": "2026-10-23T05:48:59.152912+00:00", "seconds": 1.317, "exit": 0, "timeout": false, "counts": {"tests": 19, "pass": 19, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rbqz65jtz"}
{"label": "single-bus-user-principal.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T05:48:59.154288+00:00", "end": "2026-10-23T05:48:59.719646+00:00", "seconds": 0.565, "exit": 0, "timeout": false, "counts": {"tests": 23, "pass": 23, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rztf343rx"}
{"label": "single-cli-launcher.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T05:48:59.721051+00:00", "end": "2026-10-23T05:49:26.296570+00:00", "seconds": 26.576, "exit": 1, "timeout": false, "counts": {"tests": 34, "pass": 33, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r8xucbjgl"}
@@ -0,0 +1,36 @@
TAP version 13
# organization: {"event":"reload-needs-restart","business":"demo"}
# organization: {"event":"reload-needs-restart","business":"demo"}
# Subtest: M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
ok 1 - M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
---
duration_ms: 37.235384
type: 'test'
...
# Subtest: a creative instance sends only to the user and hears only from the user
ok 2 - a creative instance sends only to the user and hears only from the user
---
duration_ms: 44.266739
type: 'test'
...
# Subtest: M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
ok 3 - M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
---
duration_ms: 83.079495
type: 'test'
...
# Subtest: row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
ok 4 - row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
---
duration_ms: 63.576082
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 356.82109
@@ -0,0 +1,124 @@
TAP version 13
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
---
duration_ms: 37.957781
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
---
duration_ms: 68.301373
type: 'test'
...
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
---
duration_ms: 31.316512
type: 'test'
...
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
---
duration_ms: 593.922455
type: 'test'
...
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
---
duration_ms: 24.857971
type: 'test'
...
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
---
duration_ms: 27.519976
type: 'test'
...
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
---
duration_ms: 30.716822
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
---
duration_ms: 33.754906
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
---
duration_ms: 29.046481
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
---
duration_ms: 26.449042
type: 'test'
...
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
---
duration_ms: 38.524966
type: 'test'
...
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
---
duration_ms: 28.558392
type: 'test'
...
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
---
duration_ms: 23.166467
type: 'test'
...
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
---
duration_ms: 28.154835
type: 'test'
...
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
---
duration_ms: 86.802213
type: 'test'
...
# Subtest: project.create refuses a repository name without its matching repo before any write
ok 16 - project.create refuses a repository name without its matching repo before any write
---
duration_ms: 15.244016
type: 'test'
...
# Subtest: project.create keeps unrelated names with and without a repository
ok 17 - project.create keeps unrelated names with and without a repository
---
duration_ms: 11.57841
type: 'test'
...
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
---
duration_ms: 46.82113
type: 'test'
...
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
---
duration_ms: 20.263528
type: 'test'
...
1..19
# tests 19
# suites 0
# pass 19
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1283.575562
@@ -0,0 +1,148 @@
TAP version 13
# Subtest: R3: bindUser binds a granted user and refuses one with no live grant
ok 1 - R3: bindUser binds a granted user and refuses one with no live grant
---
duration_ms: 15.216913
type: 'test'
...
# Subtest: R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
ok 2 - R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
---
duration_ms: 10.127905
type: 'test'
...
# Subtest: R3: each collaborator verb is allowed to a granted user
ok 3 - R3: each collaborator verb is allowed to a granted user
---
duration_ms: 18.491408
type: 'test'
...
# Subtest: R3: every verb off the collaborator list refuses a user access-refused before its own checks
ok 4 - R3: every verb off the collaborator list refuses a user access-refused before its own checks
---
duration_ms: 95.840429
type: 'test'
...
# Subtest: R3: task, web and host verbs refuse a user access-refused
ok 5 - R3: task, web and host verbs refuse a user access-refused
---
duration_ms: 22.333602
type: 'test'
...
# Subtest: R3 leak: project.list shows granted projects only
ok 6 - R3 leak: project.list shows granted projects only
---
duration_ms: 12.494935
type: 'test'
...
# Subtest: R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
ok 7 - R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
---
duration_ms: 15.195921
type: 'test'
...
# Subtest: R3 leak: thread.list shows the user its own threads that are still granted, never others
ok 8 - R3 leak: thread.list shows the user its own threads that are still granted, never others
---
duration_ms: 33.037437
type: 'test'
...
# Subtest: R3 leak: names shows granted instances only
ok 9 - R3 leak: names shows granted instances only
---
duration_ms: 14.891286
type: 'test'
...
# Subtest: R3: thread.send is from user:<id> and writes no human.input
ok 10 - R3: thread.send is from user:<id> and writes no human.input
---
duration_ms: 13.366772
type: 'test'
...
# Subtest: R3: a reply-chain message.send into another user thread is refused
ok 11 - R3: a reply-chain message.send into another user thread is refused
---
duration_ms: 15.523733
type: 'test'
...
# Subtest: R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
ok 12 - R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
---
duration_ms: 18.163702
type: 'test'
...
# Subtest: R3: a reply to: user without a matching in_reply_to is refused user-reply-required
ok 13 - R3: a reply to: user without a matching in_reply_to is refused user-reply-required
---
duration_ms: 26.445744
type: 'test'
...
# Subtest: R3: a non-thread run, the human and the tester cannot send to: user
ok 14 - R3: a non-thread run, the human and the tester cannot send to: user
---
duration_ms: 17.29104
type: 'test'
...
# Subtest: R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
ok 15 - R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
---
duration_ms: 19.258704
type: 'test'
...
# Subtest: R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
ok 16 - R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
---
duration_ms: 18.231409
type: 'test'
...
# Subtest: R3: a user refusal is recorded as action.refused by user:<id>
ok 17 - R3: a user refusal is recorded as action.refused by user:<id>
---
duration_ms: 13.797301
type: 'test'
...
# Subtest: R3: a business with a role keyed user refuses invalid-business
ok 18 - R3: a business with a role keyed user refuses invalid-business
---
duration_ms: 9.468564
type: 'test'
...
# Subtest: R3: name.set refuses the name user
ok 19 - R3: name.set refuses the name user
---
duration_ms: 9.752458
type: 'test'
...
# Subtest: R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
ok 20 - R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
---
duration_ms: 13.617784
type: 'test'
...
# Subtest: R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
ok 21 - R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
---
duration_ms: 14.726171
type: 'test'
...
# Subtest: R3 r4: a non-function access throws before the store is read
ok 22 - R3 r4: a non-function access throws before the store is read
---
duration_ms: 0.105564
type: 'test'
...
# Subtest: C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
ok 23 - C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
---
duration_ms: 15.883063
type: 'test'
...
1..23
# tests 23
# suites 0
# pass 23
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 523.149354
@@ -0,0 +1,259 @@
TAP version 13
# Subtest: C2-04: launcher workspace clones code but never the library
ok 1 - C2-04: launcher workspace clones code but never the library
---
duration_ms: 1444.885132
type: 'test'
...
# Subtest: bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
ok 2 - bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
---
duration_ms: 1312.408111
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r910aab09c0d6","instance":"chief","turn":"368e3045-8783-47d3-8ae1-e429b321d9b1","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r5bb5f0657d94","instance":"coder","turn":"3432d01f-ca94-4a5e-88d3-19588a40a81d","source":"@none","code":"baseline-unknown"}
# Subtest: the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
ok 3 - the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
---
duration_ms: 745.868459
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"3bd3e4d7-3af4-4a1c-99f1-a8f25b7ab1dd","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"b2db478d-2905-49fd-a81a-0800643bed6a","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"ac2aa380-f3a0-40d4-960f-a543acb8c431","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"e8b5124b-591b-4d5b-829c-e944bb3e24bb","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"6a6e1fa4-5182-42dc-bbc2-26f136eedee3","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"54eb77b5-3880-4afb-9772-0b9d590eb328","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"11fe8b92-1dae-416a-838b-ae305db74bc6","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"68266357-b477-433c-99ed-f1af37143d91","source":"@none","code":"baseline-unknown"}
# Subtest: a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
ok 4 - a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
---
duration_ms: 1245.265675
type: 'test'
...
# Subtest: each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
ok 5 - each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
---
duration_ms: 4072.119958
type: 'test'
...
# Subtest: a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
ok 6 - a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
---
duration_ms: 621.888037
type: 'test'
...
# Subtest: REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
ok 7 - REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
---
duration_ms: 417.133166
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r8e91dd37dc4b","instance":"coder","turn":"a7b24e30-ac5e-47cf-a951-90cf0d948b4d","source":"@none","code":"baseline-unknown"}
# Subtest: a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
ok 8 - a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
---
duration_ms: 730.904044
type: 'test'
...
# Subtest: a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
ok 9 - a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
---
duration_ms: 698.470719
type: 'test'
...
# Subtest: after a restart, the new host's first tick starts a holder whose decision is still held
ok 10 - after a restart, the new host's first tick starts a holder whose decision is still held
---
duration_ms: 920.432738
type: 'test'
...
# Subtest: a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
ok 11 - a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
---
duration_ms: 641.309798
type: 'test'
...
# Subtest: a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
ok 12 - a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
---
duration_ms: 267.647539
type: 'test'
...
# Subtest: a runner that stops at once ends its launch with the runner's reason
ok 13 - a runner that stops at once ends its launch with the runner's reason
---
duration_ms: 332.423482
type: 'test'
...
# Subtest: a Claude Code launch with no session credential refuses before it starts
ok 14 - a Claude Code launch with no session credential refuses before it starts
---
duration_ms: 181.931254
type: 'test'
...
# Subtest: a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
ok 15 - a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
---
duration_ms: 315.27683
type: 'test'
...
# usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
# plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
# Subtest: row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
ok 16 - row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
---
duration_ms: 353.028786
type: 'test'
...
# Subtest: a runner killed from the host ends its launch as killed, not failed
ok 17 - a runner killed from the host ends its launch as killed, not failed
---
duration_ms: 375.548364
type: 'test'
...
# Subtest: a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
ok 18 - a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
---
duration_ms: 187.871873
type: 'test'
...
# Subtest: row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
ok 19 - row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
---
duration_ms: 187.807189
type: 'test'
...
# Subtest: row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
not ok 20 - row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
---
duration_ms: 255.698016
type: 'test'
location: '/mnt/storage/scratch/rocko-r219/packages/cli/tests/launcher.test.mjs:766:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
[
+ 'compactions',
+ undefined,
+ 2
- 'refused',
- 'launch-failed',
- 1
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0: 'refused'
1: 'launch-failed'
2: 1
actual:
0: 'compactions'
2: 2
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/cli/tests/launcher.test.mjs:789:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
ok 21 - row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
---
duration_ms: 194.030218
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r3715d40bd7fb","instance":"chief","turn":"4c85d22f-fdab-4ab7-ad8a-6b4c4d1fbf72","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r16ae936abadf","instance":"coder","turn":"2c6b06f1-8f36-4261-81b7-2689b9bb7ff4","source":"@none","code":"baseline-unknown"}
# Subtest: a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
ok 22 - a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
---
duration_ms: 1456.773218
type: 'test'
...
# Subtest: a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
ok 23 - a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
---
duration_ms: 1063.59923
type: 'test'
...
# Subtest: a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
ok 24 - a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
---
duration_ms: 4016.888246
type: 'test'
...
# Subtest: bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
ok 25 - bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
---
duration_ms: 237.364574
type: 'test'
...
# Subtest: bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
ok 26 - bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
---
duration_ms: 197.775267
type: 'test'
...
# Subtest: bus start --no-launch starts no thread tick; the same host with launches runs it
ok 27 - bus start --no-launch starts no thread tick; the same host with launches runs it
---
duration_ms: 1181.987654
type: 'test'
...
# Subtest: a malformed sessions.json refuses the host start with exit 3 and stays as it was
ok 28 - a malformed sessions.json refuses the host start with exit 3 and stays as it was
---
duration_ms: 177.111363
type: 'test'
...
# Subtest: an over-long launch request is refused at once, not at the 10 s idle timeout
ok 29 - an over-long launch request is refused at once, not at the 10 s idle timeout
---
duration_ms: 183.364128
type: 'test'
...
# Subtest: a launch client that never closes its side doesn't hold the host's close
ok 30 - a launch client that never closes its side doesn't hold the host's close
---
duration_ms: 179.49662
type: 'test'
...
# Subtest: a runner that ignores SIGTERM is killed when the host closes
ok 31 - a runner that ignores SIGTERM is killed when the host closes
---
duration_ms: 1352.083242
type: 'test'
...
# Subtest: kill switch refuses chief and hire before launch work and a clear permits a later request
ok 32 - kill switch refuses chief and hire before launch work and a clear permits a later request
---
duration_ms: 319.505559
type: 'test'
...
# Subtest: kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
ok 33 - kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
---
duration_ms: 334.076309
type: 'test'
...
# Subtest: a launch waiting across halt and resume cannot use its stale admission
ok 34 - a launch waiting across halt and resume cannot use its stale admission
---
duration_ms: 201.932098
type: 'test'
...
1..34
# tests 34
# suites 0
# pass 33
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 26522.233904
@@ -0,0 +1 @@
{"head": "7cbd839d037f711de136108882b53597cf16d742", "manifest": "84a8bd9afc5420fc315586584aec91258a28bd728000dcd2cb61ac41b2fe711a", "start": "2026-10-23T06:09:43.704053+00:00"}
@@ -0,0 +1,2 @@
Error response from daemon: network rocko-r219-gate_default not found
exit status 1
@@ -0,0 +1,5 @@
{"label": "single-bus-creative.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T06:09:43.706182+00:00", "end": "2026-10-23T06:09:44.121877+00:00", "seconds": 0.416, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rmc2uerc6"}
{"label": "single-bus-threads.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T06:09:44.123566+00:00", "end": "2026-10-23T06:09:45.490657+00:00", "seconds": 1.367, "exit": 0, "timeout": false, "counts": {"tests": 19, "pass": 19, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rismt3po4"}
{"label": "single-bus-user-principal.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T06:09:45.492192+00:00", "end": "2026-10-23T06:09:46.107814+00:00", "seconds": 0.616, "exit": 0, "timeout": false, "counts": {"tests": 23, "pass": 23, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/repq8xlqh"}
{"label": "single-cli-launcher.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T06:09:46.109200+00:00", "end": "2026-10-23T06:10:12.635877+00:00", "seconds": 26.527, "exit": 0, "timeout": false, "counts": {"tests": 34, "pass": 34, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rkqztfjx8"}
{"label": "single-cli-thread-launch.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/thread-launch.test.mjs"], "start": "2026-10-23T06:10:12.637449+00:00", "end": "2026-10-23T06:10:30.695792+00:00", "seconds": 18.058, "exit": 1, "timeout": false, "counts": {"tests": 14, "pass": 13, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-thread-launch.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rbyz9a6iu"}
@@ -0,0 +1,36 @@
TAP version 13
# organization: {"event":"reload-needs-restart","business":"demo"}
# organization: {"event":"reload-needs-restart","business":"demo"}
# Subtest: M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
ok 1 - M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
---
duration_ms: 43.416235
type: 'test'
...
# Subtest: a creative instance sends only to the user and hears only from the user
ok 2 - a creative instance sends only to the user and hears only from the user
---
duration_ms: 43.274527
type: 'test'
...
# Subtest: M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
ok 3 - M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
---
duration_ms: 76.669863
type: 'test'
...
# Subtest: row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
ok 4 - row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
---
duration_ms: 60.418493
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 374.784911
@@ -0,0 +1,124 @@
TAP version 13
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
---
duration_ms: 37.977187
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
---
duration_ms: 68.5376
type: 'test'
...
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
---
duration_ms: 33.043064
type: 'test'
...
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
---
duration_ms: 616.013932
type: 'test'
...
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
---
duration_ms: 22.785202
type: 'test'
...
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
---
duration_ms: 27.208648
type: 'test'
...
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
---
duration_ms: 29.67741
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
---
duration_ms: 33.136315
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
---
duration_ms: 30.610794
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
---
duration_ms: 27.293744
type: 'test'
...
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
---
duration_ms: 39.426851
type: 'test'
...
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
---
duration_ms: 30.185753
type: 'test'
...
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
---
duration_ms: 25.774336
type: 'test'
...
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
---
duration_ms: 26.886111
type: 'test'
...
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
---
duration_ms: 90.298865
type: 'test'
...
# Subtest: project.create refuses a repository name without its matching repo before any write
ok 16 - project.create refuses a repository name without its matching repo before any write
---
duration_ms: 16.900927
type: 'test'
...
# Subtest: project.create keeps unrelated names with and without a repository
ok 17 - project.create keeps unrelated names with and without a repository
---
duration_ms: 11.07789
type: 'test'
...
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
---
duration_ms: 47.924595
type: 'test'
...
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
---
duration_ms: 19.917458
type: 'test'
...
1..19
# tests 19
# suites 0
# pass 19
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1316.555344
@@ -0,0 +1,148 @@
TAP version 13
# Subtest: R3: bindUser binds a granted user and refuses one with no live grant
ok 1 - R3: bindUser binds a granted user and refuses one with no live grant
---
duration_ms: 14.967712
type: 'test'
...
# Subtest: R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
ok 2 - R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
---
duration_ms: 10.570753
type: 'test'
...
# Subtest: R3: each collaborator verb is allowed to a granted user
ok 3 - R3: each collaborator verb is allowed to a granted user
---
duration_ms: 20.155376
type: 'test'
...
# Subtest: R3: every verb off the collaborator list refuses a user access-refused before its own checks
ok 4 - R3: every verb off the collaborator list refuses a user access-refused before its own checks
---
duration_ms: 102.151204
type: 'test'
...
# Subtest: R3: task, web and host verbs refuse a user access-refused
ok 5 - R3: task, web and host verbs refuse a user access-refused
---
duration_ms: 23.297818
type: 'test'
...
# Subtest: R3 leak: project.list shows granted projects only
ok 6 - R3 leak: project.list shows granted projects only
---
duration_ms: 13.028484
type: 'test'
...
# Subtest: R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
ok 7 - R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
---
duration_ms: 17.131417
type: 'test'
...
# Subtest: R3 leak: thread.list shows the user its own threads that are still granted, never others
ok 8 - R3 leak: thread.list shows the user its own threads that are still granted, never others
---
duration_ms: 34.619156
type: 'test'
...
# Subtest: R3 leak: names shows granted instances only
ok 9 - R3 leak: names shows granted instances only
---
duration_ms: 15.416042
type: 'test'
...
# Subtest: R3: thread.send is from user:<id> and writes no human.input
ok 10 - R3: thread.send is from user:<id> and writes no human.input
---
duration_ms: 13.82133
type: 'test'
...
# Subtest: R3: a reply-chain message.send into another user thread is refused
ok 11 - R3: a reply-chain message.send into another user thread is refused
---
duration_ms: 15.982484
type: 'test'
...
# Subtest: R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
ok 12 - R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
---
duration_ms: 19.055939
type: 'test'
...
# Subtest: R3: a reply to: user without a matching in_reply_to is refused user-reply-required
ok 13 - R3: a reply to: user without a matching in_reply_to is refused user-reply-required
---
duration_ms: 28.510227
type: 'test'
...
# Subtest: R3: a non-thread run, the human and the tester cannot send to: user
ok 14 - R3: a non-thread run, the human and the tester cannot send to: user
---
duration_ms: 18.072201
type: 'test'
...
# Subtest: R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
ok 15 - R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
---
duration_ms: 20.4668
type: 'test'
...
# Subtest: R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
ok 16 - R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
---
duration_ms: 17.954184
type: 'test'
...
# Subtest: R3: a user refusal is recorded as action.refused by user:<id>
ok 17 - R3: a user refusal is recorded as action.refused by user:<id>
---
duration_ms: 15.466589
type: 'test'
...
# Subtest: R3: a business with a role keyed user refuses invalid-business
ok 18 - R3: a business with a role keyed user refuses invalid-business
---
duration_ms: 10.080838
type: 'test'
...
# Subtest: R3: name.set refuses the name user
ok 19 - R3: name.set refuses the name user
---
duration_ms: 10.585336
type: 'test'
...
# Subtest: R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
ok 20 - R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
---
duration_ms: 14.198383
type: 'test'
...
# Subtest: R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
ok 21 - R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
---
duration_ms: 16.565998
type: 'test'
...
# Subtest: R3 r4: a non-function access throws before the store is read
ok 22 - R3 r4: a non-function access throws before the store is read
---
duration_ms: 0.139037
type: 'test'
...
# Subtest: C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
ok 23 - C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
---
duration_ms: 17.431688
type: 'test'
...
1..23
# tests 23
# suites 0
# pass 23
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 550.515661
@@ -0,0 +1,230 @@
TAP version 13
# Subtest: C2-04: launcher workspace clones code but never the library
ok 1 - C2-04: launcher workspace clones code but never the library
---
duration_ms: 1386.548788
type: 'test'
...
# Subtest: bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
ok 2 - bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
---
duration_ms: 1321.365393
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"rfeb27e280740","instance":"chief","turn":"6c7140a2-fbf2-4eb5-aade-18915e5d6071","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r4d1379ff54ed","instance":"coder","turn":"0828e9f0-3389-4d81-b773-d33ac8ed9436","source":"@none","code":"baseline-unknown"}
# Subtest: the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
ok 3 - the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
---
duration_ms: 729.440258
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"aa4f1c16-5525-464e-bf32-86dce3279b86","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"9bcd06eb-6a90-41fb-80ae-56a32bc1c628","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"8bbc58a8-f1e1-478c-957b-76e1d7d3a0d4","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"e1f9e391-0762-4643-bdfb-737a3f75468c","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"ee01381b-77c5-4737-9963-cc39bfcf3329","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"48edb0d5-0ec4-4e59-9eae-4f767252eaf9","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"837ddcc0-6e74-408d-a21f-aeefc287e380","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"702b0c7b-18c4-4f01-b71b-29fdf5ae1d79","source":"@none","code":"baseline-unknown"}
# Subtest: a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
ok 4 - a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
---
duration_ms: 1260.785468
type: 'test'
...
# Subtest: each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
ok 5 - each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
---
duration_ms: 4079.777924
type: 'test'
...
# Subtest: a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
ok 6 - a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
---
duration_ms: 602.445534
type: 'test'
...
# Subtest: REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
ok 7 - REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
---
duration_ms: 401.42897
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r91e3ad863ad3","instance":"coder","turn":"b80d99c8-e809-4461-9c46-6b36ca47b775","source":"@none","code":"baseline-unknown"}
# Subtest: a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
ok 8 - a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
---
duration_ms: 676.724798
type: 'test'
...
# Subtest: a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
ok 9 - a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
---
duration_ms: 578.985078
type: 'test'
...
# Subtest: after a restart, the new host's first tick starts a holder whose decision is still held
ok 10 - after a restart, the new host's first tick starts a holder whose decision is still held
---
duration_ms: 870.835521
type: 'test'
...
# Subtest: a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
ok 11 - a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
---
duration_ms: 633.801547
type: 'test'
...
# Subtest: a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
ok 12 - a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
---
duration_ms: 281.44642
type: 'test'
...
# Subtest: a runner that stops at once ends its launch with the runner's reason
ok 13 - a runner that stops at once ends its launch with the runner's reason
---
duration_ms: 350.182818
type: 'test'
...
# Subtest: a Claude Code launch with no session credential refuses before it starts
ok 14 - a Claude Code launch with no session credential refuses before it starts
---
duration_ms: 227.256782
type: 'test'
...
# Subtest: a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
ok 15 - a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
---
duration_ms: 355.020473
type: 'test'
...
# usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
# plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
# Subtest: row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
ok 16 - row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
---
duration_ms: 395.228029
type: 'test'
...
# Subtest: a runner killed from the host ends its launch as killed, not failed
ok 17 - a runner killed from the host ends its launch as killed, not failed
---
duration_ms: 426.266157
type: 'test'
...
# Subtest: a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
ok 18 - a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
---
duration_ms: 224.638023
type: 'test'
...
# Subtest: row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
ok 19 - row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
---
duration_ms: 193.63499
type: 'test'
...
# Subtest: row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
ok 20 - row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
---
duration_ms: 276.964378
type: 'test'
...
# Subtest: row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
ok 21 - row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
---
duration_ms: 199.95263
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r0292ea4215c0","instance":"chief","turn":"e2a42abe-43ec-4873-a349-c93850a0b2d1","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r0292ea4215c0","instance":"chief","turn":"b84fdcd2-08f1-4c7d-a1be-edf183ecf035","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rb645b3bdbf17","instance":"coder","turn":"8c651e69-aef3-428e-9ecb-fa2c53e86f79","source":"@none","code":"baseline-unknown"}
# Subtest: a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
ok 22 - a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
---
duration_ms: 1419.59525
type: 'test'
...
# Subtest: a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
ok 23 - a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
---
duration_ms: 1046.224325
type: 'test'
...
# Subtest: a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
ok 24 - a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
---
duration_ms: 3988.178511
type: 'test'
...
# Subtest: bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
ok 25 - bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
---
duration_ms: 242.392974
type: 'test'
...
# Subtest: bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
ok 26 - bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
---
duration_ms: 218.198607
type: 'test'
...
# Subtest: bus start --no-launch starts no thread tick; the same host with launches runs it
ok 27 - bus start --no-launch starts no thread tick; the same host with launches runs it
---
duration_ms: 1199.857353
type: 'test'
...
# Subtest: a malformed sessions.json refuses the host start with exit 3 and stays as it was
ok 28 - a malformed sessions.json refuses the host start with exit 3 and stays as it was
---
duration_ms: 181.02391
type: 'test'
...
# Subtest: an over-long launch request is refused at once, not at the 10 s idle timeout
ok 29 - an over-long launch request is refused at once, not at the 10 s idle timeout
---
duration_ms: 181.674266
type: 'test'
...
# Subtest: a launch client that never closes its side doesn't hold the host's close
ok 30 - a launch client that never closes its side doesn't hold the host's close
---
duration_ms: 183.632625
type: 'test'
...
# Subtest: a runner that ignores SIGTERM is killed when the host closes
ok 31 - a runner that ignores SIGTERM is killed when the host closes
---
duration_ms: 1365.88402
type: 'test'
...
# Subtest: kill switch refuses chief and hire before launch work and a clear permits a later request
ok 32 - kill switch refuses chief and hire before launch work and a clear permits a later request
---
duration_ms: 334.261623
type: 'test'
...
# Subtest: kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
ok 33 - kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
---
duration_ms: 337.104395
type: 'test'
...
# Subtest: a launch waiting across halt and resume cannot use its stale admission
ok 34 - a launch waiting across halt and resume cannot use its stale admission
---
duration_ms: 194.683714
type: 'test'
...
1..34
# tests 34
# suites 0
# pass 34
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 26492.10694
@@ -0,0 +1,122 @@
TAP version 13
# usage: {"event":"usage-unknown","business":"acme","run":"r2204f4cc976c","instance":"assistant","turn":"fdd36b75-12af-4495-ba60-8a2fdd991676","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r42619c482fdd","instance":"assistant","turn":"4a493440-df70-4de2-a9be-f31bb9efbe67","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r65dae8bd493b","instance":"assistant","turn":"2a27c708-e430-4bcc-82b5-67dc51173d46","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r4f264ceb0a50","instance":"assistant","turn":"dedfd89c-7a7a-4bb8-ba67-ca0e6a417ee8","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
ok 1 - REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
---
duration_ms: 5837.256276
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"red085121d9a7","instance":"assistant","turn":"536c8b62-f126-4b17-a1f2-756e2aede65e","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"ree31908db53d","instance":"assistant","turn":"f23f7966-9c87-4d7b-9d9a-93211c1431c0","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
ok 2 - REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
---
duration_ms: 1713.759995
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r5c82fe368237","instance":"assistant","turn":"081a280e-73b4-48fc-89a9-688e73704186","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
ok 3 - REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
---
duration_ms: 4442.41406
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r008070c44356","instance":"assistant","turn":"3175be8d-24d9-4d46-b9fe-e08c8edd0a5e","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r008070c44356","instance":"assistant","turn":"961030fd-2dd3-4deb-a9be-f10af56ba7f7","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
ok 4 - REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
---
duration_ms: 2897.279317
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
ok 5 - REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
---
duration_ms: 79.744891
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"rf65e96bfb0f4","instance":"assistant","turn":"61a6ab74-b6bd-4274-a83e-0f8fb4f61a68","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
ok 6 - REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
---
duration_ms: 374.600592
type: 'test'
...
# Subtest: REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
ok 7 - REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
---
duration_ms: 232.164242
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
---
duration_ms: 226.367358
type: 'test'
...
# Subtest: REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
ok 9 - REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
---
duration_ms: 78.315815
type: 'test'
...
# Subtest: C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
ok 10 - C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
---
duration_ms: 74.784926
type: 'test'
...
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
# Subtest: REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
ok 11 - REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
---
duration_ms: 192.227024
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r3ffa99981f81","instance":"assistant","turn":"c194605d-1b92-488c-9afe-2599f205e80e","source":"@none","code":"baseline-unknown"}
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mustStop","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":1}
# Subtest: REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
ok 12 - REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
---
duration_ms: 346.819478
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r013acfa3de9d","instance":"bean","turn":"26896136-02b5-4948-8c84-7e0be580c485","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r13746c56cf7b","instance":"chet","turn":"2a7d4bed-79e7-4b74-b602-c07fe90be87f","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r013acfa3de9d","instance":"bean","turn":"32222fad-092d-4ac9-b5de-d40de519bf82","source":"@none","code":"baseline-unknown"}
# Subtest: C2-09a: creative thread messages start distinct runs and keep each instance history separate
not ok 13 - C2-09a: creative thread messages start distinct runs and keep each instance history separate
---
duration_ms: 746.425172
type: 'test'
location: '/mnt/storage/scratch/rocko-r219/packages/cli/tests/thread-launch.test.mjs:463:1'
failureType: 'testCodeFailure'
error: "ENOENT: no such file or directory, open '/mnt/storage/scratch/tmp/rbyz9a6iu/mosaic-thread-Cgx1tR/data/threads/acme/0a27cae9-cdb8-436d-8cc8-a59b316577e9/session/history'"
code: 'ENOENT'
stack: |-
readFileSync (node:fs:621:20)
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/cli/tests/thread-launch.test.mjs:489:16)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# usage: {"event":"usage-unknown","business":"acme","run":"rb9998f7e5721","instance":"bean","turn":"6c83f086-940d-43ab-8219-0ee1b9dd4c7d","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rdf0bd4d3e49a","instance":"bean","turn":"663ed95c-c09b-4053-aeb7-1696b6acf272","source":"@none","code":"baseline-unknown"}
# Subtest: C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
ok 14 - C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
---
duration_ms: 643.667218
type: 'test'
...
1..14
# tests 14
# suites 0
# pass 13
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 18010.958178
@@ -0,0 +1 @@
[]
@@ -0,0 +1 @@
[]
@@ -0,0 +1 @@
{"head": "bfa421b6cd9b2b54d8da119e73b004aeafe606c3", "manifest": "2f7dfb7e41a33b89616edc5ce796e5d351c6f436284aa1e2dbfa773915be1820", "start": "2026-10-23T08:08:37.133924+00:00"}
@@ -0,0 +1,2 @@
Error response from daemon: network rocko-r219-gate_default not found
exit status 1
@@ -0,0 +1,9 @@
{"label": "single-bus-creative.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T08:08:37.137065+00:00", "end": "2026-10-23T08:08:37.553450+00:00", "seconds": 0.416, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/ruqo1xw54"}
{"label": "single-bus-threads.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T08:08:37.554878+00:00", "end": "2026-10-23T08:08:38.871766+00:00", "seconds": 1.317, "exit": 0, "timeout": false, "counts": {"tests": 19, "pass": 19, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rc8231nhf"}
{"label": "single-bus-user-principal.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T08:08:38.873280+00:00", "end": "2026-10-23T08:08:39.438646+00:00", "seconds": 0.565, "exit": 0, "timeout": false, "counts": {"tests": 23, "pass": 23, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r8q2inpml"}
{"label": "single-cli-launcher.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T08:08:39.440174+00:00", "end": "2026-10-23T08:09:06.064398+00:00", "seconds": 26.624, "exit": 0, "timeout": false, "counts": {"tests": 34, "pass": 34, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r1v5ginj7"}
{"label": "single-cli-thread-launch.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/thread-launch.test.mjs"], "start": "2026-10-23T08:09:06.066057+00:00", "end": "2026-10-23T08:09:24.118779+00:00", "seconds": 18.053, "exit": 0, "timeout": false, "counts": {"tests": 14, "pass": 14, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-thread-launch.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rkqddfdec"}
{"label": "single-webui-chat-browser.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/webui/tests/chat-browser.test.mjs"], "start": "2026-10-23T08:09:24.120216+00:00", "end": "2026-10-23T08:09:43.026298+00:00", "seconds": 18.906, "exit": 0, "timeout": false, "counts": {"tests": 14, "pass": 14, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-webui-chat-browser.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rgr2ixw_2"}
{"label": "single-webui-chat-instances.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/webui/tests/chat-instances.test.mjs"], "start": "2026-10-23T08:09:43.027683+00:00", "end": "2026-10-23T08:09:43.191979+00:00", "seconds": 0.164, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-webui-chat-instances.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rjrq7rm3e"}
{"label": "single-webui-chat.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/webui/tests/chat.test.mjs"], "start": "2026-10-23T08:09:43.193338+00:00", "end": "2026-10-23T08:09:43.708659+00:00", "seconds": 0.515, "exit": 0, "timeout": false, "counts": {"tests": 25, "pass": 25, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-webui-chat.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r2t2knykp"}
{"label": "suite-bus", "args": ["timeout", "-k", "10", "1800", "node", "--test-reporter=tap", "--test", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/account-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/agents.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/approval-binding.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/broker.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/business.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/channel-proof.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/chief-of-staff.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/comment-cursors.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/comment-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/creative.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-claim.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-status.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-store.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-verbs.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credentials.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/csuite-messaging.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/daily-quota-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/decision-close.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/decision-route.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/decisions.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/end-launch.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/fire-revokes-grants.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/gitea-grant-docker.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/gitea-grant.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/gitea-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-enroll.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-host.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/hard-waits.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/host-run.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/host-tools.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/human.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/instances-verb.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/interpreter.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/land.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/launch-notice.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/library-sync.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/library.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/lifecycle-revoke.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/lifecycle-verbs.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limit-checks.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limit-stop.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limits-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limits.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/message-citation.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/message-filter.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/messages.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/messaging.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/name-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/names.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/peer-transport.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/peercred.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/plugins-verb.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/principal.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/process.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing-timeout.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing-web.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/project-await.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/project-files.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/project-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/publish.test.mjs", "/mnt/storage/scLine truncated
@@ -0,0 +1,36 @@
TAP version 13
# organization: {"event":"reload-needs-restart","business":"demo"}
# organization: {"event":"reload-needs-restart","business":"demo"}
# Subtest: M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
ok 1 - M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
---
duration_ms: 34.682331
type: 'test'
...
# Subtest: a creative instance sends only to the user and hears only from the user
ok 2 - a creative instance sends only to the user and hears only from the user
---
duration_ms: 40.110807
type: 'test'
...
# Subtest: M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
ok 3 - M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
---
duration_ms: 73.937403
type: 'test'
...
# Subtest: row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
ok 4 - row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
---
duration_ms: 55.55064
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 330.224744
@@ -0,0 +1,124 @@
TAP version 13
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
---
duration_ms: 38.52476
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
---
duration_ms: 64.995866
type: 'test'
...
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
---
duration_ms: 32.003231
type: 'test'
...
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
---
duration_ms: 593.185752
type: 'test'
...
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
---
duration_ms: 24.74469
type: 'test'
...
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
---
duration_ms: 28.552018
type: 'test'
...
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
---
duration_ms: 31.22843
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
---
duration_ms: 32.904321
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
---
duration_ms: 28.44647
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
---
duration_ms: 24.975254
type: 'test'
...
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
---
duration_ms: 37.074551
type: 'test'
...
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
---
duration_ms: 28.633018
type: 'test'
...
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
---
duration_ms: 22.573463
type: 'test'
...
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
---
duration_ms: 24.854088
type: 'test'
...
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
---
duration_ms: 87.902895
type: 'test'
...
# Subtest: project.create refuses a repository name without its matching repo before any write
ok 16 - project.create refuses a repository name without its matching repo before any write
---
duration_ms: 16.647696
type: 'test'
...
# Subtest: project.create keeps unrelated names with and without a repository
ok 17 - project.create keeps unrelated names with and without a repository
---
duration_ms: 11.154646
type: 'test'
...
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
---
duration_ms: 47.242783
type: 'test'
...
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
---
duration_ms: 21.669855
type: 'test'
...
1..19
# tests 19
# suites 0
# pass 19
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1277.767202
@@ -0,0 +1,148 @@
TAP version 13
# Subtest: R3: bindUser binds a granted user and refuses one with no live grant
ok 1 - R3: bindUser binds a granted user and refuses one with no live grant
---
duration_ms: 13.924459
type: 'test'
...
# Subtest: R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
ok 2 - R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
---
duration_ms: 9.64513
type: 'test'
...
# Subtest: R3: each collaborator verb is allowed to a granted user
ok 3 - R3: each collaborator verb is allowed to a granted user
---
duration_ms: 18.606176
type: 'test'
...
# Subtest: R3: every verb off the collaborator list refuses a user access-refused before its own checks
ok 4 - R3: every verb off the collaborator list refuses a user access-refused before its own checks
---
duration_ms: 97.018766
type: 'test'
...
# Subtest: R3: task, web and host verbs refuse a user access-refused
ok 5 - R3: task, web and host verbs refuse a user access-refused
---
duration_ms: 22.278844
type: 'test'
...
# Subtest: R3 leak: project.list shows granted projects only
ok 6 - R3 leak: project.list shows granted projects only
---
duration_ms: 12.700951
type: 'test'
...
# Subtest: R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
ok 7 - R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
---
duration_ms: 15.363337
type: 'test'
...
# Subtest: R3 leak: thread.list shows the user its own threads that are still granted, never others
ok 8 - R3 leak: thread.list shows the user its own threads that are still granted, never others
---
duration_ms: 31.965026
type: 'test'
...
# Subtest: R3 leak: names shows granted instances only
ok 9 - R3 leak: names shows granted instances only
---
duration_ms: 14.127917
type: 'test'
...
# Subtest: R3: thread.send is from user:<id> and writes no human.input
ok 10 - R3: thread.send is from user:<id> and writes no human.input
---
duration_ms: 12.458443
type: 'test'
...
# Subtest: R3: a reply-chain message.send into another user thread is refused
ok 11 - R3: a reply-chain message.send into another user thread is refused
---
duration_ms: 14.424409
type: 'test'
...
# Subtest: R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
ok 12 - R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
---
duration_ms: 19.043152
type: 'test'
...
# Subtest: R3: a reply to: user without a matching in_reply_to is refused user-reply-required
ok 13 - R3: a reply to: user without a matching in_reply_to is refused user-reply-required
---
duration_ms: 28.314095
type: 'test'
...
# Subtest: R3: a non-thread run, the human and the tester cannot send to: user
ok 14 - R3: a non-thread run, the human and the tester cannot send to: user
---
duration_ms: 18.345488
type: 'test'
...
# Subtest: R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
ok 15 - R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
---
duration_ms: 19.2905
type: 'test'
...
# Subtest: R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
ok 16 - R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
---
duration_ms: 16.768862
type: 'test'
...
# Subtest: R3: a user refusal is recorded as action.refused by user:<id>
ok 17 - R3: a user refusal is recorded as action.refused by user:<id>
---
duration_ms: 13.097341
type: 'test'
...
# Subtest: R3: a business with a role keyed user refuses invalid-business
ok 18 - R3: a business with a role keyed user refuses invalid-business
---
duration_ms: 9.101012
type: 'test'
...
# Subtest: R3: name.set refuses the name user
ok 19 - R3: name.set refuses the name user
---
duration_ms: 9.51256
type: 'test'
...
# Subtest: R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
ok 20 - R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
---
duration_ms: 13.740514
type: 'test'
...
# Subtest: R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
ok 21 - R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
---
duration_ms: 15.133919
type: 'test'
...
# Subtest: R3 r4: a non-function access throws before the store is read
ok 22 - R3 r4: a non-function access throws before the store is read
---
duration_ms: 0.104353
type: 'test'
...
# Subtest: C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
ok 23 - C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
---
duration_ms: 15.763241
type: 'test'
...
1..23
# tests 23
# suites 0
# pass 23
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 519.847477
@@ -0,0 +1,229 @@
TAP version 13
# Subtest: C2-04: launcher workspace clones code but never the library
ok 1 - C2-04: launcher workspace clones code but never the library
---
duration_ms: 1398.187431
type: 'test'
...
# Subtest: bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
ok 2 - bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
---
duration_ms: 1322.865907
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"ra61e58b80d9d","instance":"chief","turn":"3525a183-23ba-49e0-b22d-e3b30769d70b","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r04fb94e1138e","instance":"coder","turn":"98345cf4-0180-45bb-8d5d-c2160653bd22","source":"@none","code":"baseline-unknown"}
# Subtest: the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
ok 3 - the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
---
duration_ms: 725.031053
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"5f3f43e7-dd01-49bb-8952-4d4352c5e7a9","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"ea6dd415-3ed9-4ea9-974c-82099bff050a","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"db418684-9d51-4f2b-97aa-094ed9964411","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"a95af522-8a95-40f8-bff1-fbd87ca56f8e","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"889654ce-750b-49ed-9722-9c0f3d7ea0fa","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"85616e3b-f44c-4754-8154-4a4eb28e8008","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"14d21ceb-82a6-43f4-91c0-e96168f2bb5c","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"fd1a6765-8c4a-445a-97aa-bceb3fde79dc","source":"@none","code":"baseline-unknown"}
# Subtest: a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
ok 4 - a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
---
duration_ms: 1238.624524
type: 'test'
...
# Subtest: each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
ok 5 - each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
---
duration_ms: 4045.23068
type: 'test'
...
# Subtest: a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
ok 6 - a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
---
duration_ms: 597.835066
type: 'test'
...
# Subtest: REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
ok 7 - REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
---
duration_ms: 353.154987
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r4652c5115528","instance":"coder","turn":"51a1a049-358f-438c-b27b-fca9a3396b36","source":"@none","code":"baseline-unknown"}
# Subtest: a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
ok 8 - a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
---
duration_ms: 684.451138
type: 'test'
...
# Subtest: a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
ok 9 - a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
---
duration_ms: 629.428309
type: 'test'
...
# Subtest: after a restart, the new host's first tick starts a holder whose decision is still held
ok 10 - after a restart, the new host's first tick starts a holder whose decision is still held
---
duration_ms: 904.62602
type: 'test'
...
# Subtest: a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
ok 11 - a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
---
duration_ms: 626.261549
type: 'test'
...
# Subtest: a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
ok 12 - a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
---
duration_ms: 267.648666
type: 'test'
...
# Subtest: a runner that stops at once ends its launch with the runner's reason
ok 13 - a runner that stops at once ends its launch with the runner's reason
---
duration_ms: 333.589161
type: 'test'
...
# Subtest: a Claude Code launch with no session credential refuses before it starts
ok 14 - a Claude Code launch with no session credential refuses before it starts
---
duration_ms: 196.204688
type: 'test'
...
# Subtest: a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
ok 15 - a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
---
duration_ms: 324.7864
type: 'test'
...
# usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
# plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
# Subtest: row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
ok 16 - row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
---
duration_ms: 371.866314
type: 'test'
...
# Subtest: a runner killed from the host ends its launch as killed, not failed
ok 17 - a runner killed from the host ends its launch as killed, not failed
---
duration_ms: 392.194089
type: 'test'
...
# Subtest: a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
ok 18 - a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
---
duration_ms: 202.884427
type: 'test'
...
# Subtest: row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
ok 19 - row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
---
duration_ms: 194.743064
type: 'test'
...
# Subtest: row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
ok 20 - row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
---
duration_ms: 259.238364
type: 'test'
...
# Subtest: row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
ok 21 - row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
---
duration_ms: 195.621694
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r9cf2ba4aaf31","instance":"chief","turn":"2c73ea95-2667-418b-bc55-e6718bf2dfad","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rc573a1b42fc5","instance":"coder","turn":"bea54efe-2383-4813-95e2-88f78fb5c666","source":"@none","code":"baseline-unknown"}
# Subtest: a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
ok 22 - a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
---
duration_ms: 1629.531256
type: 'test'
...
# Subtest: a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
ok 23 - a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
---
duration_ms: 1167.026862
type: 'test'
...
# Subtest: a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
ok 24 - a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
---
duration_ms: 4007.66668
type: 'test'
...
# Subtest: bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
ok 25 - bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
---
duration_ms: 239.242108
type: 'test'
...
# Subtest: bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
ok 26 - bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
---
duration_ms: 199.521317
type: 'test'
...
# Subtest: bus start --no-launch starts no thread tick; the same host with launches runs it
ok 27 - bus start --no-launch starts no thread tick; the same host with launches runs it
---
duration_ms: 1177.497521
type: 'test'
...
# Subtest: a malformed sessions.json refuses the host start with exit 3 and stays as it was
ok 28 - a malformed sessions.json refuses the host start with exit 3 and stays as it was
---
duration_ms: 178.077896
type: 'test'
...
# Subtest: an over-long launch request is refused at once, not at the 10 s idle timeout
ok 29 - an over-long launch request is refused at once, not at the 10 s idle timeout
---
duration_ms: 188.573981
type: 'test'
...
# Subtest: a launch client that never closes its side doesn't hold the host's close
ok 30 - a launch client that never closes its side doesn't hold the host's close
---
duration_ms: 184.053738
type: 'test'
...
# Subtest: a runner that ignores SIGTERM is killed when the host closes
ok 31 - a runner that ignores SIGTERM is killed when the host closes
---
duration_ms: 1355.882576
type: 'test'
...
# Subtest: kill switch refuses chief and hire before launch work and a clear permits a later request
ok 32 - kill switch refuses chief and hire before launch work and a clear permits a later request
---
duration_ms: 313.50548
type: 'test'
...
# Subtest: kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
ok 33 - kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
---
duration_ms: 323.703188
type: 'test'
...
# Subtest: a launch waiting across halt and resume cannot use its stale admission
ok 34 - a launch waiting across halt and resume cannot use its stale admission
---
duration_ms: 196.26668
type: 'test'
...
1..34
# tests 34
# suites 0
# pass 34
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 26550.340355
@@ -0,0 +1,113 @@
TAP version 13
# usage: {"event":"usage-unknown","business":"acme","run":"r3cbaedb93e9e","instance":"assistant","turn":"e9d9b5c9-7927-4afe-bbec-09ebb7e3c2d8","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"rdd4958ee00ee","instance":"assistant","turn":"691b3b36-ad3a-4a36-b0c0-c1cbe3bb9718","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r1410d16d8724","instance":"assistant","turn":"44a08edb-5d78-4422-9cc3-3de2dcbcc446","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r985e2dd44866","instance":"assistant","turn":"c19c4a62-eca2-4b50-aa07-a3039c941951","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
ok 1 - REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
---
duration_ms: 5834.488965
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r04a576c1f8c7","instance":"assistant","turn":"3dc2be1d-3ab4-42eb-96b2-fe2f23f9a098","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r71b29c392723","instance":"assistant","turn":"c4afa895-0e71-4e02-a6a8-93ee33a3149f","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
ok 2 - REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
---
duration_ms: 1705.349475
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r6a44bba72669","instance":"assistant","turn":"465d12fa-1123-481e-9f66-13cae16356a9","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
ok 3 - REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
---
duration_ms: 4414.175885
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r754792f99f16","instance":"assistant","turn":"a938a0f9-3876-4178-b3c0-3d993a88b6d1","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r754792f99f16","instance":"assistant","turn":"52f8b51f-968c-4168-b45a-0dbb31a8062e","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
ok 4 - REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
---
duration_ms: 2896.8825
type: 'test'
...
# Subtest: REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
ok 5 - REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
---
duration_ms: 80.27078
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"raeec2e45fadf","instance":"assistant","turn":"9feca456-d42b-4fef-a90c-e9e0bc42900d","source":"@none","code":"baseline-unknown"}
# Subtest: REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
ok 6 - REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
---
duration_ms: 375.325022
type: 'test'
...
# Subtest: REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
ok 7 - REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
---
duration_ms: 224.817609
type: 'test'
...
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
---
duration_ms: 218.942512
type: 'test'
...
# Subtest: REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
ok 9 - REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
---
duration_ms: 78.217748
type: 'test'
...
# Subtest: C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
ok 10 - C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
---
duration_ms: 77.300657
type: 'test'
...
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
# Subtest: REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
ok 11 - REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
---
duration_ms: 193.556032
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r88480b9d9bb0","instance":"assistant","turn":"608959cc-73cd-4dec-afb1-b6173eef917a","source":"@none","code":"baseline-unknown"}
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mustStop","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":1}
# Subtest: REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
ok 12 - REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
---
duration_ms: 344.672866
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"r757ceeb5a4e2","instance":"bean","turn":"aaba8e84-43ad-4d80-9f91-e47c4eb738a7","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r66f34e40ffb8","instance":"chet","turn":"49ba0ae9-1bf3-4f55-9890-89b3517fc2bd","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r757ceeb5a4e2","instance":"bean","turn":"48ba94a2-7b25-444e-a32a-38d665ee402e","source":"@none","code":"baseline-unknown"}
# Subtest: C2-09a: creative thread messages start distinct runs and keep each instance history separate
ok 13 - C2-09a: creative thread messages start distinct runs and keep each instance history separate
---
duration_ms: 752.885702
type: 'test'
...
# usage: {"event":"usage-unknown","business":"acme","run":"rbe602f558670","instance":"bean","turn":"9dd6de47-7f91-48a8-9bcb-4d0e23181d0a","source":"@none","code":"baseline-unknown"}
# usage: {"event":"usage-unknown","business":"acme","run":"r5c59dde2201a","instance":"bean","turn":"43b0b391-ccea-406f-bba4-b03f17ebe184","source":"@none","code":"baseline-unknown"}
# Subtest: C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
ok 14 - C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
---
duration_ms: 653.641213
type: 'test'
...
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 17977.782085
@@ -0,0 +1,94 @@
TAP version 13
# Subtest: chat browser: list/create named projects and threads, hostile labels inert and no implicit writes
ok 1 - chat browser: list/create named projects and threads, hostile labels inert and no implicit writes
---
duration_ms: 936.268368
type: 'test'
...
# Subtest: chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes
ok 2 - chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes
---
duration_ms: 602.776985
type: 'test'
...
# Subtest: chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send
ok 3 - chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send
---
duration_ms: 748.487224
type: 'test'
...
# Subtest: chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads
ok 4 - chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads
---
duration_ms: 808.240338
type: 'test'
...
# Subtest: chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate
ok 5 - chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate
---
duration_ms: 612.785782
type: 'test'
...
# Subtest: chat browser: scrolled history stays put, New messages and an own send return to the tail
ok 6 - chat browser: scrolled history stays put, New messages and an own send return to the tail
---
duration_ms: 587.664991
type: 'test'
...
# Subtest: chat browser: automatic polling follows a reply without a POST or a lost draft
ok 7 - chat browser: automatic polling follows a reply without a POST or a lost draft
---
duration_ms: 10575.301338
type: 'test'
...
# Subtest: chat browser: display stays bounded while every forward page remains reachable
ok 8 - chat browser: display stays bounded while every forward page remains reachable
---
duration_ms: 465.055105
type: 'test'
...
# Subtest: chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown
ok 9 - chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown
---
duration_ms: 543.602626
type: 'test'
...
# Subtest: chat browser: a refusal elsewhere forgets private chat drafts
ok 10 - chat browser: a refusal elsewhere forgets private chat drafts
---
duration_ms: 472.966826
type: 'test'
...
# Subtest: chat browser: a late project creation receipt cannot navigate away from another thread
ok 11 - chat browser: a late project creation receipt cannot navigate away from another thread
---
duration_ms: 549.648649
type: 'test'
...
# Subtest: C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST
ok 12 - C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST
---
duration_ms: 608.893729
type: 'test'
...
# Subtest: C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching
ok 13 - C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching
---
duration_ms: 653.667293
type: 'test'
...
# Subtest: C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread
ok 14 - C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread
---
duration_ms: 584.468742
type: 'test'
...
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 18856.847319
@@ -0,0 +1,34 @@
TAP version 13
# Subtest: C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads
ok 1 - C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads
---
duration_ms: 16.694095
type: 'test'
...
# Subtest: C2-09a metadata uses the private tester listener own bus and no human transport
ok 2 - C2-09a metadata uses the private tester listener own bus and no human transport
---
duration_ms: 2.7219
type: 'test'
...
# Subtest: C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields
ok 3 - C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields
---
duration_ms: 3.349671
type: 'test'
...
# Subtest: C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity
ok 4 - C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity
---
duration_ms: 2.024242
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 122.008979
@@ -0,0 +1,160 @@
TAP version 13
# Subtest: chat reads map only fixed verbs, strict forward paging and pure GET
ok 1 - chat reads map only fixed verbs, strict forward paging and pure GET
---
duration_ms: 32.994704
type: 'test'
...
# Subtest: chat refuses malformed and repeated query/id values before transport
ok 2 - chat refuses malformed and repeated query/id values before transport
---
duration_ms: 4.672731
type: 'test'
...
# Subtest: chat project.create: one verified write, fixed identity/class and private log
ok 3 - chat project.create: one verified write, fixed identity/class and private log
---
duration_ms: 2.784231
type: 'test'
...
# Subtest: chat project.create: each existing Console write fence makes zero bus calls
ok 4 - chat project.create: each existing Console write fence makes zero bus calls
---
duration_ms: 6.854048
type: 'test'
...
# Subtest: chat project.create: refusal or lost/malformed receipt never retries or invents success
ok 5 - chat project.create: refusal or lost/malformed receipt never retries or invents success
---
duration_ms: 3.53835
type: 'test'
...
# Subtest: chat thread.create: one verified write, fixed identity/class and private log
ok 6 - chat thread.create: one verified write, fixed identity/class and private log
---
duration_ms: 1.26663
type: 'test'
...
# Subtest: chat thread.create: each existing Console write fence makes zero bus calls
ok 7 - chat thread.create: each existing Console write fence makes zero bus calls
---
duration_ms: 5.658165
type: 'test'
...
# Subtest: chat thread.create: refusal or lost/malformed receipt never retries or invents success
ok 8 - chat thread.create: refusal or lost/malformed receipt never retries or invents success
---
duration_ms: 3.756541
type: 'test'
...
# Subtest: chat thread.send: one verified write, fixed identity/class and private log
ok 9 - chat thread.send: one verified write, fixed identity/class and private log
---
duration_ms: 1.052634
type: 'test'
...
# Subtest: chat thread.send: each existing Console write fence makes zero bus calls
ok 10 - chat thread.send: each existing Console write fence makes zero bus calls
---
duration_ms: 4.98916
type: 'test'
...
# Subtest: chat thread.send: refusal or lost/malformed receipt never retries or invents success
ok 11 - chat thread.send: refusal or lost/malformed receipt never retries or invents success
---
duration_ms: 2.799871
type: 'test'
...
# Subtest: chat thread.open: one verified write, fixed identity/class and private log
ok 12 - chat thread.open: one verified write, fixed identity/class and private log
---
duration_ms: 0.924357
type: 'test'
...
# Subtest: chat thread.open: each existing Console write fence makes zero bus calls
ok 13 - chat thread.open: each existing Console write fence makes zero bus calls
---
duration_ms: 5.935949
type: 'test'
...
# Subtest: chat thread.open: refusal or lost/malformed receipt never retries or invents success
ok 14 - chat thread.open: refusal or lost/malformed receipt never retries or invents success
---
duration_ms: 4.265801
type: 'test'
...
# Subtest: chat createThread receipt requires an instance even when its id is valid
ok 15 - chat createThread receipt requires an instance even when its id is valid
---
duration_ms: 1.94677
type: 'test'
...
# Subtest: chat write receipts expose only the fields belonging to their operation
ok 16 - chat write receipts expose only the fields belonging to their operation
---
duration_ms: 3.157564
type: 'test'
...
# Subtest: chat validates names, body, project and strict empty open payload
ok 17 - chat validates names, body, project and strict empty open payload
---
duration_ms: 3.454696
type: 'test'
...
# Subtest: chat read failures redact ownership and refuse invalid transport arrays
ok 18 - chat read failures redact ownership and refuse invalid transport arrays
---
duration_ms: 3.332048
type: 'test'
...
# Subtest: REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations
ok 19 - REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations
---
duration_ms: 83.632528
type: 'test'
...
# Subtest: chat repeats every POST boundary even when called behind an admitted request
ok 20 - chat repeats every POST boundary even when called behind an admitted request
---
duration_ms: 0.989385
type: 'test'
...
# Subtest: configured Console process injects chat through its existing human transport
ok 21 - configured Console process injects chat through its existing human transport
---
duration_ms: 134.070265
type: 'test'
...
# Subtest: chat assets, GET purity, method and unknown-route refusals are explicit
ok 22 - chat assets, GET purity, method and unknown-route refusals are explicit
---
duration_ms: 11.62792
type: 'test'
...
# Subtest: the chat seam names the transport it is given: the human one and the tester one both pass, anything else is a TypeError
ok 23 - the chat seam names the transport it is given: the human one and the tester one both pass, anything else is a TypeError
---
duration_ms: 0.497628
type: 'test'
...
# Subtest: C2-09a createThread forwards an explicit instance through the existing channel
ok 24 - C2-09a createThread forwards an explicit instance through the existing channel
---
duration_ms: 3.577119
type: 'test'
...
# Subtest: C2-09a malformed instance and extra thread fields refuse before the channel write
ok 25 - C2-09a malformed instance and extra thread fields refuse before the channel write
---
duration_ms: 3.204303
type: 'test'
...
1..25
# tests 25
# suites 0
# pass 25
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 441.402332
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
[]
@@ -0,0 +1 @@
[]
@@ -0,0 +1,130 @@
[
{
"label": "single-bus-creative.test",
"args": [
"node",
"--test",
"packages/bus/tests/creative.test.mjs"
],
"start": "2026-10-23T04:07:22.895462+00:00",
"end": "2026-10-23T04:07:23.260724+00:00",
"seconds": 0.365,
"exit": 0,
"timeout": false,
"counts": {
"tests": null,
"pass": null,
"fail": null,
"cancelled": null,
"skipped": null
},
"skips": [],
"log": "agents/rocko/work/queue-219/run-4a9c/single-bus-creative.test.tap",
"tmpdir": "/mnt/storage/scratch/tmp/rsrt5bzap",
"derived_spec_counts": {
"tests": 4,
"pass": 4,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"failed_test_names": []
},
{
"label": "single-bus-threads.test",
"args": [
"node",
"--test",
"packages/bus/tests/threads.test.mjs"
],
"start": "2026-10-23T04:07:23.262757+00:00",
"end": "2026-10-23T04:07:24.629883+00:00",
"seconds": 1.367,
"exit": 0,
"timeout": false,
"counts": {
"tests": null,
"pass": null,
"fail": null,
"cancelled": null,
"skipped": null
},
"skips": [],
"log": "agents/rocko/work/queue-219/run-4a9c/single-bus-threads.test.tap",
"tmpdir": "/mnt/storage/scratch/tmp/r5anojtow",
"derived_spec_counts": {
"tests": 19,
"pass": 19,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"failed_test_names": []
},
{
"label": "single-bus-user-principal.test",
"args": [
"node",
"--test",
"packages/bus/tests/user-principal.test.mjs"
],
"start": "2026-10-23T04:07:24.631177+00:00",
"end": "2026-10-23T04:07:25.246596+00:00",
"seconds": 0.615,
"exit": 0,
"timeout": false,
"counts": {
"tests": null,
"pass": null,
"fail": null,
"cancelled": null,
"skipped": null
},
"skips": [],
"log": "agents/rocko/work/queue-219/run-4a9c/single-bus-user-principal.test.tap",
"tmpdir": "/mnt/storage/scratch/tmp/rg47u_3c2",
"derived_spec_counts": {
"tests": 23,
"pass": 23,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"failed_test_names": []
},
{
"label": "single-cli-launcher.test",
"args": [
"node",
"--test",
"packages/cli/tests/launcher.test.mjs"
],
"start": "2026-10-23T04:07:25.247904+00:00",
"end": "2026-10-23T04:07:51.870129+00:00",
"seconds": 26.622,
"exit": 1,
"timeout": false,
"counts": {
"tests": null,
"pass": null,
"fail": null,
"cancelled": null,
"skipped": null
},
"skips": [],
"log": "agents/rocko/work/queue-219/run-4a9c/single-cli-launcher.test.tap",
"tmpdir": "/mnt/storage/scratch/tmp/r8oua_z8g",
"derived_spec_counts": {
"tests": 34,
"pass": 33,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"failed_test_names": [
"row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason",
"failing tests:",
"row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason"
]
}
]
@@ -0,0 +1 @@
{"head": "c7bf3622dd617ea1e3e68a1c9ce44774e2cfe9da", "manifest": "cd791883449f0d64fca06a9a855f855c93d7db74ced53ccebbcb0bad5a0206a7", "start": "2026-10-23T04:07:22.892671+00:00"}
@@ -0,0 +1,2 @@
Error response from daemon: network rocko-r219-gate_default not found
exit status 1
@@ -0,0 +1,4 @@
{"label": "single-bus-creative.test", "args": ["node", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T04:07:22.895462+00:00", "end": "2026-10-23T04:07:23.260724+00:00", "seconds": 0.365, "exit": 0, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rsrt5bzap"}
{"label": "single-bus-threads.test", "args": ["node", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T04:07:23.262757+00:00", "end": "2026-10-23T04:07:24.629883+00:00", "seconds": 1.367, "exit": 0, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r5anojtow"}
{"label": "single-bus-user-principal.test", "args": ["node", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T04:07:24.631177+00:00", "end": "2026-10-23T04:07:25.246596+00:00", "seconds": 0.615, "exit": 0, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rg47u_3c2"}
{"label": "single-cli-launcher.test", "args": ["node", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T04:07:25.247904+00:00", "end": "2026-10-23T04:07:51.870129+00:00", "seconds": 26.622, "exit": 1, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r8oua_z8g"}
@@ -0,0 +1,14 @@
organization: {"event":"reload-needs-restart","business":"demo"}
organization: {"event":"reload-needs-restart","business":"demo"}
✔ M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises (34.696206ms)
✔ a creative instance sends only to the user and hears only from the user (41.081291ms)
✔ M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works (81.11191ms)
✔ row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb (57.456912ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 334.223966
@@ -0,0 +1,27 @@
✔ REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger (36.004676ms)
✔ REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker (68.738374ms)
✔ REQ-MSG-1: a message to or from the assistant names its thread, or is refused (32.515768ms)
✔ REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work (610.990913ms)
✔ REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance (23.409918ms)
✔ REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder (28.817937ms)
✔ REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it (30.260544ms)
✔ REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder (32.327162ms)
✔ REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder (29.133476ms)
✔ REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant (26.476132ms)
✔ Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's (39.418785ms)
✔ REQ-USER-2: the human verbs refuse another owner's thread (28.785396ms)
✔ REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one (22.979076ms)
✔ REQ-ORG-2: projects and threads are named once, and the repo names a business project (25.496215ms)
✔ REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing (90.366693ms)
✔ project.create refuses a repository name without its matching repo before any write (16.151198ms)
✔ project.create keeps unrelated names with and without a repository (11.187274ms)
✔ C2-09a: explicit creative instances bind independent threads, default sessions and holders (47.776915ms)
✔ C2-09a: explicit missing, non-thread and malformed selectors create no thread or session (20.207317ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1299.709199
@@ -0,0 +1,31 @@
✔ R3: bindUser binds a granted user and refuses one with no live grant (14.786261ms)
✔ R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say (9.870795ms)
✔ R3: each collaborator verb is allowed to a granted user (19.637409ms)
✔ R3: every verb off the collaborator list refuses a user access-refused before its own checks (105.182773ms)
✔ R3: task, web and host verbs refuse a user access-refused (25.672551ms)
✔ R3 leak: project.list shows granted projects only (13.27019ms)
✔ R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist (15.965771ms)
✔ R3 leak: thread.list shows the user its own threads that are still granted, never others (35.018528ms)
✔ R3 leak: names shows granted instances only (15.057536ms)
✔ R3: thread.send is from user:<id> and writes no human.input (13.514578ms)
✔ R3: a reply-chain message.send into another user thread is refused (16.631267ms)
✔ R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice (18.899841ms)
✔ R3: a reply to: user without a matching in_reply_to is refused user-reply-required (28.916725ms)
✔ R3: a non-thread run, the human and the tester cannot send to: user (17.965675ms)
✔ R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9 (19.241443ms)
✔ R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request (17.997053ms)
✔ R3: a user refusal is recorded as action.refused by user:<id> (14.840437ms)
✔ R3: a business with a role keyed user refuses invalid-business (10.081108ms)
✔ R3: name.set refuses the name user (10.301262ms)
✔ R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers (14.447561ms)
✔ R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester (15.786706ms)
✔ R3 r4: a non-function access throws before the store is read (0.121803ms)
✔ C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project (16.966486ms)
ℹ tests 23
ℹ suites 0
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 545.0572
@@ -0,0 +1,84 @@
✔ C2-04: launcher workspace clones code but never the library (1405.320275ms)
✔ bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest (1322.785792ms)
usage: {"event":"usage-unknown","business":"acme","run":"r6724498202ca","instance":"chief","turn":"4d5e8541-7110-44bb-8a7a-ed92208a0fd3","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"rd57aefe67267","instance":"coder","turn":"b9afd99d-fad8-48ce-8c03-ca9f9b329cfb","source":"@none","code":"baseline-unknown"}
✔ the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code (726.266248ms)
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"0286a736-4a58-46ff-92bb-e974030f5bd7","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"a6496c08-1b00-4b40-8d0e-03bc7e972248","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"cdf4283b-3be0-415c-88d2-ea1e45163617","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"046a20f2-e7eb-477b-b6e6-b7588b603cbd","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"9a07e317-c472-4f17-bb7d-2309cf341af7","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"b0ebda74-f2a9-440a-a173-39dcae896036","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"15238cb8-619f-4a31-b853-fb55d9b86e79","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"8a3ef9b0-73bb-4a3f-bf0e-06dcbc0f60c4","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"4077ae80-1bfc-4cdd-bfca-27c992282797","source":"@none","code":"baseline-unknown"}
✔ a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold (1123.845931ms)
✔ each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged (4152.636087ms)
✔ a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none (617.243337ms)
✔ REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing (412.306281ms)
usage: {"event":"usage-unknown","business":"acme","run":"rcf551273e0ad","instance":"coder","turn":"c4220d02-0bf0-4122-aa52-925afe04df92","source":"@none","code":"baseline-unknown"}
✔ a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited (700.748992ms)
✔ a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder (638.103132ms)
✔ after a restart, the new host's first tick starts a holder whose decision is still held (890.273051ms)
✔ a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder (632.625787ms)
✔ a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left (266.198371ms)
✔ a runner that stops at once ends its launch with the runner's reason (345.619813ms)
✔ a Claude Code launch with no session credential refuses before it starts (189.183758ms)
✔ a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables (338.515656ms)
usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
✔ row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason (364.92973ms)
✔ a runner killed from the host ends its launch as killed, not failed (396.074165ms)
✔ a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback (192.417417ms)
✔ row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts (190.827519ms)
✖ row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason (192.548301ms)
✔ row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts (189.824532ms)
usage: {"event":"usage-unknown","business":"acme","run":"rf7aede34a71b","instance":"chief","turn":"799876e4-fe02-4621-aaac-dfc5cf192082","source":"@none","code":"baseline-unknown"}
usage: {"event":"usage-unknown","business":"acme","run":"ref958a5bcbb6","instance":"coder","turn":"ce0a5214-37f5-48d4-ae90-c9f6745e058d","source":"@none","code":"baseline-unknown"}
✔ a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold (1782.388685ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (1025.160483ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3976.016342ms)
✔ bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was (236.101723ms)
✔ bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned (195.720542ms)
✔ bus start --no-launch starts no thread tick; the same host with launches runs it (1179.765366ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (174.297238ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (187.137601ms)
✔ a launch client that never closes its side doesn't hold the host's close (184.807395ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1359.638326ms)
✔ kill switch refuses chief and hire before launch work and a clear permits a later request (317.536928ms)
✔ kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle (326.035999ms)
✔ a launch waiting across halt and resume cannot use its stale admission (196.129785ms)
ℹ tests 34
ℹ suites 0
ℹ pass 33
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 26568.728775
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:766:1
✖ row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason (192.548301ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
'refused',
+ 'session-required',
- 'launch-failed',
1
]
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/cli/tests/launcher.test.mjs:788:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ 'refused', 'session-required', 1 ],
expected: [ 'refused', 'launch-failed', 1 ],
operator: 'deepStrictEqual',
diff: 'simple'
}
+17 -14
View File
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
});
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
@@ -610,7 +610,7 @@ export class Broker {
);
}
#threaded(business, role) {
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
}
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
@@ -6171,24 +6171,27 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
// A thread belongs to the user who made it and to the business's one thread instance.
// A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'name'], ['project', 'name']);
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
const instance = a.instance === undefined
? (instances.length === 1 ? instances[0] : null)
: identifier(a.instance);
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
// The bus never picks between instances; the business file declares exactly one.
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
if (!instances.length) fail('thread-instance-missing');
if (instances.length > 1) fail('thread-instance-ambiguous');
// The human selects an instance; omission is compatible only with one candidate.
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -6197,13 +6200,13 @@ export class Broker {
this.#now(),
s.business,
a.project,
instances[0],
instance,
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
return { id: tid, instance: instances[0] };
this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
return { id: tid, instance };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// and the tester's lists only its own (row 175 listed it the business human's).
+8 -5
View File
@@ -44,15 +44,18 @@ function setup(t) {
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
const threads = { assistant: thread };
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
if (['bean', 'quill'].includes(role))
threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const count = (sql) => store.get(sql).n;
return { call, count, store };
return { call, count, store, threads };
}
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
});
test('a creative instance sends only to the user and hears only from the user', (t) => {
const { call, count } = setup(t);
const { call, count, threads } = setup(t);
for (const who of ['bean', 'quill']) {
call(who, 'message.send', { to: 'human', body: 'hello' });
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
for (const from of ['chief', 'pm']) {
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
}
call('human', 'message.send', { to: who, body: 'hello' });
call('human', 'thread.send', { thread: threads[who], body: 'hello' });
}
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
+4 -1
View File
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
const f = await setup(t);
assert.equal((await f.approve()).end.action, 'grant.applied');
const r = await restart(t, f, creativeOps(f.business));
const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
r.broker.request(cap, { verb: 'role.claim' });
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
const before = f.requests().length;
+2 -1
View File
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more