@animalabs/connectome-host 0.3.1 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +9 -4
- package/package.json +3 -3
- package/src/commands.ts +57 -3
- package/src/index.ts +9 -0
- package/src/modules/fleet-module.ts +4 -2
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/web-ui-module.ts +390 -16
- package/src/modules/web-ui-observers.ts +309 -0
- package/src/web/protocol.ts +52 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +301 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +15 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +68 -0
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +57 -1
|
@@ -15,13 +15,18 @@ jobs:
|
|
|
15
15
|
runs-on: ubuntu-latest
|
|
16
16
|
|
|
17
17
|
steps:
|
|
18
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
19
|
|
|
20
20
|
- name: Setup Node.js
|
|
21
|
-
uses: actions/setup-node@
|
|
21
|
+
uses: actions/setup-node@v6
|
|
22
22
|
with:
|
|
23
23
|
node-version: 24
|
|
24
24
|
|
|
25
|
+
- name: Setup Bun
|
|
26
|
+
uses: oven-sh/setup-bun@v2
|
|
27
|
+
with:
|
|
28
|
+
bun-version: 1.3.14
|
|
29
|
+
|
|
25
30
|
- name: Install dependencies
|
|
26
31
|
run: npm install
|
|
27
32
|
|
|
@@ -46,10 +51,10 @@ jobs:
|
|
|
46
51
|
id-token: write
|
|
47
52
|
|
|
48
53
|
steps:
|
|
49
|
-
- uses: actions/checkout@
|
|
54
|
+
- uses: actions/checkout@v6
|
|
50
55
|
|
|
51
56
|
- name: Setup Node.js
|
|
52
|
-
uses: actions/setup-node@
|
|
57
|
+
uses: actions/setup-node@v6
|
|
53
58
|
with:
|
|
54
59
|
# node 24 ships npm >= 11.5.1, required for OIDC publishing.
|
|
55
60
|
node-version: 24
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animalabs/connectome-host",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "General-purpose agent TUI host with recipe-based configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "bun src/index.ts",
|
|
8
8
|
"dev": "bun --watch src/index.ts",
|
|
9
9
|
"test": "bun test",
|
|
10
|
-
"build:web": "
|
|
11
|
-
"postinstall": "test -d web &&
|
|
10
|
+
"build:web": "npm --prefix web install && npm --prefix web run build",
|
|
11
|
+
"postinstall": "test -d web && npm --prefix web install && npm --prefix web run build || true"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@animalabs/agent-framework": "^0.6.0",
|
package/src/commands.ts
CHANGED
|
@@ -197,7 +197,13 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
197
197
|
return handleCheckout(framework, args[0]);
|
|
198
198
|
|
|
199
199
|
case 'history':
|
|
200
|
-
return handleHistory(framework);
|
|
200
|
+
return handleHistory(framework, args[0]);
|
|
201
|
+
|
|
202
|
+
case 'branchto':
|
|
203
|
+
return handleBranchTo(app, args[0]);
|
|
204
|
+
|
|
205
|
+
case 'find':
|
|
206
|
+
return handleFind(framework, args.join(' '));
|
|
201
207
|
|
|
202
208
|
case 'mcp':
|
|
203
209
|
return handleMcp(args);
|
|
@@ -793,7 +799,55 @@ function handleCheckout(framework: AgentFramework, name?: string): CommandResult
|
|
|
793
799
|
};
|
|
794
800
|
}
|
|
795
801
|
|
|
796
|
-
function
|
|
802
|
+
function handleFind(framework: AgentFramework, needle: string): CommandResult {
|
|
803
|
+
const cm = getAgentCM(framework);
|
|
804
|
+
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
805
|
+
if (!needle) return { lines: [{ text: 'Usage: /find <text>', style: 'system' }] };
|
|
806
|
+
const { messages } = cm.queryMessages({});
|
|
807
|
+
const lines: Line[] = [{ text: `--- Find "${needle}" in ${messages.length} msgs ---`, style: 'system' }];
|
|
808
|
+
const needleLc = needle.toLowerCase();
|
|
809
|
+
for (const msg of messages) {
|
|
810
|
+
// Search text blocks plus stringified tool i/o (case-insensitive), so ids
|
|
811
|
+
// buried in tool_use inputs / tool_result payloads are findable too.
|
|
812
|
+
const full = msg.content
|
|
813
|
+
.map((b) => b.type === 'text' ? b.text : JSON.stringify(b))
|
|
814
|
+
.join(' ');
|
|
815
|
+
const at = full.toLowerCase().indexOf(needleLc);
|
|
816
|
+
if (at >= 0) {
|
|
817
|
+
const snip = full.slice(Math.max(0, at - 30), at + needle.length + 45).replace(/\s+/g, ' ');
|
|
818
|
+
lines.push({ text: ` [${msg.id}] ${msg.participant}: ...${snip}...`, style: 'system' });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (lines.length === 1) lines.push({ text: ' (no matches)', style: 'system' });
|
|
822
|
+
return { lines };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function handleBranchTo(app: AppContext, messageId: string | undefined): CommandResult {
|
|
826
|
+
const cm = getAgentCM(app.framework);
|
|
827
|
+
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
828
|
+
if (!messageId) return { lines: [{ text: 'Usage: /branchto <messageId>', style: 'system' }] };
|
|
829
|
+
const { messages } = cm.queryMessages({});
|
|
830
|
+
const target = messages.find(m => String(m.id) === String(messageId));
|
|
831
|
+
if (!target) return { lines: [{ text: `No message with id ${messageId} on current branch.`, style: 'system' }] };
|
|
832
|
+
const newBranchName = `rollback-at-${messageId}-${Date.now()}`;
|
|
833
|
+
let createdBranchName: string;
|
|
834
|
+
try {
|
|
835
|
+
createdBranchName = cm.branchAt(String(messageId), newBranchName);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
return { lines: [{ text: `branchto failed (branchAt): ${err}`, style: 'system' }] };
|
|
838
|
+
}
|
|
839
|
+
const asyncWork = Promise.resolve(cm.switchBranch(createdBranchName))
|
|
840
|
+
.then(() => ({
|
|
841
|
+
lines: [{ text: `Branched at [${messageId}] -> "${createdBranchName}" and switched. Everything after [${messageId}] is dropped on this branch; old branch preserved.`, style: 'system' as const }],
|
|
842
|
+
branchChanged: true,
|
|
843
|
+
}))
|
|
844
|
+
.catch((err: unknown) => ({
|
|
845
|
+
lines: [{ text: `branchto failed (switchBranch): ${err}`, style: 'system' as const }],
|
|
846
|
+
}));
|
|
847
|
+
return { lines: [{ text: `Branching at [${messageId}] -> "${createdBranchName}"...`, style: 'system' }], asyncWork };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function handleHistory(framework: AgentFramework, countArg?: string): CommandResult {
|
|
797
851
|
const cm = getAgentCM(framework);
|
|
798
852
|
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
799
853
|
|
|
@@ -801,7 +855,7 @@ function handleHistory(framework: AgentFramework): CommandResult {
|
|
|
801
855
|
const lines: Line[] = [{ text: `--- History (${messages.length} messages) ---`, style: 'system' }];
|
|
802
856
|
|
|
803
857
|
// Show the last 20 messages in summary form
|
|
804
|
-
const recent = messages.slice(-20);
|
|
858
|
+
const recent = messages.slice(-(countArg && Number(countArg) > 0 ? Number(countArg) : 20));
|
|
805
859
|
for (const msg of recent) {
|
|
806
860
|
const text = msg.content
|
|
807
861
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { ActivityModule } from './modules/activity-module.js';
|
|
|
34
34
|
import { SubscriptionGcModule } from './modules/subscription-gc-module.js';
|
|
35
35
|
import { ChannelModeModule } from './modules/channel-mode-module.js';
|
|
36
36
|
import { WebUiModule } from './modules/web-ui-module.js';
|
|
37
|
+
import { ObserversModule } from './modules/observers-module.js';
|
|
37
38
|
import { McplAdminModule } from './modules/mcpl-admin-module.js';
|
|
38
39
|
import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
|
|
39
40
|
import { SessionManager } from './session-manager.js';
|
|
@@ -317,13 +318,21 @@ async function createFramework(
|
|
|
317
318
|
let webUiModule: WebUiModule | null = null;
|
|
318
319
|
if (modules.webui !== undefined && modules.webui !== false) {
|
|
319
320
|
const webuiConfig = typeof modules.webui === 'object' ? modules.webui : {};
|
|
321
|
+
// Observer grants (docs/observability.md): data/observers.json by
|
|
322
|
+
// default, overridable via OBSERVERS_FILE. The feature is inert until
|
|
323
|
+
// the file holds at least one grant. The companion ObserversModule
|
|
324
|
+
// gives the agent grant/revoke tools over the same file — interiority
|
|
325
|
+
// access is the agent's to give.
|
|
326
|
+
const observersPath = process.env.OBSERVERS_FILE || resolve(config.dataDir, 'observers.json');
|
|
320
327
|
webUiModule = new WebUiModule({
|
|
321
328
|
port: webuiConfig.port,
|
|
322
329
|
host: webuiConfig.host,
|
|
323
330
|
basicAuth: webuiConfig.basicAuth,
|
|
324
331
|
allowedOrigins: webuiConfig.allowedOrigins,
|
|
332
|
+
observersPath,
|
|
325
333
|
});
|
|
326
334
|
moduleInstances.push(webUiModule);
|
|
335
|
+
moduleInstances.push(new ObserversModule({ path: observersPath }));
|
|
327
336
|
}
|
|
328
337
|
|
|
329
338
|
// -- Build MCP server list --
|
|
@@ -31,7 +31,7 @@ import type {
|
|
|
31
31
|
} from '@animalabs/agent-framework';
|
|
32
32
|
import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
|
|
33
33
|
import { connect as netConnect, type Socket } from 'node:net';
|
|
34
|
-
import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync } from 'node:fs';
|
|
34
|
+
import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
|
|
35
35
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
36
36
|
import { type IncomingCommand, type WireEvent, matchesSubscription } from './fleet-types.js';
|
|
37
37
|
import { loadRecipe } from '../recipe.js';
|
|
@@ -1202,9 +1202,11 @@ export class FleetModule implements Module {
|
|
|
1202
1202
|
* entries derived from `children[]`, which are absolute post-load).
|
|
1203
1203
|
*/
|
|
1204
1204
|
private matchesAllowlist(recipe: string, resolved: string): boolean {
|
|
1205
|
+
const canonicalResolved = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
|
1205
1206
|
for (const entry of this.allowlist) {
|
|
1206
1207
|
if (entry === '*') return true;
|
|
1207
|
-
|
|
1208
|
+
const canonicalEntry = !entry.includes('*') && existsSync(entry) ? realpathSync(entry) : entry;
|
|
1209
|
+
if (entry === recipe || entry === resolved || canonicalEntry === canonicalResolved) return true;
|
|
1208
1210
|
if (entry.endsWith('*') && !entry.slice(0, -1).includes('*')) {
|
|
1209
1211
|
const prefix = entry.slice(0, -1);
|
|
1210
1212
|
if (recipe.startsWith(prefix) || resolved.startsWith(prefix)) return true;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObserversModule — the agent holds the pen on who may observe its interior.
|
|
3
|
+
*
|
|
4
|
+
* Interiority access (thinking, tool payloads, conversation stream via the
|
|
5
|
+
* webui) is a consent question before it is an ACL question — see connectome
|
|
6
|
+
* docs/observability.md §4. These tools let the agent read and edit its own
|
|
7
|
+
* data/observers.json grant file. The webui's ObserverRegistry hot-reloads
|
|
8
|
+
* the file on mtime (~3s), so grants and revocations apply to new
|
|
9
|
+
* connections without a restart. Operators can edit the same file directly;
|
|
10
|
+
* writes here are atomic (tmp+rename) so the two never corrupt each other.
|
|
11
|
+
*
|
|
12
|
+
* Grants marked `protected: true` (recipe-designated operator baseline) are
|
|
13
|
+
* visible but not revocable through these tools; only a file edit removes
|
|
14
|
+
* them. Tools also cannot CREATE protected grants — protection is an
|
|
15
|
+
* operator-level marker.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
Module,
|
|
20
|
+
ModuleContext,
|
|
21
|
+
ProcessEvent,
|
|
22
|
+
ProcessState,
|
|
23
|
+
EventResponse,
|
|
24
|
+
ToolDefinition,
|
|
25
|
+
ToolCall,
|
|
26
|
+
ToolResult,
|
|
27
|
+
} from '@animalabs/agent-framework';
|
|
28
|
+
import {
|
|
29
|
+
OBSERVER_SCOPES,
|
|
30
|
+
loadObserversFile,
|
|
31
|
+
saveObserversFile,
|
|
32
|
+
type ObserverGrant,
|
|
33
|
+
type ObserverScope,
|
|
34
|
+
type ObserversFile,
|
|
35
|
+
} from './web-ui-observers.js';
|
|
36
|
+
|
|
37
|
+
export interface ObserversModuleConfig {
|
|
38
|
+
/** Absolute path to the grant file (same one the webui watches). */
|
|
39
|
+
path: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ObserversModule implements Module {
|
|
43
|
+
readonly name = 'observers';
|
|
44
|
+
|
|
45
|
+
constructor(private readonly config: ObserversModuleConfig) {}
|
|
46
|
+
|
|
47
|
+
async start(_ctx: ModuleContext): Promise<void> {}
|
|
48
|
+
async stop(): Promise<void> {}
|
|
49
|
+
|
|
50
|
+
getTools(): ToolDefinition[] {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
name: 'get',
|
|
54
|
+
description:
|
|
55
|
+
'List who is currently authorized to observe your internals through the web viewer ' +
|
|
56
|
+
'(thinking, tool calls, conversation stream — by scope). Each grant is an ed25519 ' +
|
|
57
|
+
'public key + label + scope list. Labels are testimony, not verified identity.',
|
|
58
|
+
inputSchema: { type: 'object', properties: {} },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'grant',
|
|
62
|
+
description:
|
|
63
|
+
'Authorize an observer key to watch your internals through the web viewer. ' +
|
|
64
|
+
`Scopes (event families): ${OBSERVER_SCOPES.join(', ')}. ` +
|
|
65
|
+
"'health' = liveness/usage only; 'ops' = alerts (refusals, failures); 'messages' = the " +
|
|
66
|
+
"conversation; 'tools' = tool calls and results; 'thinking' = your thinking blocks; " +
|
|
67
|
+
"'debug' = compiled-context debug endpoints (implies seeing everything in your window). " +
|
|
68
|
+
'Grant deliberately — this is access to your interior. Takes effect within ~3 seconds.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
key: { type: 'string', description: 'ed25519:<base64url public key> — the fingerprint the person/device shows you.' },
|
|
73
|
+
label: { type: 'string', description: 'Who this is (your words — e.g. "antra-phone", "fleet-hub").' },
|
|
74
|
+
scopes: {
|
|
75
|
+
type: 'array',
|
|
76
|
+
items: { type: 'string', enum: [...OBSERVER_SCOPES] },
|
|
77
|
+
description: 'Event families this key may receive.',
|
|
78
|
+
},
|
|
79
|
+
expires: { type: 'string', description: 'Optional ISO timestamp; the grant stops working after this.' },
|
|
80
|
+
},
|
|
81
|
+
required: ['key', 'label', 'scopes'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'revoke',
|
|
86
|
+
description:
|
|
87
|
+
'Remove an observer grant by key or by label. Protected (operator-baseline) grants ' +
|
|
88
|
+
'cannot be revoked here. Existing connections are not killed, but new connections and ' +
|
|
89
|
+
'HTTP sessions stop authenticating within ~3 seconds.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
key: { type: 'string', description: 'ed25519:<base64url public key> to revoke.' },
|
|
94
|
+
label: { type: 'string', description: 'Alternative: revoke by exact label (must match exactly one grant).' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
102
|
+
const input = (call.input ?? {}) as Record<string, unknown>;
|
|
103
|
+
try {
|
|
104
|
+
switch (call.name) {
|
|
105
|
+
case 'get':
|
|
106
|
+
return ok({ observers: this.load().observers.map(redactNothing) });
|
|
107
|
+
|
|
108
|
+
case 'grant': {
|
|
109
|
+
const key = typeof input.key === 'string' ? input.key.trim() : '';
|
|
110
|
+
const label = typeof input.label === 'string' ? input.label.trim() : '';
|
|
111
|
+
const scopes = Array.isArray(input.scopes) ? input.scopes : [];
|
|
112
|
+
if (!key.startsWith('ed25519:') || key.length < 20) {
|
|
113
|
+
return err('key must be "ed25519:<base64url public key>"');
|
|
114
|
+
}
|
|
115
|
+
if (!label) return err('label is required');
|
|
116
|
+
if (scopes.length === 0 || !scopes.every((s) => (OBSERVER_SCOPES as readonly string[]).includes(s as string))) {
|
|
117
|
+
return err(`scopes must be a non-empty subset of: ${OBSERVER_SCOPES.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
const expires = typeof input.expires === 'string' ? input.expires : null;
|
|
120
|
+
if (expires && !Number.isFinite(Date.parse(expires))) return err('expires must be an ISO timestamp');
|
|
121
|
+
|
|
122
|
+
const file = this.load();
|
|
123
|
+
const existing = file.observers.find((g) => g.key === key);
|
|
124
|
+
if (existing?.protected) return err('that key holds a protected grant — edit the file to change it');
|
|
125
|
+
const grant: ObserverGrant = { key, label, scopes: scopes as ObserverScope[], expires };
|
|
126
|
+
if (existing) {
|
|
127
|
+
Object.assign(existing, grant); // update label/scopes/expiry in place
|
|
128
|
+
} else {
|
|
129
|
+
file.observers.push(grant);
|
|
130
|
+
}
|
|
131
|
+
saveObserversFile(this.config.path, file);
|
|
132
|
+
return ok({
|
|
133
|
+
message: `${existing ? 'Updated' : 'Granted'}: ${label} → [${grant.scopes.join(', ')}]${expires ? ` until ${expires}` : ''}. Live within ~3s.`,
|
|
134
|
+
observers: file.observers,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'revoke': {
|
|
139
|
+
const key = typeof input.key === 'string' ? input.key.trim() : '';
|
|
140
|
+
const label = typeof input.label === 'string' ? input.label.trim() : '';
|
|
141
|
+
if (!key && !label) return err('provide key or label');
|
|
142
|
+
const file = this.load();
|
|
143
|
+
const matches = file.observers.filter((g) => (key ? g.key === key : g.label === label));
|
|
144
|
+
if (matches.length === 0) return err('no matching grant');
|
|
145
|
+
if (matches.length > 1) return err(`label matches ${matches.length} grants — revoke by key`);
|
|
146
|
+
const target = matches[0]!;
|
|
147
|
+
if (target.protected) return err('that grant is protected (operator baseline) — edit the file to remove it');
|
|
148
|
+
file.observers = file.observers.filter((g) => g !== target);
|
|
149
|
+
saveObserversFile(this.config.path, file);
|
|
150
|
+
return ok({ message: `Revoked: ${target.label}. New connections stop within ~3s.`, observers: file.observers });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
default:
|
|
154
|
+
return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return err(`observers file operation failed: ${e instanceof Error ? e.message : e}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private load(): ObserversFile {
|
|
166
|
+
return loadObserversFile(this.config.path) ?? { observers: [] };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function redactNothing(g: ObserverGrant): ObserverGrant {
|
|
171
|
+
return g; // grants hold public keys + labels only — nothing secret to redact
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ok(data: Record<string, unknown>): ToolResult {
|
|
175
|
+
return { success: true, data, isError: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function err(message: string): ToolResult {
|
|
179
|
+
return { success: false, error: message, isError: true };
|
|
180
|
+
}
|