Compare commits
1
Commits
refactor
...
rocko/row219
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd17203ed8 |
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
062646487451993302c768c6812b827c834e952b
|
||||
@@ -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
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
Reference in new issue
Block a user