@usejarvis/brain 0.1.0
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/LICENSE +153 -0
- package/README.md +278 -0
- package/bin/jarvis.ts +413 -0
- package/package.json +74 -0
- package/scripts/ensure-bun.cjs +8 -0
- package/src/actions/README.md +421 -0
- package/src/actions/app-control/desktop-controller.test.ts +26 -0
- package/src/actions/app-control/desktop-controller.ts +438 -0
- package/src/actions/app-control/interface.ts +64 -0
- package/src/actions/app-control/linux.ts +273 -0
- package/src/actions/app-control/macos.ts +54 -0
- package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
- package/src/actions/app-control/sidecar-launcher.ts +286 -0
- package/src/actions/app-control/windows.ts +44 -0
- package/src/actions/browser/cdp.ts +138 -0
- package/src/actions/browser/chrome-launcher.ts +252 -0
- package/src/actions/browser/session.ts +437 -0
- package/src/actions/browser/stealth.ts +49 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/terminal/executor.ts +157 -0
- package/src/actions/terminal/wsl-bridge.ts +126 -0
- package/src/actions/test.ts +93 -0
- package/src/actions/tools/agents.ts +321 -0
- package/src/actions/tools/builtin.ts +846 -0
- package/src/actions/tools/commitments.ts +192 -0
- package/src/actions/tools/content.ts +217 -0
- package/src/actions/tools/delegate.ts +147 -0
- package/src/actions/tools/desktop.test.ts +55 -0
- package/src/actions/tools/desktop.ts +305 -0
- package/src/actions/tools/goals.ts +376 -0
- package/src/actions/tools/local-tools-guard.ts +20 -0
- package/src/actions/tools/registry.ts +171 -0
- package/src/actions/tools/research.ts +111 -0
- package/src/actions/tools/sidecar-list.ts +57 -0
- package/src/actions/tools/sidecar-route.ts +105 -0
- package/src/actions/tools/workflows.ts +216 -0
- package/src/agents/agent.ts +132 -0
- package/src/agents/delegation.ts +107 -0
- package/src/agents/hierarchy.ts +113 -0
- package/src/agents/index.ts +19 -0
- package/src/agents/messaging.ts +125 -0
- package/src/agents/orchestrator.ts +576 -0
- package/src/agents/role-discovery.ts +61 -0
- package/src/agents/sub-agent-runner.ts +307 -0
- package/src/agents/task-manager.ts +151 -0
- package/src/authority/approval-delivery.ts +59 -0
- package/src/authority/approval.ts +196 -0
- package/src/authority/audit.ts +158 -0
- package/src/authority/authority.test.ts +519 -0
- package/src/authority/deferred-executor.ts +103 -0
- package/src/authority/emergency.ts +66 -0
- package/src/authority/engine.ts +297 -0
- package/src/authority/index.ts +12 -0
- package/src/authority/learning.ts +111 -0
- package/src/authority/tool-action-map.ts +74 -0
- package/src/awareness/analytics.ts +466 -0
- package/src/awareness/awareness.test.ts +332 -0
- package/src/awareness/capture-engine.ts +305 -0
- package/src/awareness/context-graph.ts +130 -0
- package/src/awareness/context-tracker.ts +349 -0
- package/src/awareness/index.ts +25 -0
- package/src/awareness/intelligence.ts +321 -0
- package/src/awareness/ocr-engine.ts +88 -0
- package/src/awareness/service.ts +528 -0
- package/src/awareness/struggle-detector.ts +342 -0
- package/src/awareness/suggestion-engine.ts +476 -0
- package/src/awareness/types.ts +201 -0
- package/src/cli/autostart.ts +241 -0
- package/src/cli/deps.ts +449 -0
- package/src/cli/doctor.ts +230 -0
- package/src/cli/helpers.ts +401 -0
- package/src/cli/onboard.ts +580 -0
- package/src/comms/README.md +329 -0
- package/src/comms/auth-error.html +48 -0
- package/src/comms/channels/discord.ts +228 -0
- package/src/comms/channels/signal.ts +56 -0
- package/src/comms/channels/telegram.ts +316 -0
- package/src/comms/channels/whatsapp.ts +60 -0
- package/src/comms/channels.test.ts +173 -0
- package/src/comms/desktop-notify.ts +114 -0
- package/src/comms/example.ts +129 -0
- package/src/comms/index.ts +129 -0
- package/src/comms/streaming.ts +142 -0
- package/src/comms/voice.test.ts +152 -0
- package/src/comms/voice.ts +291 -0
- package/src/comms/websocket.test.ts +409 -0
- package/src/comms/websocket.ts +473 -0
- package/src/config/README.md +387 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +137 -0
- package/src/config/loader.ts +142 -0
- package/src/config/types.ts +260 -0
- package/src/daemon/README.md +232 -0
- package/src/daemon/agent-service-interface.ts +9 -0
- package/src/daemon/agent-service.ts +600 -0
- package/src/daemon/api-routes.ts +2119 -0
- package/src/daemon/background-agent-service.ts +396 -0
- package/src/daemon/background-agent.test.ts +78 -0
- package/src/daemon/channel-service.ts +201 -0
- package/src/daemon/commitment-executor.ts +297 -0
- package/src/daemon/event-classifier.ts +239 -0
- package/src/daemon/event-coalescer.ts +123 -0
- package/src/daemon/event-reactor.ts +214 -0
- package/src/daemon/health.ts +220 -0
- package/src/daemon/index.ts +1004 -0
- package/src/daemon/llm-settings.ts +316 -0
- package/src/daemon/observer-service.ts +150 -0
- package/src/daemon/pid.ts +98 -0
- package/src/daemon/research-queue.ts +155 -0
- package/src/daemon/services.ts +175 -0
- package/src/daemon/ws-service.ts +788 -0
- package/src/goals/accountability.ts +240 -0
- package/src/goals/awareness-bridge.ts +185 -0
- package/src/goals/estimator.ts +185 -0
- package/src/goals/events.ts +28 -0
- package/src/goals/goals.test.ts +400 -0
- package/src/goals/integration.test.ts +329 -0
- package/src/goals/nl-builder.test.ts +220 -0
- package/src/goals/nl-builder.ts +256 -0
- package/src/goals/rhythm.test.ts +177 -0
- package/src/goals/rhythm.ts +275 -0
- package/src/goals/service.test.ts +135 -0
- package/src/goals/service.ts +348 -0
- package/src/goals/types.ts +106 -0
- package/src/goals/workflow-bridge.ts +96 -0
- package/src/integrations/google-api.ts +134 -0
- package/src/integrations/google-auth.ts +175 -0
- package/src/llm/README.md +291 -0
- package/src/llm/anthropic.ts +386 -0
- package/src/llm/gemini.ts +371 -0
- package/src/llm/index.ts +19 -0
- package/src/llm/manager.ts +153 -0
- package/src/llm/ollama.ts +307 -0
- package/src/llm/openai.ts +350 -0
- package/src/llm/provider.test.ts +231 -0
- package/src/llm/provider.ts +60 -0
- package/src/llm/test.ts +87 -0
- package/src/observers/README.md +278 -0
- package/src/observers/calendar.ts +113 -0
- package/src/observers/clipboard.ts +136 -0
- package/src/observers/email.ts +109 -0
- package/src/observers/example.ts +58 -0
- package/src/observers/file-watcher.ts +124 -0
- package/src/observers/index.ts +159 -0
- package/src/observers/notifications.ts +197 -0
- package/src/observers/observers.test.ts +203 -0
- package/src/observers/processes.ts +225 -0
- package/src/personality/README.md +61 -0
- package/src/personality/adapter.ts +196 -0
- package/src/personality/index.ts +20 -0
- package/src/personality/learner.ts +209 -0
- package/src/personality/model.ts +132 -0
- package/src/personality/personality.test.ts +236 -0
- package/src/roles/README.md +252 -0
- package/src/roles/authority.ts +119 -0
- package/src/roles/example-usage.ts +198 -0
- package/src/roles/index.ts +42 -0
- package/src/roles/loader.ts +143 -0
- package/src/roles/prompt-builder.ts +194 -0
- package/src/roles/test-multi.ts +102 -0
- package/src/roles/test-role.yaml +77 -0
- package/src/roles/test-utils.ts +93 -0
- package/src/roles/test.ts +106 -0
- package/src/roles/tool-guide.ts +190 -0
- package/src/roles/types.ts +36 -0
- package/src/roles/utils.ts +200 -0
- package/src/scripts/google-setup.ts +168 -0
- package/src/sidecar/connection.ts +179 -0
- package/src/sidecar/index.ts +6 -0
- package/src/sidecar/manager.ts +542 -0
- package/src/sidecar/protocol.ts +85 -0
- package/src/sidecar/rpc.ts +161 -0
- package/src/sidecar/scheduler.ts +136 -0
- package/src/sidecar/types.ts +112 -0
- package/src/sidecar/validator.ts +144 -0
- package/src/vault/README.md +110 -0
- package/src/vault/awareness.ts +341 -0
- package/src/vault/commitments.ts +299 -0
- package/src/vault/content-pipeline.ts +260 -0
- package/src/vault/conversations.ts +173 -0
- package/src/vault/entities.ts +180 -0
- package/src/vault/extractor.test.ts +356 -0
- package/src/vault/extractor.ts +345 -0
- package/src/vault/facts.ts +190 -0
- package/src/vault/goals.ts +477 -0
- package/src/vault/index.ts +87 -0
- package/src/vault/keychain.ts +99 -0
- package/src/vault/observations.ts +115 -0
- package/src/vault/relationships.ts +178 -0
- package/src/vault/retrieval.test.ts +126 -0
- package/src/vault/retrieval.ts +227 -0
- package/src/vault/schema.ts +658 -0
- package/src/vault/settings.ts +38 -0
- package/src/vault/vectors.ts +92 -0
- package/src/vault/workflows.ts +403 -0
- package/src/workflows/auto-suggest.ts +290 -0
- package/src/workflows/engine.ts +366 -0
- package/src/workflows/events.ts +24 -0
- package/src/workflows/executor.ts +207 -0
- package/src/workflows/nl-builder.ts +198 -0
- package/src/workflows/nodes/actions/agent-task.ts +73 -0
- package/src/workflows/nodes/actions/calendar-action.ts +85 -0
- package/src/workflows/nodes/actions/code-execution.ts +73 -0
- package/src/workflows/nodes/actions/discord.ts +77 -0
- package/src/workflows/nodes/actions/file-write.ts +73 -0
- package/src/workflows/nodes/actions/gmail.ts +69 -0
- package/src/workflows/nodes/actions/http-request.ts +117 -0
- package/src/workflows/nodes/actions/notification.ts +85 -0
- package/src/workflows/nodes/actions/run-tool.ts +55 -0
- package/src/workflows/nodes/actions/send-message.ts +82 -0
- package/src/workflows/nodes/actions/shell-command.ts +76 -0
- package/src/workflows/nodes/actions/telegram.ts +60 -0
- package/src/workflows/nodes/builtin.ts +119 -0
- package/src/workflows/nodes/error/error-handler.ts +37 -0
- package/src/workflows/nodes/error/fallback.ts +47 -0
- package/src/workflows/nodes/error/retry.ts +82 -0
- package/src/workflows/nodes/logic/delay.ts +42 -0
- package/src/workflows/nodes/logic/if-else.ts +41 -0
- package/src/workflows/nodes/logic/loop.ts +90 -0
- package/src/workflows/nodes/logic/merge.ts +38 -0
- package/src/workflows/nodes/logic/race.ts +40 -0
- package/src/workflows/nodes/logic/switch.ts +59 -0
- package/src/workflows/nodes/logic/template-render.ts +53 -0
- package/src/workflows/nodes/logic/variable-get.ts +37 -0
- package/src/workflows/nodes/logic/variable-set.ts +59 -0
- package/src/workflows/nodes/registry.ts +99 -0
- package/src/workflows/nodes/transform/aggregate.ts +99 -0
- package/src/workflows/nodes/transform/csv-parse.ts +70 -0
- package/src/workflows/nodes/transform/json-parse.ts +63 -0
- package/src/workflows/nodes/transform/map-filter.ts +84 -0
- package/src/workflows/nodes/transform/regex-match.ts +89 -0
- package/src/workflows/nodes/triggers/calendar.ts +33 -0
- package/src/workflows/nodes/triggers/clipboard.ts +32 -0
- package/src/workflows/nodes/triggers/cron.ts +40 -0
- package/src/workflows/nodes/triggers/email.ts +40 -0
- package/src/workflows/nodes/triggers/file-change.ts +45 -0
- package/src/workflows/nodes/triggers/git.ts +46 -0
- package/src/workflows/nodes/triggers/manual.ts +23 -0
- package/src/workflows/nodes/triggers/poll.ts +81 -0
- package/src/workflows/nodes/triggers/process.ts +44 -0
- package/src/workflows/nodes/triggers/screen-event.ts +37 -0
- package/src/workflows/nodes/triggers/webhook.ts +39 -0
- package/src/workflows/safe-eval.ts +139 -0
- package/src/workflows/template.ts +118 -0
- package/src/workflows/triggers/cron.ts +311 -0
- package/src/workflows/triggers/manager.ts +285 -0
- package/src/workflows/triggers/observer-bridge.ts +172 -0
- package/src/workflows/triggers/poller.ts +201 -0
- package/src/workflows/triggers/screen-condition.ts +218 -0
- package/src/workflows/triggers/triggers.test.ts +740 -0
- package/src/workflows/triggers/webhook.ts +191 -0
- package/src/workflows/types.ts +133 -0
- package/src/workflows/variables.ts +72 -0
- package/src/workflows/workflows.test.ts +383 -0
- package/src/workflows/yaml.ts +104 -0
- package/ui/dist/index-j75njzc1.css +1199 -0
- package/ui/dist/index-p2zh407q.js +80603 -0
- package/ui/dist/index.html +13 -0
- package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
- package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
- package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
- package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
- package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Manager
|
|
3
|
+
*
|
|
4
|
+
* Brain-side service that manages sidecar enrollment, authentication,
|
|
5
|
+
* and connection tracking. Handles ES256 key pair lifecycle and JWT signing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generateKeyPair, exportJWK, exportPKCS8, exportSPKI, importPKCS8, importSPKI, SignJWT, jwtVerify, createRemoteJWKSet, type JWK } from 'jose';
|
|
9
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import type { ServerWebSocket } from 'bun';
|
|
12
|
+
import type { Service, ServiceStatus } from '../daemon/services.ts';
|
|
13
|
+
import { getDb, generateId } from '../vault/schema.ts';
|
|
14
|
+
import type {
|
|
15
|
+
SidecarRecord,
|
|
16
|
+
SidecarInfo,
|
|
17
|
+
SidecarTokenClaims,
|
|
18
|
+
ConnectedSidecar,
|
|
19
|
+
SidecarCapability,
|
|
20
|
+
UnavailableCapability,
|
|
21
|
+
} from './types.ts';
|
|
22
|
+
import type { RPCRequest, RPCTimeouts, SidecarEvent, RPCResultPayload, RPCErrorPayload, RPCProgressPayload } from './protocol.ts';
|
|
23
|
+
import { DEFAULT_RPC_TIMEOUTS } from './protocol.ts';
|
|
24
|
+
import { EventScheduler } from './scheduler.ts';
|
|
25
|
+
import { RPCTracker } from './rpc.ts';
|
|
26
|
+
import { SidecarConnection } from './connection.ts';
|
|
27
|
+
|
|
28
|
+
const ALG = 'ES256';
|
|
29
|
+
const KEY_DIR_NAME = 'sidecar-keys';
|
|
30
|
+
const PRIVATE_KEY_FILE = 'private.pem';
|
|
31
|
+
const PUBLIC_KEY_FILE = 'public.pem';
|
|
32
|
+
|
|
33
|
+
export class SidecarManager implements Service {
|
|
34
|
+
readonly name = 'sidecar-manager';
|
|
35
|
+
|
|
36
|
+
private privateKey: CryptoKey | null = null;
|
|
37
|
+
private publicKey: CryptoKey | null = null;
|
|
38
|
+
private publicJwk: JWK | null = null;
|
|
39
|
+
private keyId: string = '';
|
|
40
|
+
private dataDir: string;
|
|
41
|
+
private brainUrl: string = '';
|
|
42
|
+
private _status: ServiceStatus = 'stopped';
|
|
43
|
+
|
|
44
|
+
/** Runtime map of connected sidecars (not persisted) */
|
|
45
|
+
private connected = new Map<string, ConnectedSidecar>();
|
|
46
|
+
|
|
47
|
+
/** Protocol infrastructure */
|
|
48
|
+
private scheduler: EventScheduler;
|
|
49
|
+
private rpcTracker: RPCTracker;
|
|
50
|
+
private sidecarConnections = new Map<string, SidecarConnection>();
|
|
51
|
+
private progressListeners = new Set<(sidecarId: string, rpcId: string, progress: number, message?: string) => void>();
|
|
52
|
+
private eventListeners = new Set<(sidecarId: string, event: SidecarEvent) => void>();
|
|
53
|
+
|
|
54
|
+
constructor(dataDir: string) {
|
|
55
|
+
this.dataDir = dataDir;
|
|
56
|
+
this.scheduler = new EventScheduler();
|
|
57
|
+
this.rpcTracker = new RPCTracker();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set the brain's external URL (used in JWT claims).
|
|
62
|
+
* Must be called before enrolling sidecars.
|
|
63
|
+
* Example: "shiny-panda.domain.com" or "localhost:3142"
|
|
64
|
+
*/
|
|
65
|
+
setBrainUrl(url: string): void {
|
|
66
|
+
this.brainUrl = url;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --------------- Service Interface ---------------
|
|
70
|
+
|
|
71
|
+
async start(): Promise<void> {
|
|
72
|
+
this._status = 'starting';
|
|
73
|
+
try {
|
|
74
|
+
await this.loadOrGenerateKeys();
|
|
75
|
+
|
|
76
|
+
// Wire scheduler handlers
|
|
77
|
+
this.scheduler.on('rpc_result', async (sidecarId, event) => {
|
|
78
|
+
const payload = event.payload as RPCResultPayload | RPCErrorPayload;
|
|
79
|
+
if (payload.error) {
|
|
80
|
+
this.rpcTracker.fail(payload.rpc_id, new Error(`${payload.error.code}: ${payload.error.message}`));
|
|
81
|
+
} else {
|
|
82
|
+
// Attach binary data to result when present (e.g. capture_screen returns image in binary)
|
|
83
|
+
const result = payload.result as Record<string, unknown> | undefined;
|
|
84
|
+
if (event.binary && result && typeof result === 'object') {
|
|
85
|
+
(result as Record<string, unknown>)._binary = event.binary;
|
|
86
|
+
}
|
|
87
|
+
this.rpcTracker.resolve(payload.rpc_id, result);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.scheduler.on('rpc_progress', async (sidecarId, event) => {
|
|
92
|
+
const payload = event.payload as RPCProgressPayload;
|
|
93
|
+
for (const listener of this.progressListeners) {
|
|
94
|
+
listener(sidecarId, payload.rpc_id, payload.progress, payload.message);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Register handlers for each sidecar observer event type
|
|
99
|
+
const sidecarEventTypes = ['screen_capture', 'context_changed', 'idle_detected', 'clipboard_change'];
|
|
100
|
+
const sidecarEventHandler = async (sidecarId: string, event: SidecarEvent) => {
|
|
101
|
+
for (const listener of this.eventListeners) {
|
|
102
|
+
listener(sidecarId, event);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
for (const type of sidecarEventTypes) {
|
|
106
|
+
this.scheduler.on(type, sidecarEventHandler);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.rpcTracker.onDetachedComplete((rpcId, result, error) => {
|
|
110
|
+
if (error) {
|
|
111
|
+
console.warn(`[SidecarManager] Detached RPC ${rpcId} failed:`, error.message);
|
|
112
|
+
} else {
|
|
113
|
+
console.log(`[SidecarManager] Detached RPC ${rpcId} completed`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.scheduler.start();
|
|
118
|
+
|
|
119
|
+
this._status = 'running';
|
|
120
|
+
console.log('[SidecarManager] Started — keys loaded, scheduler running');
|
|
121
|
+
} catch (err) {
|
|
122
|
+
this._status = 'error';
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async stop(): Promise<void> {
|
|
128
|
+
this._status = 'stopping';
|
|
129
|
+
|
|
130
|
+
// Stop scheduler
|
|
131
|
+
this.scheduler.stop();
|
|
132
|
+
|
|
133
|
+
// Close all sidecar connections and fail pending RPCs
|
|
134
|
+
for (const [id, conn] of this.sidecarConnections) {
|
|
135
|
+
this.rpcTracker.failAll(id, 'manager stopping');
|
|
136
|
+
conn.close();
|
|
137
|
+
}
|
|
138
|
+
this.sidecarConnections.clear();
|
|
139
|
+
|
|
140
|
+
this.privateKey = null;
|
|
141
|
+
this.publicKey = null;
|
|
142
|
+
this.publicJwk = null;
|
|
143
|
+
this.connected.clear();
|
|
144
|
+
this._status = 'stopped';
|
|
145
|
+
console.log('[SidecarManager] Stopped');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
status(): ServiceStatus {
|
|
149
|
+
return this._status;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --------------- Key Management ---------------
|
|
153
|
+
|
|
154
|
+
private get keysDir(): string {
|
|
155
|
+
return path.join(this.dataDir, KEY_DIR_NAME);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private get privateKeyPath(): string {
|
|
159
|
+
return path.join(this.keysDir, PRIVATE_KEY_FILE);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private get publicKeyPath(): string {
|
|
163
|
+
return path.join(this.keysDir, PUBLIC_KEY_FILE);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async loadOrGenerateKeys(): Promise<void> {
|
|
167
|
+
if (existsSync(this.privateKeyPath) && existsSync(this.publicKeyPath)) {
|
|
168
|
+
await this.loadKeys();
|
|
169
|
+
console.log('[SidecarManager] Loaded existing ES256 key pair');
|
|
170
|
+
} else {
|
|
171
|
+
await this.generateKeys();
|
|
172
|
+
console.log('[SidecarManager] Generated new ES256 key pair');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Export public key as JWK for the JWKS endpoint
|
|
176
|
+
this.publicJwk = await exportJWK(this.publicKey!);
|
|
177
|
+
this.keyId = this.publicJwk.x ?? 'default'; // use x-coordinate as kid (stable, unique)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async generateKeys(): Promise<void> {
|
|
181
|
+
mkdirSync(this.keysDir, { recursive: true });
|
|
182
|
+
|
|
183
|
+
const { privateKey, publicKey } = await generateKeyPair(ALG, { extractable: true });
|
|
184
|
+
this.privateKey = privateKey;
|
|
185
|
+
this.publicKey = publicKey;
|
|
186
|
+
|
|
187
|
+
// Export to PEM and write to disk
|
|
188
|
+
const pkcs8 = await exportPKCS8(privateKey);
|
|
189
|
+
const spki = await exportSPKI(publicKey);
|
|
190
|
+
|
|
191
|
+
await Bun.write(this.privateKeyPath, pkcs8);
|
|
192
|
+
await Bun.write(this.publicKeyPath, spki);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async loadKeys(): Promise<void> {
|
|
196
|
+
const privatePem = await Bun.file(this.privateKeyPath).text();
|
|
197
|
+
const publicPem = await Bun.file(this.publicKeyPath).text();
|
|
198
|
+
|
|
199
|
+
this.privateKey = await importPKCS8(privatePem, ALG, { extractable: true });
|
|
200
|
+
this.publicKey = await importSPKI(publicPem, ALG, { extractable: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --------------- JWKS ---------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Returns the JWKS (JSON Web Key Set) containing the brain's public key.
|
|
207
|
+
* Served at GET /api/sidecars/.well-known/jwks.json
|
|
208
|
+
*/
|
|
209
|
+
getJwks(): { keys: JWK[] } {
|
|
210
|
+
if (!this.publicJwk) {
|
|
211
|
+
throw new Error('SidecarManager not started');
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
keys: [
|
|
215
|
+
{
|
|
216
|
+
...this.publicJwk,
|
|
217
|
+
alg: ALG,
|
|
218
|
+
use: 'sig',
|
|
219
|
+
kid: this.keyId,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --------------- Enrollment ---------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Enroll a new sidecar. Returns the signed JWT enrollment token.
|
|
229
|
+
*/
|
|
230
|
+
async enrollSidecar(name: string): Promise<{ token: string; sidecar: SidecarRecord }> {
|
|
231
|
+
if (!this.privateKey) throw new Error('SidecarManager not started');
|
|
232
|
+
if (!this.brainUrl) throw new Error('Brain URL not configured — call setBrainUrl() first');
|
|
233
|
+
|
|
234
|
+
// Validate name
|
|
235
|
+
const trimmed = name.trim();
|
|
236
|
+
if (!trimmed || trimmed.length > 64) {
|
|
237
|
+
throw new Error('Sidecar name must be 1-64 characters');
|
|
238
|
+
}
|
|
239
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
240
|
+
throw new Error('Sidecar name may only contain letters, numbers, hyphens, and underscores');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check uniqueness
|
|
244
|
+
const db = getDb();
|
|
245
|
+
const existing = db.query('SELECT id FROM sidecars WHERE name = ? AND status = ?').get(trimmed, 'enrolled') as { id: string } | null;
|
|
246
|
+
if (existing) {
|
|
247
|
+
throw new Error(`Sidecar "${trimmed}" is already enrolled`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const id = generateId();
|
|
251
|
+
const tokenId = generateId();
|
|
252
|
+
|
|
253
|
+
// Determine protocol based on brain URL
|
|
254
|
+
const isSecure = !this.brainUrl.includes('localhost') && !this.brainUrl.match(/:\d+$/);
|
|
255
|
+
const wsProtocol = isSecure ? 'wss' : 'ws';
|
|
256
|
+
const httpProtocol = isSecure ? 'https' : 'http';
|
|
257
|
+
|
|
258
|
+
const brainWs = `${wsProtocol}://${this.brainUrl}/sidecar/connect`;
|
|
259
|
+
const jwksUrl = `${httpProtocol}://${this.brainUrl}/api/sidecars/.well-known/jwks.json`;
|
|
260
|
+
|
|
261
|
+
// Sign JWT
|
|
262
|
+
const token = await new SignJWT({
|
|
263
|
+
sid: id,
|
|
264
|
+
name: trimmed,
|
|
265
|
+
brain: brainWs,
|
|
266
|
+
jwks: jwksUrl,
|
|
267
|
+
} satisfies Omit<SidecarTokenClaims, 'sub' | 'jti' | 'iat'>)
|
|
268
|
+
.setProtectedHeader({ alg: ALG, kid: this.keyId })
|
|
269
|
+
.setSubject(`sidecar:${id}`)
|
|
270
|
+
.setJti(tokenId)
|
|
271
|
+
.setIssuedAt()
|
|
272
|
+
.sign(this.privateKey);
|
|
273
|
+
|
|
274
|
+
// Store in database
|
|
275
|
+
db.run(
|
|
276
|
+
'INSERT INTO sidecars (id, name, token_id) VALUES (?, ?, ?)',
|
|
277
|
+
[id, trimmed, tokenId],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const sidecar = db.query('SELECT * FROM sidecars WHERE id = ?').get(id) as SidecarRecord;
|
|
281
|
+
console.log(`[SidecarManager] Enrolled sidecar "${trimmed}" (${id})`);
|
|
282
|
+
|
|
283
|
+
return { token, sidecar };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --------------- Registry (DB queries) ---------------
|
|
287
|
+
|
|
288
|
+
/** Get all enrolled sidecars with connection state */
|
|
289
|
+
listSidecars(): SidecarInfo[] {
|
|
290
|
+
const db = getDb();
|
|
291
|
+
const records = db.query('SELECT * FROM sidecars WHERE status = ? ORDER BY enrolled_at DESC').all('enrolled') as SidecarRecord[];
|
|
292
|
+
return records.map((r) => this.toSidecarInfo(r));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Get a single sidecar by ID */
|
|
296
|
+
getSidecar(id: string): SidecarInfo | null {
|
|
297
|
+
const db = getDb();
|
|
298
|
+
const record = db.query('SELECT * FROM sidecars WHERE id = ?').get(id) as SidecarRecord | null;
|
|
299
|
+
return record ? this.toSidecarInfo(record) : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Revoke a sidecar and remove it from the database. Disconnects if connected. */
|
|
303
|
+
revokeSidecar(id: string): boolean {
|
|
304
|
+
const db = getDb();
|
|
305
|
+
const result = db.run('DELETE FROM sidecars WHERE id = ? AND status = ?', [id, 'enrolled']);
|
|
306
|
+
if (result.changes > 0) {
|
|
307
|
+
this.connected.delete(id);
|
|
308
|
+
console.log(`[SidecarManager] Revoked and removed sidecar ${id}`);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Check if a sidecar ID is enrolled (not revoked) */
|
|
315
|
+
isEnrolled(id: string): boolean {
|
|
316
|
+
const db = getDb();
|
|
317
|
+
const row = db.query('SELECT id FROM sidecars WHERE id = ? AND status = ?').get(id, 'enrolled');
|
|
318
|
+
return row !== null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Update last_seen_at for a sidecar */
|
|
322
|
+
touchSidecar(id: string): void {
|
|
323
|
+
const db = getDb();
|
|
324
|
+
db.run("UPDATE sidecars SET last_seen_at = datetime('now') WHERE id = ?", [id]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --------------- Connection Tracking ---------------
|
|
328
|
+
|
|
329
|
+
/** Register a connected sidecar (called after WS handshake + registration message) */
|
|
330
|
+
registerConnection(sidecar: ConnectedSidecar): void {
|
|
331
|
+
this.connected.set(sidecar.id, sidecar);
|
|
332
|
+
// Persist connection details to DB so they're available even when offline
|
|
333
|
+
const db = getDb();
|
|
334
|
+
db.run(
|
|
335
|
+
`UPDATE sidecars SET last_seen_at = datetime('now'), hostname = ?, os = ?, platform = ?, capabilities = ? WHERE id = ?`,
|
|
336
|
+
[sidecar.hostname, sidecar.os, sidecar.platform, JSON.stringify(sidecar.capabilities), sidecar.id],
|
|
337
|
+
);
|
|
338
|
+
console.log(`[SidecarManager] Sidecar connected: ${sidecar.name} (${sidecar.id})`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Remove a connected sidecar (called on WS close) */
|
|
342
|
+
removeConnection(id: string): void {
|
|
343
|
+
const sc = this.connected.get(id);
|
|
344
|
+
this.connected.delete(id);
|
|
345
|
+
if (sc) {
|
|
346
|
+
console.log(`[SidecarManager] Sidecar disconnected: ${sc.name} (${id})`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Update capabilities for a connected sidecar (called on config reload) */
|
|
351
|
+
updateCapabilities(sidecarId: string, capabilities: SidecarCapability[], unavailableCapabilities: UnavailableCapability[] = []): void {
|
|
352
|
+
const conn = this.connected.get(sidecarId);
|
|
353
|
+
if (conn) {
|
|
354
|
+
conn.capabilities = capabilities;
|
|
355
|
+
conn.unavailableCapabilities = unavailableCapabilities;
|
|
356
|
+
}
|
|
357
|
+
const db = getDb();
|
|
358
|
+
db.run('UPDATE sidecars SET capabilities = ? WHERE id = ?', [JSON.stringify(capabilities), sidecarId]);
|
|
359
|
+
console.log(`[SidecarManager] Capabilities updated for ${sidecarId}: ${capabilities.join(', ')}`);
|
|
360
|
+
if (unavailableCapabilities.length > 0) {
|
|
361
|
+
console.log(`[SidecarManager] Unavailable: ${unavailableCapabilities.map(u => u.name).join(', ')}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Get all currently connected sidecars */
|
|
366
|
+
getConnectedSidecars(): ConnectedSidecar[] {
|
|
367
|
+
return Array.from(this.connected.values());
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Check if a specific sidecar is connected */
|
|
371
|
+
isConnected(id: string): boolean {
|
|
372
|
+
return this.connected.has(id);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --------------- Protocol: Token Validation ---------------
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Verify a JWT token and return claims if valid and sidecar is enrolled.
|
|
379
|
+
*/
|
|
380
|
+
async validateToken(token: string): Promise<SidecarTokenClaims | null> {
|
|
381
|
+
if (!this.publicKey) return null;
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const { payload } = await jwtVerify(token, this.publicKey, {
|
|
385
|
+
algorithms: [ALG],
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const claims = payload as unknown as SidecarTokenClaims;
|
|
389
|
+
|
|
390
|
+
// Check sidecar is still enrolled
|
|
391
|
+
if (!claims.sid || !this.isEnrolled(claims.sid)) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return claims;
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --------------- Protocol: WebSocket Handlers ---------------
|
|
402
|
+
|
|
403
|
+
/** Called when a sidecar WebSocket connects (after JWT validation) */
|
|
404
|
+
handleSidecarConnect(ws: ServerWebSocket<unknown>, sidecarId: string): void {
|
|
405
|
+
const connection = new SidecarConnection(
|
|
406
|
+
sidecarId,
|
|
407
|
+
ws,
|
|
408
|
+
this.scheduler,
|
|
409
|
+
() => this.handleSidecarDisconnect(sidecarId),
|
|
410
|
+
);
|
|
411
|
+
connection.startHeartbeat();
|
|
412
|
+
this.sidecarConnections.set(sidecarId, connection);
|
|
413
|
+
this.touchSidecar(sidecarId);
|
|
414
|
+
console.log(`[SidecarManager] Sidecar WS connected: ${sidecarId}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Route inbound messages to the correct SidecarConnection */
|
|
418
|
+
handleSidecarMessage(ws: ServerWebSocket<unknown>, message: string | Buffer): void {
|
|
419
|
+
// Find connection by ws — we need the sidecar_id from ws.data
|
|
420
|
+
const sidecarId = (ws.data as any)?.sidecar_id as string;
|
|
421
|
+
if (!sidecarId) return;
|
|
422
|
+
|
|
423
|
+
// Intercept registration messages before routing to connection
|
|
424
|
+
if (typeof message === 'string') {
|
|
425
|
+
try {
|
|
426
|
+
const parsed = JSON.parse(message);
|
|
427
|
+
if (parsed.type === 'register') {
|
|
428
|
+
const record = this.getSidecar(sidecarId);
|
|
429
|
+
this.registerConnection({
|
|
430
|
+
id: sidecarId,
|
|
431
|
+
name: record?.name ?? parsed.hostname ?? sidecarId,
|
|
432
|
+
hostname: parsed.hostname ?? 'unknown',
|
|
433
|
+
os: parsed.os ?? 'unknown',
|
|
434
|
+
platform: parsed.platform ?? 'unknown',
|
|
435
|
+
capabilities: parsed.capabilities ?? [],
|
|
436
|
+
unavailableCapabilities: parsed.unavailable_capabilities ?? [],
|
|
437
|
+
connectedAt: new Date(),
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (parsed.type === 'capabilities_update') {
|
|
442
|
+
this.updateCapabilities(sidecarId, parsed.capabilities ?? [], parsed.unavailable_capabilities ?? []);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
// Not JSON or not a register message — fall through to connection handler
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const connection = this.sidecarConnections.get(sidecarId);
|
|
451
|
+
if (!connection) return;
|
|
452
|
+
|
|
453
|
+
if (message instanceof Buffer) {
|
|
454
|
+
connection.handleBinary(message);
|
|
455
|
+
} else {
|
|
456
|
+
connection.handleMessage(message.toString());
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Called when a pong is received from a sidecar */
|
|
461
|
+
handleSidecarPong(sidecarId: string): void {
|
|
462
|
+
const connection = this.sidecarConnections.get(sidecarId);
|
|
463
|
+
if (connection) {
|
|
464
|
+
connection.handlePong();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Called when a sidecar WebSocket disconnects */
|
|
469
|
+
handleSidecarDisconnect(sidecarId: string): void {
|
|
470
|
+
const conn = this.sidecarConnections.get(sidecarId);
|
|
471
|
+
if (conn) {
|
|
472
|
+
conn.close();
|
|
473
|
+
this.sidecarConnections.delete(sidecarId);
|
|
474
|
+
}
|
|
475
|
+
this.scheduler.removeSidecar(sidecarId);
|
|
476
|
+
this.rpcTracker.failAll(sidecarId, 'disconnected');
|
|
477
|
+
this.removeConnection(sidecarId);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --------------- Protocol: RPC Dispatch ---------------
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Send an RPC to a connected sidecar.
|
|
484
|
+
* Returns the result, "detached" if initial timeout expires, or throws on failure.
|
|
485
|
+
*/
|
|
486
|
+
async dispatchRPC(
|
|
487
|
+
sidecarId: string,
|
|
488
|
+
method: string,
|
|
489
|
+
params: Record<string, unknown> = {},
|
|
490
|
+
timeouts: RPCTimeouts = DEFAULT_RPC_TIMEOUTS,
|
|
491
|
+
): Promise<unknown> {
|
|
492
|
+
const connection = this.sidecarConnections.get(sidecarId);
|
|
493
|
+
if (!connection) {
|
|
494
|
+
throw new Error(`Sidecar ${sidecarId} is not connected`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const rpcId = generateId();
|
|
498
|
+
const request: RPCRequest = {
|
|
499
|
+
type: 'rpc_request',
|
|
500
|
+
id: rpcId,
|
|
501
|
+
method,
|
|
502
|
+
params,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Send the request over WebSocket
|
|
506
|
+
connection.sendRPC(request);
|
|
507
|
+
|
|
508
|
+
// Track and await result
|
|
509
|
+
return this.rpcTracker.dispatch(rpcId, sidecarId, method, timeouts);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Register a listener for RPC progress events */
|
|
513
|
+
onProgress(listener: (sidecarId: string, rpcId: string, progress: number, message?: string) => void): void {
|
|
514
|
+
this.progressListeners.add(listener);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Register a listener for sidecar events */
|
|
518
|
+
onEvent(listener: (sidecarId: string, event: SidecarEvent) => void): void {
|
|
519
|
+
this.eventListeners.add(listener);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// --------------- Helpers ---------------
|
|
523
|
+
|
|
524
|
+
private toSidecarInfo(record: SidecarRecord): SidecarInfo {
|
|
525
|
+
const conn = this.connected.get(record.id);
|
|
526
|
+
const parsedCapabilities = record.capabilities ? JSON.parse(record.capabilities) : undefined;
|
|
527
|
+
return {
|
|
528
|
+
id: record.id,
|
|
529
|
+
name: record.name,
|
|
530
|
+
enrolled_at: record.enrolled_at,
|
|
531
|
+
last_seen_at: record.last_seen_at,
|
|
532
|
+
status: record.status,
|
|
533
|
+
connected: !!conn,
|
|
534
|
+
hostname: conn?.hostname ?? record.hostname ?? undefined,
|
|
535
|
+
os: conn?.os ?? record.os ?? undefined,
|
|
536
|
+
platform: conn?.platform ?? record.platform ?? undefined,
|
|
537
|
+
capabilities: conn?.capabilities ?? parsedCapabilities,
|
|
538
|
+
unavailable_capabilities: conn?.unavailableCapabilities,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Communication Protocol — Message Types
|
|
3
|
+
*
|
|
4
|
+
* Defines the WebSocket protocol between brain and sidecars.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** RPC state machine */
|
|
8
|
+
export type RPCState = 'pending' | 'detached' | 'completed' | 'failed' | 'timed_out' | 'cancelled';
|
|
9
|
+
|
|
10
|
+
/** Event priority levels */
|
|
11
|
+
export type EventPriority = 'critical' | 'high' | 'normal' | 'low';
|
|
12
|
+
|
|
13
|
+
// ---- Binary data variants ----
|
|
14
|
+
|
|
15
|
+
export interface BinaryDataInline {
|
|
16
|
+
type: 'inline';
|
|
17
|
+
mime_type: string;
|
|
18
|
+
/** Base64-encoded data */
|
|
19
|
+
data: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BinaryDataRef {
|
|
23
|
+
type: 'ref';
|
|
24
|
+
ref_id: string;
|
|
25
|
+
mime_type: string;
|
|
26
|
+
size: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type BinaryData = BinaryDataInline | BinaryDataRef;
|
|
30
|
+
|
|
31
|
+
// ---- Brain → Sidecar ----
|
|
32
|
+
|
|
33
|
+
export interface RPCRequest {
|
|
34
|
+
type: 'rpc_request';
|
|
35
|
+
id: string;
|
|
36
|
+
method: string;
|
|
37
|
+
params: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Sidecar → Brain ----
|
|
41
|
+
|
|
42
|
+
export interface RPCResultPayload {
|
|
43
|
+
rpc_id: string;
|
|
44
|
+
result: unknown;
|
|
45
|
+
error?: undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RPCErrorPayload {
|
|
49
|
+
rpc_id: string;
|
|
50
|
+
result?: undefined;
|
|
51
|
+
error: { code: string; message: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RPCProgressPayload {
|
|
55
|
+
rpc_id: string;
|
|
56
|
+
progress: number;
|
|
57
|
+
message?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SidecarEventPayload {
|
|
61
|
+
[key: string]: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SidecarEvent {
|
|
65
|
+
type: 'rpc_result' | 'rpc_progress' | 'sidecar_event';
|
|
66
|
+
event_type: string;
|
|
67
|
+
timestamp: number;
|
|
68
|
+
payload: RPCResultPayload | RPCErrorPayload | RPCProgressPayload | SidecarEventPayload;
|
|
69
|
+
priority?: EventPriority;
|
|
70
|
+
binary?: BinaryData;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- Timeout configuration ----
|
|
74
|
+
|
|
75
|
+
export interface RPCTimeouts {
|
|
76
|
+
/** Initial timeout before transitioning to DETACHED (ms) */
|
|
77
|
+
initial: number;
|
|
78
|
+
/** Max timeout for detached RPCs (ms) */
|
|
79
|
+
max: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const DEFAULT_RPC_TIMEOUTS: RPCTimeouts = {
|
|
83
|
+
initial: 30_000,
|
|
84
|
+
max: 300_000,
|
|
85
|
+
};
|