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:
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user