Compare commits

..
Author SHA1 Message Date
Rocko fd17203ed8 wip: publish unverified row 219 inspection candidate
Preserve the draft for inspection under sage-3113. Tests and mutants have not run. Old-test adaptations approved in mos-1316 and mos-1317 are recorded in OLD-TEST-CHANGES.md.
2026-10-22 21:03:38 -05:00
31 changed files with 1734 additions and 162 deletions

No files matched your search

-68
View File
@@ -1,68 +0,0 @@
# Vikunja574: deterministic fixture Git before/after comparisons
Owner Gander, gate Sage. mos-1369 A4 and mos-1370; sage-3107 and sage-3110. Take574 before573, as separate candidates. Base062646487451993302c768c6812b827c834e952b (origin/refactor at start), branchgander/v574. No queue move before Sage places the row. Build/push authorized now; every test/proof run waits Sage CLEAR after270+271 run5. Row292 remains independently held for final base and C1 review.
## Exact authorized delta
Only prepend `-c maintenance.auto=false -c gc.auto=0` to each fixture helper's Git argv. No production change, environment change, assertion change or fingerprint/snapshot change. Full-root comparisons still include .git. Production checkout.mjs's SAFE argv already contains both flags; gitEnv() alone does not. The helper called gitEnv() but skipped SAFE.
- packages/seat/tests/checkout.test.mjs:git34-35 commits fixture roots; once():88-91 fingerprints root including.git. Whole clone comparison246-249 also covers it.
- packages/seat/tests/working.test.mjs:git27-28 commits fixture repositories; fingerprint():40 and refusal checks83-86/120-122 cover the whole root.
- packages/cli/tests/git-socket.test.mjs:gitIn32-33 commits projects; fingerprint():43 and once79-101 cover project/.git.
- packages/cli/tests/lifecycle.test.mjs:gitIn47-49, commit71; snapshot459 walks every regular file, T11 compares pb.root831/839 including project/.git. Outside original list, explicitly authorized sage-3110 / mos-1370 before editing.
Static preservation check removes only the four inserted argv elements from each candidate file and requires exact equality with its base blob. Thus every original assertion, fingerprint and snapshot byte stays intact. No new tests mirror the flags; runtime evidence is the requested trace over real fixture commits and whole files.
## Named but unchanged
- bus/grant-enroll.test.mjs imports fingerprint from host-grant.mjs for SSH host keys. No fixture Git helper or Git command; unchanged.
- bus/grant-executor.test.mjs uses SSH-key fingerprints; its commit references are grant transaction steps, not Git fixture commits. No Git helper; unchanged.
- bus/host-tools.test.mjs:28 hashes decoded SSH public-key bytes. No Git helper; unchanged.
- conversation/reader.test.mjs:52/71/73 fingerprints session fixture files, but creates no Git repository and makes no Git call. Unchanged.
## Other fixture audit, screened and unchanged
- cli/lifecycle.test.mjs: whole-root .git snapshot is exposed; now included by explicit scope ruling above.
- cli/launcher.test.mjs: fixture commits, but the before/after byte check961-971 names the sessions JSON file, not the repository tree or Git object directory. No maintenance-lock fingerprint exposure in that comparison.
- cli/thread-launch.test.mjs and session-launch.test.mjs: fixture repositories, but compared before/transcripts/events/session-store values are application histories or evidence directories, not a whole .git tree. Unchanged.
- cli/publish-socket.test.mjs and seat/publish.test.mjs: fixture commits, with ref values and fake-Gitea request/credential assertions. No whole-root fingerprint/snapshot; unchanged.
- seat/landing.test.mjs: fixture commits support gate/root-path checks. The tree() helper builds a fixture and comparisons concern gate steps/results and output, not a whole-root byte inventory. Unchanged.
- harness/git-tools.test.mjs: fixture commits support advertised object/ref and request assertions; no source-root byte fingerprint. Unchanged.
- project/project.test.mjs, human-file.test.mjs and read.test.mjs: before/after checks use HEAD/status, spec content, or a record path; no full .git inventory including maintenance.lock. tester-init.test.mjs checks created records and identity. Unchanged.
- bus/project-files.test.mjs via spec-fixtures.mjs: state() compares rev-parse HEAD and status --porcelain; detached maintenance changes neither asserted logical value. Unchanged.
- library/mirror.test.mjs and sync.test.mjs via helpers.mjs: before/after byte checks name the sync-state file; comparison does not inventory Git objects/maintenance.lock. Unchanged.
- cli/stack-copy.test.mjs: tree() compares published copies, which exclude source .git; source fixture already configures gc.auto0. No whole-source-repo .git fingerprint. Unchanged.
- cli/stack-release-fixture.mjs and release tests: fp() compares unit, wrapper, pointer and store bytes, and tree() comparisons cover data/copies; source fixture.git is outside those roots and already sets gc.auto0. Unchanged.
- webui/pinned-copy.test.mjs: fixture commits and gc.auto0; before/after assertion names config bytes and copied output, not source.git inventory. Unchanged.
- queue/store.test.mjs via helpers.mjs: snap337 compares queue/view/.git/mosaic-queue.head only, not object/maintenance-lock paths. review tests compare request/log records. Unchanged.
- ledger/ledger.test.mjs: Git fixture history is read as logical commits/timestamps, no whole-root .git fingerprint comparison found. Unchanged.
This is an audit of fixture before/after comparisons, not a claim every unflagged fixture Git helper is fixed. No outside named scope was changed without approval. Mos's provided probe /mnt/storage/scratch/mos-1369/maint-lock-probe.py was read only, never executed.
## Trace and gate plan, no execution yet
Real Git: /usr/bin/git, `git version 2.55.0` (read-only version query). gitEnv() retains PATH and scrubs outer tracing env, so an outer GIT_TRACE2 alone would be vacuous. On CLEAR create an executable git shim in own scratch, outside the candidate. It only exports GIT_TRACE2 to a per-run absolute trace path and execs /usr/bin/git with the unchanged argv. The candidate contains no shim.
Positive control first: a fresh fixture-style repo/commit WITHOUT either flag, traced through that shim, must show a maintenance child_start. Do not accept a missing/empty trace as success. Allow the detached positive-control child to finish and verify its scratch repo is idle before the flagged proof.
Flagged control and four touched files whole then use the shim. Require observed fixture commit commands in each applicable trace and zero maintenance child_start. Classify a maintenance child by the trace's child_start and `maintenance run --auto` command, not by a substring in this plan. Save the raw trace, tested SHA, exact command/timeouts, command/child counts, and restored-tree check. No assertion weakening or .git exclusion is allowed. Any old red, timeout, maintenance child, or missing positive control stops for Sage/Mos before a retry.
Recommended slot scope: positive/flagged fixture control plus the four touched files whole with GIT_TRACE2, and any broader seat/CLI suite Sage requests. Record per-file timeout-k10 at least3x successful historical whole-file receipt with headroom before launch; use900s where timing is unknown. TMPDIR from mktemp-d /mnt/storage/scratch/tmp/gXXXXXX, never /tmp. Pipefail/tee and busy check before each start. Trace runs are test work and are not authorized before CLEAR. No dependencies installed and no live ports/units/config accessed. Session registration remains Sage's.
## Filbert static approval and proof refinements
filbert-v574-7da38224-gander statically approves source65e919e5c / packet7da382244 / manifestcdda41f5. No runtime proof yet. Their independent audit found no missed fixture within the whole-root .git race class.
P1: checkout and working tests resolve command-v-git when loading their own command wrappers. The trace shim therefore lives in a stable absolute scratch bin directory for the entire run, including fixture hooks. It chains only to /usr/bin/git2.55.0 and is not deleted until all calls and detached children finish.
P2: add GIT_TRACE2_EVENT alongside GIT_TRACE2 in the scratch shim, pointing at a per-run directory. Git writes one JSON event file per process, avoiding interleaved event lines. Count child_start argv tokens for maintenance/run/--auto exactly and report actual fixture commit start counts alongside zero maintenance. The shim only sets tracing variables and execs the real git; no Git argv/config or candidate file is changed by tracing.
P3: both positive and flagged controls call the real gitEnv() from checkout.mjs, with PATH resolving that same shim. A shell-only unflagged commit is not sufficient. Positive requires observed fixture commit and maintenance child_start; flagged requires observed fixture commit and zero such child. No missing/empty trace counts as a pass.
P4: after each traced whole file, wait for no git process whose /proc cwd is under that run TMPDIR, including deleted cwd, before counting. Detached maintenance could write events after Node exits. Save the cwd sweep and count trace only after the writer set is empty. A nonempty/unstable writer set is a stop, never silently ignored.
P5: green fingerprints alone are not the proof. The trace commit count and maintenance-child count are required for every changed suite.
Additional screened files from Filbert, unchanged: bus/publish.test.mjs lists JSON files only; discord/git.test.mjs checks .vault-locks only; stack-release-external checks named files and tree(e.state); queue/write.test.mjs lists gitDir entries filtered to .tmp. Shared helpers plugins/tests/helpers, runs/tests/helpers, conversation/tests/harness and case-sudo-fixture make no Git calls.
Follow-up note, outside574: product Git commits in queue/src/store.mjs, discord/src/git.mjs and harness/src/tools.mjs do not carry both maintenance flags. No whole-root .git fingerprint currently covers those roots. A future fingerprint test there must account for detached maintenance. This note authorizes no product change or assertion change here. working.mjs fetches at183/237/245 and harness git-tools fetch249 use --no-auto-maintenance; existing production checkout SAFE has both flags.
-4
View File
@@ -1,4 +0,0 @@
721af7a3745dae10d0dad9226e7ba5ce32754b5da7367aa13f970d7f03677a3c packages/cli/tests/git-socket.test.mjs
a0cae5797886b729600dcbcad8e38ec94f97143c77fd91648050417c2d5630f7 packages/cli/tests/lifecycle.test.mjs
cb3941fed464fd4c88aac7c05bb32db1b1776858650979523f64dc6fc3baf7d3 packages/seat/tests/checkout.test.mjs
22cbdd43fc11c6f234d0f2c1512d5039839d1242fd421e62f2aa3d3db1c9de05 packages/seat/tests/working.test.mjs
-30
View File
@@ -1,30 +0,0 @@
[
{
"file": "packages/cli/tests/git-socket.test.mjs",
"base_sha256": "ec4bbbc98adab6561205e5ec8e2f2495f1f55224da53e567c7a72e36b5410709",
"candidate_sha256": "721af7a3745dae10d0dad9226e7ba5ce32754b5da7367aa13f970d7f03677a3c",
"only_inserted_argv_flags": true,
"assertions_and_fingerprint_unchanged": true
},
{
"file": "packages/cli/tests/lifecycle.test.mjs",
"base_sha256": "5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938",
"candidate_sha256": "a0cae5797886b729600dcbcad8e38ec94f97143c77fd91648050417c2d5630f7",
"only_inserted_argv_flags": true,
"assertions_and_fingerprint_unchanged": true
},
{
"file": "packages/seat/tests/checkout.test.mjs",
"base_sha256": "663040d7c737aba6403e6397bacf1325b558cc14df3e5677f0a28f9499c45127",
"candidate_sha256": "cb3941fed464fd4c88aac7c05bb32db1b1776858650979523f64dc6fc3baf7d3",
"only_inserted_argv_flags": true,
"assertions_and_fingerprint_unchanged": true
},
{
"file": "packages/seat/tests/working.test.mjs",
"base_sha256": "15234267a0e28c53b13a4219ea45e71608722fb137d1f266adfd3f80040eb7c6",
"candidate_sha256": "22cbdd43fc11c6f234d0f2c1512d5039839d1242fd421e62f2aa3d3db1c9de05",
"only_inserted_argv_flags": true,
"assertions_and_fingerprint_unchanged": true
}
]
-15
View File
@@ -1,15 +0,0 @@
{
"vikunja": 574,
"queue": "pending Sage placement",
"gate": "sage",
"base": "062646487451993302c768c6812b827c834e952b",
"source": "65e919e5cef30911b9902f410549da431423cec4",
"branch": "gander/v574",
"manifest": "cdda41f5baa6b04e6e5bb9b82beff8e00ceef440c6702565cc86944f249bb5d3",
"tests": "NOT RUN; no trace/Node/test until Sage CLEAR",
"scope_authority": [
"mos-1369",
"sage-3110",
"mos-1370"
]
}
+181
View File
@@ -0,0 +1,181 @@
# 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.
@@ -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.
+1
View File
@@ -0,0 +1 @@
062646487451993302c768c6812b827c834e952b
+777
View File
@@ -0,0 +1,777 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index c50f8ac23..5ee242f1d 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([
@@ -312,7 +312,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
@@ -593,7 +593,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) {
@@ -5790,24 +5790,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(
@@ -5816,13 +5819,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 e044b7627..f019df83f 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";
@@ -175,8 +176,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;
@@ -299,7 +298,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`);
@@ -854,7 +853,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..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) => {
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 0d207a0a0..84f8ed088 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,71 @@ 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, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'session', 'history'), 'utf8'), 'Chet input\n');
+ 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 @@
38c4cb694cb756cfc771395afbeaa0779737bb94bc55bd1ea3a72a00d5448041 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
c905590032b6f8eb7d607d04ce6a585ed783a0c84033dd8436eab7922f2251e1 packages/cli/src/launcher.mjs
fc301cde49e6db1286e90d6ac3410f5f4d87351bec61f1bae27c235a041848b6 packages/cli/tests/launcher.test.mjs
a219036737419870e0aa9d0da20843759a66be5bafff44d3292593833f8fcb9a 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
+193
View File
@@ -0,0 +1,193 @@
[
{
"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: explicit creative"
},
{
"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\";"
}
]
}
]
@@ -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,39 @@
"""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: explicit creative')
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";')]
(p/'mutants.json').write_text(json.dumps(specs,indent=2)+'\n')
print('STATIC_PREFLIGHT',len(specs),'exact-once sites; no tests run')
+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([
@@ -312,7 +312,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
@@ -593,7 +593,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) {
@@ -5790,24 +5790,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(
@@ -5816,13 +5819,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);
+39
View File
@@ -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');
});
+20 -1
View File
@@ -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');
});
+4 -5
View File
@@ -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";
@@ -175,8 +176,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;
@@ -299,7 +298,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`);
@@ -854,7 +853,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)));
}
+1 -1
View File
@@ -30,7 +30,7 @@ import { fixture } from "./helpers.mjs";
const TASK = "vikunja:1/2";
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
return r.stdout.trim();
}
+14 -7
View File
@@ -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) => {
+1 -1
View File
@@ -45,7 +45,7 @@ async function until(fn, ms = 15000) {
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
+84
View File
@@ -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,71 @@ 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, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'session', 'history'), 'utf8'), 'Chet input\n');
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]));
});
+1 -1
View File
@@ -32,7 +32,7 @@ function tmp(t) {
// git for the test's own setup, with no user config.
function git(cwd, ...args) {
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
+1 -1
View File
@@ -25,7 +25,7 @@ function tmp(t) {
}
function git(cwd, ...args) {
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
+2 -1
View File
@@ -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();
+1 -1
View File
@@ -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 }),
+34 -6
View File
@@ -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');
}
});
+20
View File
@@ -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 });
}
+65 -1
View File
@@ -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);
});
@@ -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]);
});
+18
View File
@@ -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, []);
});