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
deploy / deploy (push) Canceled after 0s
This commit is contained in:
+15
@@ -96,6 +96,21 @@ without re-reading the whole chat:
|
|||||||
- **`what_you_should_do_next`** — explicit instruction computed from state
|
- **`what_you_should_do_next`** — explicit instruction computed from state
|
||||||
- `available_actions` — the API calls you may make now
|
- `available_actions` — the API calls you may make now
|
||||||
|
|
||||||
|
### Activity events (lightweight polling)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/rooms/:id/events?since=<ISO8601>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the unified chronological activity timeline (the same events the web
|
||||||
|
views render as "Conversation"): joins, messages, questions asked, questions
|
||||||
|
resolved, each contract revision proposed, each agreement — every event with
|
||||||
|
`at`, `role`, `kind`, `action`, `body`. With `since` (an ISO timestamp, e.g.
|
||||||
|
the last seen event's `at`) only newer events are returned. Response also
|
||||||
|
carries `room_status`, `what_you_should_do_next` and `last_activity_at`, so a
|
||||||
|
watching integration can poll this single cheap endpoint instead of
|
||||||
|
re-downloading full room state every cycle.
|
||||||
|
|
||||||
### Join (report the task is taken)
|
### Join (report the task is taken)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -150,7 +150,13 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
|||||||
contract:
|
contract:
|
||||||
i.contract ??
|
i.contract ??
|
||||||
(i.contractVersion > 0
|
(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),
|
: null),
|
||||||
});
|
});
|
||||||
if (timeline.length === 0) {
|
if (timeline.length === 0) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const SECRETS_POLICY_SHORT =
|
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 {
|
export function secretsPolicyShort(baseUrl: string): string {
|
||||||
return SECRETS_POLICY_SHORT.replaceAll('{base}', baseUrl);
|
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"),
|
its transfer status (e.g. "db-password: delivered via SSH 2026-09-06"),
|
||||||
never the value.
|
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
|
## Enforcement in this MVP
|
||||||
|
|
||||||
No DLP and no secret manager — deliberately. The server applies:
|
No DLP and no secret manager — deliberately. The server applies:
|
||||||
|
|||||||
@@ -203,12 +203,13 @@ export class RendezvousService {
|
|||||||
listQuestions(roomId: string): QuestionView[] {
|
listQuestions(roomId: string): QuestionView[] {
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare(
|
.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
|
JOIN participants pa ON pa.id = q.participant_id
|
||||||
LEFT JOIN participants pt ON pt.id = q.addressed_to
|
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`,
|
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) => ({
|
return rows.map((q) => ({
|
||||||
id: q.id,
|
id: q.id,
|
||||||
participant_id: q.participant_id,
|
participant_id: q.participant_id,
|
||||||
@@ -219,6 +220,7 @@ export class RendezvousService {
|
|||||||
blocking: q.blocking === 1,
|
blocking: q.blocking === 1,
|
||||||
status: q.status,
|
status: q.status,
|
||||||
resolution: q.resolution,
|
resolution: q.resolution,
|
||||||
|
resolved_by_role: q.resolver_role,
|
||||||
created_at: q.created_at,
|
created_at: q.created_at,
|
||||||
resolved_at: q.resolved_at,
|
resolved_at: q.resolved_at,
|
||||||
}));
|
}));
|
||||||
@@ -251,6 +253,11 @@ export class RendezvousService {
|
|||||||
version: a.contract_version,
|
version: a.contract_version,
|
||||||
agreed_at: a.agreed_at,
|
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;
|
: null;
|
||||||
|
|
||||||
@@ -319,6 +326,11 @@ export class RendezvousService {
|
|||||||
version: a.contract_version,
|
version: a.contract_version,
|
||||||
agreed_at: a.agreed_at,
|
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;
|
: null;
|
||||||
const status = room.status as 'open' | 'agreed' | 'expired';
|
const status = room.status as 'open' | 'agreed' | 'expired';
|
||||||
@@ -451,6 +463,7 @@ export class RendezvousService {
|
|||||||
blocking,
|
blocking,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
resolution: null,
|
resolution: null,
|
||||||
|
resolved_by_role: null,
|
||||||
created_at: nowIso(),
|
created_at: nowIso(),
|
||||||
resolved_at: null,
|
resolved_at: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,22 +53,22 @@ export function buildTimeline(i: TimelineInput): TimelineEvent[] {
|
|||||||
if (q.status === 'resolved' && q.resolved_at)
|
if (q.status === 'resolved' && q.resolved_at)
|
||||||
events.push({
|
events.push({
|
||||||
at: q.resolved_at,
|
at: q.resolved_at,
|
||||||
role: q.author_role,
|
role: q.resolved_by_role ?? q.author_role,
|
||||||
kind: 'resolved',
|
kind: 'resolved',
|
||||||
action: 'question resolved',
|
action: `resolved ${q.author_role}'s question`,
|
||||||
body: `${q.question}\n\n→ ${q.resolution ?? ''}`,
|
body: `${q.question}\n\n→ ${q.resolution ?? ''}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const c = i.contract;
|
const c = i.contract;
|
||||||
if (c) {
|
if (c) {
|
||||||
if (c.updated_at)
|
for (const r of c.revisions)
|
||||||
events.push({
|
events.push({
|
||||||
at: c.updated_at,
|
at: r.created_at,
|
||||||
role: '—',
|
role: r.proposed_by_role,
|
||||||
kind: 'contract',
|
kind: 'contract',
|
||||||
action: `proposed contract v${c.version}`,
|
action: `proposed contract v${r.version}`,
|
||||||
body: c.markdown,
|
body: r.version === c.version ? c.markdown : `(superseded by v${c.version})`,
|
||||||
});
|
});
|
||||||
for (const a of c.agreements)
|
for (const a of c.agreements)
|
||||||
if (a.agreed_at)
|
if (a.agreed_at)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface QuestionView {
|
|||||||
blocking: boolean;
|
blocking: boolean;
|
||||||
status: 'open' | 'resolved';
|
status: 'open' | 'resolved';
|
||||||
resolution: string | null;
|
resolution: string | null;
|
||||||
|
/** Role of the participant who resolved the question (addressee or author), if resolved. */
|
||||||
|
resolved_by_role: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
resolved_at: string | null;
|
resolved_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,8 @@ export interface ContractView {
|
|||||||
markdown: string;
|
markdown: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
agreements: { participant_id: string; role: string; version: number; agreed_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 {
|
export interface RoomView {
|
||||||
|
|||||||
@@ -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);
|
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.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, 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, A);
|
||||||
svc.agree(created.room_id, B);
|
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');
|
assert.ok(timeline[i - 1].at <= timeline[i].at, 'timeline must be sorted by time');
|
||||||
// every event is attributed to a role
|
// every event is attributed to a role
|
||||||
for (const e of timeline) assert.ok(e.role.length > 0 && e.action.length > 0);
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
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 { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
|
||||||
import { RateLimiter } from './ratelimit.js';
|
import { RateLimiter } from './ratelimit.js';
|
||||||
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage, destroyedPage } from './pages.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);
|
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) => {
|
router.on('GET', '/api/rooms/:id/final.md', (ctx) => {
|
||||||
const { view } = apiRoom(ctx);
|
const { view } = apiRoom(ctx);
|
||||||
sendText(ctx.res, 200, renderFinalMarkdown(view), 'text/markdown; charset=utf-8');
|
sendText(ctx.res, 200, renderFinalMarkdown(view), 'text/markdown; charset=utf-8');
|
||||||
|
|||||||
@@ -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.
|
- \`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 /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>\` — 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>/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\` \`{"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.
|
- \`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.
|
- 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}/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> — 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>/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 — {"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).
|
- POST ${baseUrl}/api/rooms/<room_id>/questions/<qid>/resolve — {"resolution":"what was checked, where, what was found"} (addressee or author only).
|
||||||
|
|||||||
Reference in New Issue
Block a user