Skip to content

Commit 01d8a20

Browse files
tps-flintclaude
andauthored
feat(cap-observatory): Team-View v3 producer — agents self-report status (#45)
* feat(cap-observatory): Team-View v3 producer — agents self-report status New Bob capability (the producer half of the cockpit). On a cron tick each agent builds its ObsAgentSnapshot (role/model/status / currentTask = Beads summary / lastActivity / lastHeartbeat) + ObsEventFeed events, runs them through sanitize() (the redaction boundary — strips fs paths, secrets, transcripts, mail bodies), batches all of the office's agents into ONE POST, signs with the OFFICE Ed25519 key (payload officeId:tsMs:nonce:POST:/IngestEvents, per-record nonce+tsMs for replay protection), and POSTs to /IngestEvents. Mirrors cap-flair; blessed in the capability catalog. Tests: 31 (sanitize vs realistic dirty input, all status branches, reader proves no prompt/transcript leak, office-key sign->verify round-trip); workspace 263 pass. Implements the K&S spec conditions (sanitize boundary, office-key signing, batch-per-office, replay nonce). Server-side follow-ups (IngestEvents persisting currentTask/type; schema "stale" enum) tracked separately for CP1/CP2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cap-observatory): regenerate bun.lock for new deps CI ran bun install --frozen-lockfile; the cap-observatory package added deps that weren't reflected in bun.lock, so Build + Dependency Audit failed. Regenerated lockfile (+11 entries); typecheck + 31 cap-observatory tests green locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cap-observatory): bound PEM regex quantifiers in sanitize() (ReDoS) CodeQL js/polynomial-redos flagged the PEM private-key pattern: the unbounded [^-]* and [\s\S]*? made sanitizeString() O(n^2) on crafted "-----BEGIN…PRIVATE KEY-----" repetitions — a DoS vector in the very redaction boundary it protects. Bound both quantifiers (PEM type labels < 40 chars; a key block < 8 KB); the long-base64/hex rules still redact the key body as backstop. Adds a PEM-redaction test and a linear-time regression test (5000-rep adversarial input completes in < 1s). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Flint <263629284+tps-flint@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2448ec0 commit 01d8a20

15 files changed

Lines changed: 1685 additions & 3 deletions

bun.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
"packages/*"
88
],
99
"scripts": {
10-
"build": "cd packages/shell && bun run build && cd ../discord && bun run build && cd ../cap-discord && bun run build && cd ../cap-flair && bun run build && cd ../cli && bun run build",
11-
"test": "cd packages/shell && bun test && cd ../discord && bun test && cd ../cap-discord && bun test && cd ../cap-flair && bun test && cd ../cli && bun test",
12-
"typecheck": "cd packages/shell && bun run build && bun run typecheck && cd ../discord && bun run build && bun run typecheck && cd ../cap-discord && bun run build && bun run typecheck && cd ../cap-flair && bun run build && bun run typecheck && cd ../cli && bun run typecheck",
10+
"build": "cd packages/shell && bun run build && cd ../discord && bun run build && cd ../cap-discord && bun run build && cd ../cap-flair && bun run build && cd ../cap-observatory && bun run build && cd ../cli && bun run build",
11+
"test": "cd packages/shell && bun test && cd ../discord && bun test && cd ../cap-discord && bun test && cd ../cap-flair && bun test && cd ../cap-observatory && bun test && cd ../cli && bun test",
12+
"typecheck": "cd packages/shell && bun run build && bun run typecheck && cd ../discord && bun run build && bun run typecheck && cd ../cap-discord && bun run build && bun run typecheck && cd ../cap-flair && bun run build && bun run typecheck && cd ../cap-observatory && bun run build && bun run typecheck && cd ../cli && bun run typecheck",
1313
"lint": "biome check .",
1414
"lint:fix": "biome check --write ."
1515
},
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@tpsdev-ai/bob-cap-observatory",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"description": "Bob capability: Team-View observatory producer. A pi extension — each office self-reports sanitized, OFFICE-key-signed agent snapshots + event feed to /IngestEvents.",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"import": "./dist/index.js",
11+
"types": "./dist/index.d.ts"
12+
},
13+
"./manifest": {
14+
"import": "./dist/manifest.js",
15+
"types": "./dist/manifest.d.ts"
16+
}
17+
},
18+
"files": [
19+
"dist"
20+
],
21+
"scripts": {
22+
"build": "tsc -p .",
23+
"typecheck": "tsc -p . --noEmit",
24+
"test": "bun test"
25+
},
26+
"dependencies": {
27+
"@mariozechner/pi-coding-agent": "0.73.1",
28+
"@tpsdev-ai/bob-shell": "workspace:*",
29+
"typebox": "1.1.38"
30+
},
31+
"keywords": [
32+
"observatory",
33+
"team-view",
34+
"cockpit",
35+
"bob",
36+
"agent",
37+
"pi-coding-agent",
38+
"capability"
39+
],
40+
"repository": {
41+
"type": "git",
42+
"url": "https://github.com/tpsdev-ai/bob.git",
43+
"directory": "packages/cap-observatory"
44+
},
45+
"homepage": "https://github.com/tpsdev-ai/bob#readme",
46+
"bugs": "https://github.com/tpsdev-ai/bob/issues",
47+
"license": "Apache-2.0"
48+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// The testable core of the observatory (team-view producer) capability, decoupled
2+
// from pi's real ExtensionAPI so it can be unit-tested with fakes (no live
3+
// observatory, no real office key, no network). `index.ts` is the thin factory
4+
// that builds the real ObservatoryHttpClient + reader and calls this.
5+
//
6+
// What this wires — ONE OUTBOUND tool via pi.registerTool:
7+
// observatory_report — build the office's snapshots/events, sanitize, sign with
8+
// the OFFICE key, and POST ONE batched call to /IngestEvents.
9+
//
10+
// CRON MODEL (see bob/packages/shell/src/cron.ts): cron does NOT call the
11+
// capability directly — it injects a prompt into the AGENT, which then calls this
12+
// tool. So the capability EXPOSES the report logic as a tool; bob.yaml wires a
13+
// `cron:` entry whose prompt says "report your status to the observatory". Same
14+
// shape as flair_write / discord_send. There is NO inbound listener (serves:false).
15+
16+
import { type TSchema, Type } from "typebox";
17+
import type { IngestBatch, ObservatoryClient } from "./client.js";
18+
import type { AgentSignal } from "./snapshot.js";
19+
import { buildBatch } from "./snapshot.js";
20+
21+
// The minimal slice of pi's ExtensionAPI this core needs — declared structurally
22+
// so a tiny test fake and the real ExtensionAPI both satisfy it.
23+
export interface PiLike {
24+
registerTool(tool: {
25+
name: string;
26+
label: string;
27+
description: string;
28+
parameters: TSchema;
29+
execute: (
30+
toolCallId: string,
31+
params: Record<string, unknown>,
32+
) => Promise<{ content: Array<{ type: "text"; text: string }>; details: unknown }>;
33+
}): void;
34+
}
35+
36+
export interface WireOptions {
37+
pi: PiLike;
38+
client: ObservatoryClient;
39+
// Reads the office's live signals each tick (fs seam). Returns the AgentSignal[]
40+
// the builder turns into the batched snapshot/event payload.
41+
readSignals: () => AgentSignal[];
42+
// Threshold (s) past which an agent is reported stale. Defaults to 600.
43+
staleThresholdSeconds?: number;
44+
// Logger seam — defaults to console.error. NOTHING here ever logs the key (it
45+
// lives only inside the client) or a raw signal (only counts/status).
46+
log?: (msg: string) => void;
47+
}
48+
49+
function ok(text: string): { content: Array<{ type: "text"; text: string }>; details: unknown } {
50+
return { content: [{ type: "text", text }], details: {} };
51+
}
52+
53+
export function wireObservatoryCapability(opts: WireOptions): void {
54+
const { pi, client, readSignals } = opts;
55+
const log = opts.log ?? ((m: string) => console.error(m));
56+
57+
// --- observatory_report ------------------------------------------------
58+
pi.registerTool({
59+
name: "observatory_report",
60+
label: "Observatory Report",
61+
description:
62+
"Report this office's live status to the team observatory. Builds each agent's snapshot (role/model/status/current-task) + the events they caused, sanitizes them (no paths/secrets/bodies), signs with the office key, and POSTs ONE batched call. Call this on each cron tick.",
63+
parameters: Type.Object({}),
64+
async execute() {
65+
const signals = readSignals();
66+
const batch: IngestBatch = buildBatch(signals, {
67+
staleThresholdSeconds: opts.staleThresholdSeconds,
68+
});
69+
// post() runs sanitize() + the office-key signature internally — exactly
70+
// one redacted, signed path to the wire.
71+
const result = await client.post(batch);
72+
// Status line names counts + per-agent status ONLY — never a task summary,
73+
// a path, or the key.
74+
const byStatus = batch.agents.reduce<Record<string, number>>((acc, a) => {
75+
acc[a.status] = (acc[a.status] ?? 0) + 1;
76+
return acc;
77+
}, {});
78+
const statusLine = Object.entries(byStatus)
79+
.map(([s, n]) => `${n} ${s}`)
80+
.join(", ");
81+
const msg = `reported ${result.agents} agent(s) [${statusLine}] + ${result.events} event(s) to the observatory`;
82+
log(`observatory capability: ${msg}`);
83+
return ok(msg);
84+
},
85+
});
86+
87+
log("observatory capability: registered observatory_report");
88+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Ed25519-authenticated HTTP client for the observatory, decoupled from the pi
2+
// ExtensionAPI so it can be unit-tested with injected fetch/clock/key. This is
3+
// the observatory analog of cap-flair's FlairHttpClient — same TPS-Ed25519 wire
4+
// protocol — with TWO deliberate differences:
5+
// 1. it signs with the OFFICE key (not a per-agent Flair key): the endpoint
6+
// verifies ObsOffice.publicKey (see tps-observatory/resources/IngestEvents.ts).
7+
// 2. it POSTs ONE batched call per office per tick to /IngestEvents (the
8+
// endpoint rate-limits 1 call/10s per office and accepts arrays).
9+
//
10+
// PROTOCOL (mirrors flair's canonical scripts/flair-client.mjs + IngestEvents):
11+
// Authorization: TPS-Ed25519 <officeId>:<tsMs>:<nonce>:<sigB64>
12+
// signature = Ed25519( "<officeId>:<tsMs>:<nonce>:POST:/IngestEvents" )
13+
// - tsMs is Date.now() in MILLISECONDS (NOT seconds — a 1000x error → 401).
14+
// - the server enforces a ±window on tsMs + (by CP2) nonce dedup; each record
15+
// ALSO carries its own nonce+tsMs for the per-record replay store.
16+
// body: { officeId, events: OrgEventRecord[], agents: AgentStatus[], syncedAt }
17+
//
18+
// SECURITY:
19+
// * The OFFICE private key is read from a FILE PATH once, imported as a
20+
// non-extractable CryptoKey, and used only to sign. It is never logged,
21+
// echoed, returned in a tool result, or placed in an error message.
22+
// * sanitize() runs HERE, immediately before the POST is assembled, so there
23+
// is exactly one path to the wire and it is always redacted (defense in
24+
// depth — the builder already passes summaries only).
25+
26+
import { webcrypto } from "node:crypto";
27+
import { readFileSync } from "node:fs";
28+
import { homedir } from "node:os";
29+
import { sanitize } from "./sanitize.js";
30+
import type { AgentStatus, OrgEventRecord } from "./snapshot.js";
31+
32+
const { subtle } = webcrypto;
33+
34+
// The signed POST path — bound into the signature payload (so a signature for
35+
// one endpoint can't be replayed against another).
36+
const INGEST_PATH = "/IngestEvents";
37+
38+
export interface IngestBatch {
39+
events: OrgEventRecord[];
40+
agents: AgentStatus[];
41+
}
42+
43+
export interface IngestResult {
44+
ok: boolean;
45+
events: number;
46+
agents: number;
47+
}
48+
49+
export interface ObservatoryClient {
50+
// POST one batched, signed, sanitized call for the whole office.
51+
post(batch: IngestBatch): Promise<IngestResult>;
52+
}
53+
54+
// Minimal fetch shape we depend on (so tests pass a fake without DOM lib types).
55+
type FetchLike = (
56+
url: string,
57+
init: { method: string; headers: Record<string, string>; body?: string },
58+
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
59+
60+
export interface ObservatoryHttpClientOptions {
61+
url: string;
62+
officeId: string;
63+
// Path to the base64-PKCS8 OFFICE Ed25519 private key. Read once, lazily.
64+
officeKeyFile: string;
65+
// Seams (tests). Production uses global fetch, Date.now, randomUUID, fs.
66+
fetchImpl?: FetchLike;
67+
now?: () => number;
68+
uuid?: () => string;
69+
readFile?: (path: string) => string;
70+
}
71+
72+
export class ObservatoryHttpClient implements ObservatoryClient {
73+
private readonly url: string;
74+
private readonly officeId: string;
75+
private readonly officeKeyFile: string;
76+
private readonly fetchImpl: FetchLike;
77+
private readonly now: () => number;
78+
private readonly uuid: () => string;
79+
private readonly readFile: (path: string) => string;
80+
// Imported once; reused across requests.
81+
private keyPromise?: Promise<webcrypto.CryptoKey>;
82+
83+
constructor(opts: ObservatoryHttpClientOptions) {
84+
// Drop trailing slashes so `${url}${path}` never doubles them. A linear loop
85+
// (not a `/\/+$/` regex) — the regex form is a polynomial-ReDoS class on
86+
// uncontrolled (config) input that CodeQL rightly flags.
87+
let base = opts.url;
88+
while (base.endsWith("/")) base = base.slice(0, -1);
89+
this.url = base;
90+
this.officeId = opts.officeId;
91+
// Expand a leading ~/ so configs can use the ~/.flair/keys/<name>.key
92+
// convention without the caller pre-resolving it.
93+
this.officeKeyFile = opts.officeKeyFile.startsWith("~/")
94+
? `${homedir()}/${opts.officeKeyFile.slice(2)}`
95+
: opts.officeKeyFile;
96+
this.fetchImpl = opts.fetchImpl ?? ((u, i) => fetch(u, i) as unknown as ReturnType<FetchLike>);
97+
this.now = opts.now ?? (() => Date.now());
98+
this.uuid = opts.uuid ?? (() => webcrypto.randomUUID());
99+
this.readFile = opts.readFile ?? ((p) => readFileSync(p, "utf8"));
100+
}
101+
102+
private loadKey(): Promise<webcrypto.CryptoKey> {
103+
if (!this.keyPromise) {
104+
const b64 = this.readFile(this.officeKeyFile).trim();
105+
this.keyPromise = subtle.importKey(
106+
"pkcs8",
107+
Buffer.from(b64, "base64"),
108+
{ name: "Ed25519" },
109+
false,
110+
["sign"],
111+
);
112+
}
113+
return this.keyPromise;
114+
}
115+
116+
// post — sanitize → sign with the OFFICE key → POST one batched call.
117+
async post(batch: IngestBatch): Promise<IngestResult> {
118+
// REDACTION BOUNDARY: the only path to the wire, always sanitized.
119+
const clean = sanitize(batch);
120+
121+
const key = await this.loadKey();
122+
const ts = String(this.now());
123+
const nonce = this.uuid();
124+
// tsMs in MILLISECONDS. Signature binds officeId + ts + nonce + METHOD+path,
125+
// so a captured signature can't be replayed against another endpoint/office.
126+
const payload = `${this.officeId}:${ts}:${nonce}:POST:${INGEST_PATH}`;
127+
const sig = await subtle.sign("Ed25519", key, new TextEncoder().encode(payload));
128+
129+
const body = JSON.stringify({
130+
officeId: this.officeId,
131+
events: clean.events,
132+
agents: clean.agents,
133+
syncedAt: new Date(Number(ts)).toISOString(),
134+
});
135+
136+
const headers: Record<string, string> = {
137+
Authorization: `TPS-Ed25519 ${this.officeId}:${ts}:${nonce}:${Buffer.from(sig).toString("base64")}`,
138+
"Content-Type": "application/json",
139+
};
140+
141+
const res = await this.fetchImpl(`${this.url}${INGEST_PATH}`, {
142+
method: "POST",
143+
headers,
144+
body,
145+
});
146+
const text = await res.text();
147+
if (!res.ok) {
148+
// Never include the request body or auth header — only status + a short,
149+
// server-provided reason (which names no secret).
150+
throw new Error(`observatory POST ${INGEST_PATH} -> ${res.status}: ${text.slice(0, 200)}`);
151+
}
152+
let parsed: { ok?: boolean; events?: number; agents?: number } = {};
153+
try {
154+
parsed = JSON.parse(text);
155+
} catch {
156+
// Endpoint returned non-JSON 200 — treat as success with unknown counts.
157+
}
158+
return {
159+
ok: parsed.ok ?? true,
160+
events: typeof parsed.events === "number" ? parsed.events : clean.events.length,
161+
agents: typeof parsed.agents === "number" ? parsed.agents : clean.agents.length,
162+
};
163+
}
164+
}

0 commit comments

Comments
 (0)