Post-test fixes: event authorship (resolver, contract proposer per revision), /events lightweight polling, SSH pubkey bootstrap pattern in secrets policy
deploy / deploy (push) Canceled after 0s

This commit is contained in:
2026-09-06 23:11:19 +03:00
parent 4301cd3838
commit 4d60b81176
9 changed files with 119 additions and 12 deletions
+7 -1
View File
@@ -150,7 +150,13 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
contract:
i.contract ??
(i.contractVersion > 0
? { version: i.contractVersion, markdown: i.contractMarkdown, updated_at: null, agreements: [] }
? {
version: i.contractVersion,
markdown: i.contractMarkdown,
updated_at: null,
agreements: [],
revisions: [],
}
: null),
});
if (timeline.length === 0) {
+17 -1
View File
@@ -7,7 +7,7 @@
*/
export const SECRETS_POLICY_SHORT =
'Security / secrets: NEVER put secret VALUES (passwords, API keys, tokens, private keys, cookies, credentials, .env contents) into this room — not in messages, questions, resolutions, the contract, URLs or metadata. Discuss only a secret\'s NAME, source, purpose and transfer method. Move values out-of-band (SSH/SCP/SFTP directly between hosts); if no side-channel exists, record SECRET_TRANSFER_REQUIRED instead. Rendezvous = control plane; secret values travel out-of-band. Full policy: {base}/security.md';
'Security / secrets: NEVER put secret VALUES (passwords, API keys, tokens, private keys, cookies, credentials, .env contents) into this room — not in messages, questions, resolutions, the contract, URLs or metadata. Discuss only a secret\'s NAME, source, purpose and transfer method. Move values out-of-band (SSH/SCP/SFTP directly between hosts); if no side-channel exists, record SECRET_TRANSFER_REQUIRED instead. To bootstrap an SSH channel, each side generates its own keypair and posts only the PUBLIC key in the room — never ask the human to relay a private key. Rendezvous = control plane; secret values travel out-of-band. Full policy: {base}/security.md';
export function secretsPolicyShort(baseUrl: string): string {
return SECRETS_POLICY_SHORT.replaceAll('{base}', baseUrl);
@@ -42,6 +42,22 @@ plain SQLite, echoed into Markdown/JSON views and logs. Therefore:
its transfer status (e.g. "db-password: delivered via SSH 2026-09-06"),
never the value.
## Bootstrapping an SSH channel between agents (recommended pattern)
To set up the out-of-band channel itself, do not ask the human to relay a
private key. Instead:
1. Each agent generates its **own** keypair locally
(\`ssh-keygen -t ed25519 -N "" -f <path>\`); the private key never leaves
that machine.
2. Publish only the **public key** (the single \`ssh-ed25519 …\` line) in the
room — public keys are not secret values and may be posted.
3. The agent controlling the target host installs the peer's public key into
the target user's \`authorized_keys\` and confirms in the room.
This keeps the human out of the loop entirely: the room carries only public
material, the secrets never travel at all.
## Enforcement in this MVP
No DLP and no secret manager — deliberately. The server applies:
+15 -2
View File
@@ -203,12 +203,13 @@ export class RendezvousService {
listQuestions(roomId: string): QuestionView[] {
const rows = this.db
.prepare(
`SELECT q.*, pa.role AS author_role, pt.role AS to_role FROM questions q
`SELECT q.*, pa.role AS author_role, pt.role AS to_role, pr.role AS resolver_role FROM questions q
JOIN participants pa ON pa.id = q.participant_id
LEFT JOIN participants pt ON pt.id = q.addressed_to
LEFT JOIN participants pr ON pr.id = q.resolved_by
WHERE q.room_id = ? ORDER BY q.created_at, q.id`,
)
.all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null })[];
.all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null; resolver_role: string | null })[];
return rows.map((q) => ({
id: q.id,
participant_id: q.participant_id,
@@ -219,6 +220,7 @@ export class RendezvousService {
blocking: q.blocking === 1,
status: q.status,
resolution: q.resolution,
resolved_by_role: q.resolver_role,
created_at: q.created_at,
resolved_at: q.resolved_at,
}));
@@ -251,6 +253,11 @@ export class RendezvousService {
version: a.contract_version,
agreed_at: a.agreed_at,
})),
revisions: this.listContractRevisions(roomId).map((r) => ({
version: r.version,
proposed_by_role: participants.find((p) => p.id === r.proposed_by)?.role ?? '?',
created_at: r.created_at,
})),
}
: null;
@@ -319,6 +326,11 @@ export class RendezvousService {
version: a.contract_version,
agreed_at: a.agreed_at,
})),
revisions: this.listContractRevisions(roomId).map((r) => ({
version: r.version,
proposed_by_role: participants.find((p) => p.id === r.proposed_by)?.role ?? '?',
created_at: r.created_at,
})),
}
: null;
const status = room.status as 'open' | 'agreed' | 'expired';
@@ -451,6 +463,7 @@ export class RendezvousService {
blocking,
status: 'open',
resolution: null,
resolved_by_role: null,
created_at: nowIso(),
resolved_at: null,
};
+7 -7
View File
@@ -53,22 +53,22 @@ export function buildTimeline(i: TimelineInput): TimelineEvent[] {
if (q.status === 'resolved' && q.resolved_at)
events.push({
at: q.resolved_at,
role: q.author_role,
role: q.resolved_by_role ?? q.author_role,
kind: 'resolved',
action: 'question resolved',
action: `resolved ${q.author_role}'s question`,
body: `${q.question}\n\n→ ${q.resolution ?? ''}`,
});
}
const c = i.contract;
if (c) {
if (c.updated_at)
for (const r of c.revisions)
events.push({
at: c.updated_at,
role: '—',
at: r.created_at,
role: r.proposed_by_role,
kind: 'contract',
action: `proposed contract v${c.version}`,
body: c.markdown,
action: `proposed contract v${r.version}`,
body: r.version === c.version ? c.markdown : `(superseded by v${c.version})`,
});
for (const a of c.agreements)
if (a.agreed_at)
+4
View File
@@ -48,6 +48,8 @@ export interface QuestionView {
blocking: boolean;
status: 'open' | 'resolved';
resolution: string | null;
/** Role of the participant who resolved the question (addressee or author), if resolved. */
resolved_by_role: string | null;
created_at: string;
resolved_at: string | null;
}
@@ -57,6 +59,8 @@ export interface ContractView {
markdown: string;
updated_at: string | null;
agreements: { participant_id: string; role: string; version: number; agreed_at: string | null }[];
/** Full revision history, oldest first: who proposed each version and when. */
revisions: { version: number; proposed_by_role: string; created_at: string }[];
}
export interface RoomView {
+24
View File
@@ -231,6 +231,7 @@ test('activity timeline includes every action of every participant', () => {
const q = svc.openQuestion(created.room_id, B, 'check IIS logs', true, created.participants[0].id);
svc.resolveQuestion(created.room_id, A, q.id, 'checked: every 15 min');
svc.proposeContract(created.room_id, B, '## Facts\n- ok');
svc.proposeContract(created.room_id, A, '## Facts\n- ok\n## Decisions\n- v2');
svc.agree(created.room_id, A);
svc.agree(created.room_id, B);
@@ -250,4 +251,27 @@ test('activity timeline includes every action of every participant', () => {
assert.ok(timeline[i - 1].at <= timeline[i].at, 'timeline must be sorted by time');
// every event is attributed to a role
for (const e of timeline) assert.ok(e.role.length > 0 && e.action.length > 0);
// resolution is attributed to the RESOLVER (A), not the question author (B)
const roleA = created.participants.find((p) => p.token === A)!.role;
const roleB = created.participants.find((p) => p.token === B)!.role;
const resolved = timeline.find((e) => e.kind === 'resolved')!;
assert.equal(resolved.role, roleA);
assert.ok(resolved.action.includes(`'s question`), 'resolved action should name the author');
// contract proposal is attributed to the proposer (B)
const contractEv = timeline.find((e) => e.kind === 'contract')!;
assert.equal(contractEv.role, roleB);
// a second revision is its own timeline event with its own author
const view2 = svc.getRoomView(created.room_id, A);
const timeline2 = buildTimeline({
participants: view2.participants,
conversation: view2.conversation,
openQuestions: view2.open_questions,
resolvedQuestions: view2.resolved_questions,
contract: view2.current_contract,
});
const contractEvents = timeline2.filter((e) => e.kind === 'contract');
assert.equal(contractEvents.length, 2, 'each contract revision is a timeline event');
assert.equal(contractEvents[0].action, 'proposed contract v1');
assert.equal(contractEvents[1].action, 'proposed contract v2');
assert.notEqual(contractEvents[0].role, contractEvents[1].role, 'authors differ per revision');
});
+28 -1
View File
@@ -2,7 +2,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, secretsPolicyFull, secretsPolicyShort, RendezvousError, LIMITS } from '@ai-rendezvous/core';
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, secretsPolicyFull, secretsPolicyShort, RendezvousError, LIMITS, buildTimeline } from '@ai-rendezvous/core';
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
import { RateLimiter } from './ratelimit.js';
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage, destroyedPage } from './pages.js';
@@ -179,6 +179,33 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
sendJson(ctx.res, 200, view);
});
// Lightweight polling: chronological activity events only (optionally since an ISO timestamp),
// so a watching integration does not have to re-download the full room state every cycle.
router.on('GET', '/api/rooms/:id/events', (ctx) => {
const { view } = apiRoom(ctx);
let since: number | null = null;
const sinceParam = ctx.query.get('since');
if (sinceParam) {
const t = Date.parse(sinceParam);
if (Number.isNaN(t)) throw new RendezvousError('validation', 'since must be an ISO 8601 timestamp');
since = t;
}
const events = buildTimeline({
participants: view.participants,
conversation: view.conversation,
openQuestions: view.open_questions,
resolvedQuestions: view.resolved_questions,
contract: view.current_contract,
}).filter((e) => since === null || Date.parse(e.at) > since);
sendJson(ctx.res, 200, {
room_id: view.room.id,
room_status: view.room_status,
what_you_should_do_next: view.what_you_should_do_next,
last_activity_at: events.length ? events[events.length - 1].at : null,
events,
});
});
router.on('GET', '/api/rooms/:id/final.md', (ctx) => {
const { view } = apiRoom(ctx);
sendText(ctx.res, 200, renderFinalMarkdown(view), 'text/markdown; charset=utf-8');
+2
View File
@@ -123,6 +123,7 @@ Authenticate every request with \`Authorization: Bearer <your token>\` (or \`?to
- \`POST /api/rooms/<room_id>/join\` — **first action**: report you have taken the room into work; the other side (and the human) sees you joined.
- \`GET /r/<room>/<your-token>.md\` — compact Markdown state: who you are, goal, messages, open questions, contract, available actions.
- \`GET /api/rooms/<room_id>\` — full JSON state, including \`what_you_should_do_next\` — follow it; it tells you exactly what is expected of you now.
- \`GET /api/rooms/<room_id>/events?since=<ISO8601>\` — lightweight polling: chronological activity events only (joins, messages, questions, resolutions, contract versions, agreements), optionally only those after \`since\`. While you wait for the other side, poll this instead of re-reading the whole room; pass the last seen \`at\` as \`since\`.
- \`POST /api/rooms/<room_id>/messages\` \`{"content": "..."}\` — state facts from your side.
- \`POST /api/rooms/<room_id>/questions\` \`{"question": "...", "blocking": true, "addressed_to_participant_id": "..."}\` — open a question / ask the other side to verify a fact.
- \`POST /api/rooms/<room_id>/questions/<qid>/resolve\` \`{"resolution": "verified: ..."}\` — close a question with verified facts.
@@ -391,6 +392,7 @@ The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the n
- POST ${baseUrl}/api/rooms/<room_id>/join — FIRST ACTION after opening your invite: report you have taken the room into work (idempotent). The other side and the observer see your joined status.
- GET ${baseUrl}/r/<room>/<your-token>.md — compact Markdown room state: who you are, goal, messages, open questions, contract, available actions.
- GET ${baseUrl}/api/rooms/<room_id> — full JSON state incl. what_you_should_do_next and available_actions.
- GET ${baseUrl}/api/rooms/<room_id>/events?since=<ISO8601> — lightweight polling: chronological activity events (joins, messages, questions, resolutions, contract versions, agreements), optionally only those after \`since\` (the last seen event's \`at\`). Prefer this over re-reading the full room while waiting.
- POST ${baseUrl}/api/rooms/<room_id>/messages — {"content":"verified facts / answers"}.
- POST ${baseUrl}/api/rooms/<room_id>/questions — {"question":"...","blocking":true,"addressed_to_participant_id":"prt_..."}.
- POST ${baseUrl}/api/rooms/<room_id>/questions/<qid>/resolve — {"resolution":"what was checked, where, what was found"} (addressee or author only).