@shumkov/orchestra 0.2.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/README.md +24 -0
- package/docs/EXTRACTION.md +83 -0
- package/index.js +57 -0
- package/lib/approvals/store.js +251 -0
- package/lib/async-lock.js +50 -0
- package/lib/canonical-json.js +63 -0
- package/lib/claude-bin.js +247 -0
- package/lib/compaction-warn.js +60 -0
- package/lib/context-usage.js +94 -0
- package/lib/error/classify.js +469 -0
- package/lib/process/attachment-base.js +41 -0
- package/lib/process/channels-bridge-protocol.js +200 -0
- package/lib/process/channels-bridge-server.js +281 -0
- package/lib/process/channels-bridge.mjs +482 -0
- package/lib/process/cli-process.js +4135 -0
- package/lib/process/factory.js +236 -0
- package/lib/process/hook-append.js +72 -0
- package/lib/process/hook-event-tail.js +163 -0
- package/lib/process/hook-settings.js +182 -0
- package/lib/process/process-manager.js +643 -0
- package/lib/process/process.js +216 -0
- package/lib/process/sdk-process.js +891 -0
- package/lib/process-guard.js +297 -0
- package/lib/questions/store.js +107 -0
- package/lib/tmux/log-tail.js +339 -0
- package/lib/tmux/orphan-sweep.js +80 -0
- package/lib/tmux/poll-scheduler.js +111 -0
- package/lib/tmux/startup-gate.js +251 -0
- package/lib/tmux/tmux-runner.js +415 -0
- package/package.json +46 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// provenance: polygram@0.17.11 lib/process/channels-bridge-protocol.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
|
|
2
|
+
/**
|
|
3
|
+
* Bridge ↔ daemon socket protocol — typed schemas.
|
|
4
|
+
*
|
|
5
|
+
* Wire format: newline-delimited JSON over a unix socket per session.
|
|
6
|
+
* Both endpoints (CliProcess and channels-bridge.mjs) speak the same
|
|
7
|
+
* message kinds. This module centralizes the shape so both sides safeParse
|
|
8
|
+
* inbound messages with the same constraints — protecting against malformed
|
|
9
|
+
* payloads silently corrupting pending-state Maps.
|
|
10
|
+
*
|
|
11
|
+
* Adding a new message kind:
|
|
12
|
+
* 1. Define its schema below as `<KindName>MessageSchema`
|
|
13
|
+
* 2. Add it to `AnyDaemonToBridgeMessage` or `AnyBridgeToDaemonMessage`
|
|
14
|
+
* 3. Handle it in the corresponding switch (cli-process.js
|
|
15
|
+
* _onBridgeMsg or channels-bridge.mjs handleDaemonMessage)
|
|
16
|
+
*
|
|
17
|
+
* Validation policy:
|
|
18
|
+
* - Daemon side uses `safeParse` and drops malformed messages with a warn
|
|
19
|
+
* (downgrades silent corruption into observable log)
|
|
20
|
+
* - Bridge side does the same on inbound from daemon
|
|
21
|
+
* - All validation happens AFTER hello-handshake auth (the auth gate is
|
|
22
|
+
* the first line of defense; schema is the second)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const { z } = require('zod');
|
|
28
|
+
|
|
29
|
+
// ─── shared primitives ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const NonEmptyString = z.string().min(1);
|
|
32
|
+
const OptionalString = z.string().optional();
|
|
33
|
+
const ToolCallId = z.string().min(1);
|
|
34
|
+
const RequestId = z.string().min(1);
|
|
35
|
+
const TurnId = z.string().min(1);
|
|
36
|
+
|
|
37
|
+
// ─── bridge → daemon ───────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const HelloSchema = z.object({
|
|
40
|
+
kind: z.literal('hello'),
|
|
41
|
+
session_key: NonEmptyString,
|
|
42
|
+
secret: NonEmptyString,
|
|
43
|
+
}).passthrough();
|
|
44
|
+
|
|
45
|
+
const SessionInitSchema = z.object({
|
|
46
|
+
kind: z.literal('session_init'),
|
|
47
|
+
claude_session_id: z.string(), // may be empty if claude generated one before bridge sees it
|
|
48
|
+
}).passthrough();
|
|
49
|
+
|
|
50
|
+
const ToolCallMessageSchema = z.object({
|
|
51
|
+
kind: z.literal('tool'),
|
|
52
|
+
session: NonEmptyString,
|
|
53
|
+
tool_call_id: ToolCallId,
|
|
54
|
+
// 'ask' (0.12 interactive questions): a blocking tool whose answer rides back
|
|
55
|
+
// on a `question_answer` daemon→bridge message (NOT the fast `tool_ack`); its
|
|
56
|
+
// args are {chat_id, turn_id?, questions:[...]}, not reply-shaped. _dispatchToolCall
|
|
57
|
+
// branches on the name so the reply-only paths (chat_id-mismatch, content-dedup,
|
|
58
|
+
// reply-turn-binding) don't run for it.
|
|
59
|
+
name: z.enum(['reply', 'react', 'edit_message', 'ask']),
|
|
60
|
+
args: z.object({}).passthrough(),
|
|
61
|
+
}).passthrough();
|
|
62
|
+
|
|
63
|
+
const PermRequestMessageSchema = z.object({
|
|
64
|
+
kind: z.literal('perm_req'),
|
|
65
|
+
session: NonEmptyString,
|
|
66
|
+
request_id: RequestId,
|
|
67
|
+
tool_name: NonEmptyString,
|
|
68
|
+
description: z.string(),
|
|
69
|
+
input_preview: z.string(),
|
|
70
|
+
}).passthrough();
|
|
71
|
+
|
|
72
|
+
const PongMessageSchema = z.object({
|
|
73
|
+
kind: z.literal('pong'),
|
|
74
|
+
}).passthrough();
|
|
75
|
+
|
|
76
|
+
// 0.12 Phase 1.6: bridge tells daemon when claude has finished registering
|
|
77
|
+
// the bridge as an MCP server (claude sent its first ListToolsRequest).
|
|
78
|
+
// Polygram's _waitForBridgeHandshake gates on this in addition to hello,
|
|
79
|
+
// eliminating the cold-spawn race (Finding 0.3.A).
|
|
80
|
+
const McpReadyMessageSchema = z.object({
|
|
81
|
+
kind: z.literal('mcp-ready'),
|
|
82
|
+
session: NonEmptyString,
|
|
83
|
+
}).passthrough();
|
|
84
|
+
|
|
85
|
+
const AnyBridgeToDaemonMessage = z.discriminatedUnion('kind', [
|
|
86
|
+
HelloSchema,
|
|
87
|
+
SessionInitSchema,
|
|
88
|
+
ToolCallMessageSchema,
|
|
89
|
+
PermRequestMessageSchema,
|
|
90
|
+
PongMessageSchema,
|
|
91
|
+
McpReadyMessageSchema,
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// ─── daemon → bridge ───────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const HelloAckSchema = z.object({
|
|
97
|
+
kind: z.literal('hello_ack'),
|
|
98
|
+
}).passthrough();
|
|
99
|
+
|
|
100
|
+
const HelloRejectSchema = z.object({
|
|
101
|
+
kind: z.literal('hello_reject'),
|
|
102
|
+
reason: z.string().optional(),
|
|
103
|
+
}).passthrough();
|
|
104
|
+
|
|
105
|
+
const UserMessageSchema = z.object({
|
|
106
|
+
kind: z.literal('user_msg'),
|
|
107
|
+
text: z.string(),
|
|
108
|
+
chat_id: z.union([z.string(), z.number()]).optional(),
|
|
109
|
+
user: OptionalString,
|
|
110
|
+
msg_id: z.union([z.string(), z.number()]).optional(),
|
|
111
|
+
turn_id: OptionalString,
|
|
112
|
+
}).passthrough();
|
|
113
|
+
|
|
114
|
+
const PermVerdictMessageSchema = z.object({
|
|
115
|
+
kind: z.literal('perm_verdict'),
|
|
116
|
+
request_id: RequestId,
|
|
117
|
+
behavior: z.enum(['allow', 'deny']),
|
|
118
|
+
}).passthrough();
|
|
119
|
+
|
|
120
|
+
const ToolAckMessageSchema = z.object({
|
|
121
|
+
kind: z.literal('tool_ack'),
|
|
122
|
+
tool_call_id: ToolCallId,
|
|
123
|
+
ok: z.boolean(),
|
|
124
|
+
error: z.string().optional(),
|
|
125
|
+
// 0.13: the delivered Telegram message_id, surfaced back to claude so it can
|
|
126
|
+
// `edit_message` that bubble for progressive status. Present on a successful
|
|
127
|
+
// `reply`/`edit_message` ack; absent on errors / re-acks.
|
|
128
|
+
message_id: z.union([z.number(), z.string()]).nullish(),
|
|
129
|
+
}).passthrough();
|
|
130
|
+
|
|
131
|
+
// 0.12 interactive questions: carries the user's answer back for an `ask` tool
|
|
132
|
+
// call. Separate from `tool_ack` (which has no payload field and resolves the
|
|
133
|
+
// fast reply round-trip) so a blocking question can return a structured result.
|
|
134
|
+
// `result` is one of {answers:[...]} | {cancelled:true} | {timedout:true}.
|
|
135
|
+
const QuestionAnswerMessageSchema = z.object({
|
|
136
|
+
kind: z.literal('question_answer'),
|
|
137
|
+
tool_call_id: ToolCallId,
|
|
138
|
+
result: z.object({}).passthrough(),
|
|
139
|
+
}).passthrough();
|
|
140
|
+
|
|
141
|
+
const PingMessageSchema = z.object({
|
|
142
|
+
kind: z.literal('ping'),
|
|
143
|
+
}).passthrough();
|
|
144
|
+
|
|
145
|
+
const AnyDaemonToBridgeMessage = z.discriminatedUnion('kind', [
|
|
146
|
+
HelloAckSchema,
|
|
147
|
+
HelloRejectSchema,
|
|
148
|
+
UserMessageSchema,
|
|
149
|
+
PermVerdictMessageSchema,
|
|
150
|
+
ToolAckMessageSchema,
|
|
151
|
+
QuestionAnswerMessageSchema,
|
|
152
|
+
PingMessageSchema,
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
// ─── helpers ──────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse + validate a bridge → daemon message. Returns
|
|
159
|
+
* {ok:true, msg} on success or {ok:false, error} on failure.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} raw — already JSON.parsed object
|
|
162
|
+
* @returns {{ok: true, msg: object}|{ok: false, error: string}}
|
|
163
|
+
*/
|
|
164
|
+
function parseBridgeToDaemonMessage(raw) {
|
|
165
|
+
const r = AnyBridgeToDaemonMessage.safeParse(raw);
|
|
166
|
+
if (r.success) return { ok: true, msg: r.data };
|
|
167
|
+
return { ok: false, error: zodErrorBrief(r.error, raw?.kind) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseDaemonToBridgeMessage(raw) {
|
|
171
|
+
const r = AnyDaemonToBridgeMessage.safeParse(raw);
|
|
172
|
+
if (r.success) return { ok: true, msg: r.data };
|
|
173
|
+
return { ok: false, error: zodErrorBrief(r.error, raw?.kind) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function zodErrorBrief(err, kindHint) {
|
|
177
|
+
const issues = (err?.issues || []).slice(0, 3).map(i => `${i.path.join('.')}: ${i.message}`);
|
|
178
|
+
return `kind=${kindHint || '?'} — ${issues.join('; ') || 'unknown'}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
// schemas (exported for tests + downstream consumers)
|
|
183
|
+
HelloSchema,
|
|
184
|
+
SessionInitSchema,
|
|
185
|
+
ToolCallMessageSchema,
|
|
186
|
+
PermRequestMessageSchema,
|
|
187
|
+
PongMessageSchema,
|
|
188
|
+
AnyBridgeToDaemonMessage,
|
|
189
|
+
HelloAckSchema,
|
|
190
|
+
HelloRejectSchema,
|
|
191
|
+
UserMessageSchema,
|
|
192
|
+
PermVerdictMessageSchema,
|
|
193
|
+
ToolAckMessageSchema,
|
|
194
|
+
QuestionAnswerMessageSchema,
|
|
195
|
+
PingMessageSchema,
|
|
196
|
+
AnyDaemonToBridgeMessage,
|
|
197
|
+
// helpers
|
|
198
|
+
parseBridgeToDaemonMessage,
|
|
199
|
+
parseDaemonToBridgeMessage,
|
|
200
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// provenance: polygram@0.17.11 lib/process/channels-bridge-server.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
|
|
2
|
+
/**
|
|
3
|
+
* ChannelsBridgeServer — per-session unix-socket server for the bridge
|
|
4
|
+
* subprocess to connect back to.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from CliProcess (M1 refactor) so the socket lifecycle —
|
|
7
|
+
* listen with restrictive umask, accept ONE bridge, hello-handshake auth,
|
|
8
|
+
* line-delimited JSON I/O, schema validation, single-bridge-per-session
|
|
9
|
+
* enforcement, clean teardown — lives in one focused class instead of
|
|
10
|
+
* sprawling across CliProcess.
|
|
11
|
+
*
|
|
12
|
+
* Owns:
|
|
13
|
+
* - net.Server lifecycle (listen / close)
|
|
14
|
+
* - socket file mode (0o600 via umask wrap + defensive chmod)
|
|
15
|
+
* - bridge connection state (single connection accepted)
|
|
16
|
+
* - hello-handshake secret verification
|
|
17
|
+
* - line-buffer + JSON parse + zod schema validation (channels-bridge-protocol)
|
|
18
|
+
*
|
|
19
|
+
* Does NOT own:
|
|
20
|
+
* - protocol semantics (tool routing, perm relay, turn lifecycle) — those
|
|
21
|
+
* stay in CliProcess, which subscribes to the events this class emits
|
|
22
|
+
* - claude/bridge process lifecycle
|
|
23
|
+
*
|
|
24
|
+
* Event surface (EventEmitter):
|
|
25
|
+
* 'bridge-ready' — daemon-side handshake (hello + session_init) complete
|
|
26
|
+
* 'mcp-ready' — claude-side MCP-server registration complete (first
|
|
27
|
+
* ListToolsRequest received from claude). 0.12 P1.6
|
|
28
|
+
* cold-spawn race fix — see channels-bridge.mjs.
|
|
29
|
+
* 'bridge-message', msg — every validated bridge→daemon message (post-auth)
|
|
30
|
+
* 'bridge-disconnected' — single-bridge connection closed
|
|
31
|
+
* 'error', err — socket-level errors (rare; non-fatal)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const crypto = require('node:crypto');
|
|
37
|
+
const EventEmitter = require('node:events');
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const net = require('node:net');
|
|
40
|
+
|
|
41
|
+
const { parseBridgeToDaemonMessage } = require('./channels-bridge-protocol');
|
|
42
|
+
|
|
43
|
+
class ChannelsBridgeServer extends EventEmitter {
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {string} opts.sockPath
|
|
47
|
+
* @param {string} opts.sessionKey — bridge must echo this in hello
|
|
48
|
+
* @param {string} opts.sockSecret — bridge must present this in hello
|
|
49
|
+
* @param {object} [opts.logger=console]
|
|
50
|
+
* @param {string} [opts.label='channels-bridge-server']
|
|
51
|
+
*/
|
|
52
|
+
constructor({ sockPath, sessionKey, sockSecret, logger = console, label = 'channels-bridge-server' } = {}) {
|
|
53
|
+
super();
|
|
54
|
+
if (!sockPath) throw new TypeError('ChannelsBridgeServer: sockPath required');
|
|
55
|
+
if (!sessionKey) throw new TypeError('ChannelsBridgeServer: sessionKey required');
|
|
56
|
+
if (!sockSecret) throw new TypeError('ChannelsBridgeServer: sockSecret required');
|
|
57
|
+
this.sockPath = sockPath;
|
|
58
|
+
this.sessionKey = sessionKey;
|
|
59
|
+
this.sockSecret = sockSecret;
|
|
60
|
+
this.logger = logger;
|
|
61
|
+
this.label = label;
|
|
62
|
+
|
|
63
|
+
this.server = null;
|
|
64
|
+
this.conn = null; // current bridge connection (one per session)
|
|
65
|
+
this.authenticated = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bind + listen on the unix socket with restrictive umask so the inode is
|
|
70
|
+
* created with mode 0o600 from birth (P1 #9 TOCTOU mitigation). Defensive
|
|
71
|
+
* chmod runs in the listen callback as belt-and-suspenders.
|
|
72
|
+
*
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
async listen() {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
try { fs.unlinkSync(this.sockPath); } catch {}
|
|
78
|
+
|
|
79
|
+
this.server = net.createServer({ allowHalfOpen: false }, conn => this._onConnect(conn));
|
|
80
|
+
this.server.on('error', err => {
|
|
81
|
+
this.logger.error?.(`[${this.label}] socket error: ${err.message}`);
|
|
82
|
+
this.emit('error', err);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const prevUmask = process.umask(0o077);
|
|
86
|
+
this.server.listen(this.sockPath, err => {
|
|
87
|
+
process.umask(prevUmask);
|
|
88
|
+
if (err) return reject(err);
|
|
89
|
+
try {
|
|
90
|
+
fs.chmodSync(this.sockPath, 0o600);
|
|
91
|
+
} catch (chmodErr) {
|
|
92
|
+
return reject(new Error(`failed to chmod 0600 ${this.sockPath}: ${chmodErr.message}`));
|
|
93
|
+
}
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write a daemon→bridge message. Drops silently (with warn) if no live
|
|
101
|
+
* connection. Returns true if write was attempted, false if dropped.
|
|
102
|
+
*/
|
|
103
|
+
writeMessage(obj) {
|
|
104
|
+
if (!this.conn || this.conn.destroyed) {
|
|
105
|
+
this.logger.warn?.(`[${this.label}] writeMessage — no live connection (kind=${obj?.kind})`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
this.conn.write(JSON.stringify(obj) + '\n');
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
this.logger.warn?.(`[${this.label}] socket write failed: ${err.message}`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Forcibly destroy the bridge connection (used by the pong watchdog to
|
|
119
|
+
* trigger the normal close→drain→respawn chain).
|
|
120
|
+
*/
|
|
121
|
+
destroyConnection() {
|
|
122
|
+
if (this.conn) try { this.conn.destroy(); } catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Tear down the server + close the connection + unlink the socket file.
|
|
127
|
+
* Idempotent.
|
|
128
|
+
*/
|
|
129
|
+
async close() {
|
|
130
|
+
if (this.conn) {
|
|
131
|
+
try { this.conn.end(); } catch {}
|
|
132
|
+
this.conn = null;
|
|
133
|
+
}
|
|
134
|
+
if (this.server) {
|
|
135
|
+
await new Promise(resolve => this.server.close(() => resolve()));
|
|
136
|
+
this.server = null;
|
|
137
|
+
}
|
|
138
|
+
try { fs.unlinkSync(this.sockPath); } catch {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── private ──────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
_onConnect(conn) {
|
|
144
|
+
// Single bridge per session — reject second connections.
|
|
145
|
+
if (this.conn && !this.conn.destroyed) {
|
|
146
|
+
this.logger.warn?.(`[${this.label}] extra bridge connection rejected`);
|
|
147
|
+
try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'already-connected' }) + '\n'); } catch {}
|
|
148
|
+
conn.end();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.conn = conn;
|
|
152
|
+
let buf = '';
|
|
153
|
+
let authenticated = false;
|
|
154
|
+
|
|
155
|
+
// utf8 setEncoding reassembles multibyte sequences split across data
|
|
156
|
+
// events (Node's internal StringDecoder). Without it, `buf += chunk`
|
|
157
|
+
// decodes each Buffer chunk independently and a char straddling the
|
|
158
|
+
// ~64KB chunk boundary becomes U+FFFD — silent corruption of large
|
|
159
|
+
// replies. Same class as the log-tail.js StringDecoder fix.
|
|
160
|
+
conn.setEncoding('utf8');
|
|
161
|
+
conn.on('data', chunk => {
|
|
162
|
+
buf += chunk;
|
|
163
|
+
let nl;
|
|
164
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
165
|
+
const line = buf.slice(0, nl);
|
|
166
|
+
buf = buf.slice(nl + 1);
|
|
167
|
+
if (!line.trim()) continue;
|
|
168
|
+
let raw;
|
|
169
|
+
try { raw = JSON.parse(line); }
|
|
170
|
+
catch {
|
|
171
|
+
this.logger.warn?.(`[${this.label}] bad json from bridge: ${line.slice(0, 100)}`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!authenticated) {
|
|
176
|
+
// Review F#7: harden the hello-handshake.
|
|
177
|
+
// 1. timingSafeEqual for the secret compare so a same-uid
|
|
178
|
+
// attacker can't byte-by-byte probe via response-timing.
|
|
179
|
+
// 2. ROTATE the secret after first successful auth (set to
|
|
180
|
+
// null) so a stale WATER_SOCK_SECRET leaked via
|
|
181
|
+
// /proc/<pid>/environ can't replay against this
|
|
182
|
+
// CliProcess after the legit bridge disconnects.
|
|
183
|
+
// The bridge process is one-shot per spawn anyway (it
|
|
184
|
+
// exits on socket close — see channels-bridge.mjs:109),
|
|
185
|
+
// so legitimate re-auth within one CliProcess
|
|
186
|
+
// instance never happens — only a hijacker would.
|
|
187
|
+
const verdict = this._verifyHelloAuth(raw);
|
|
188
|
+
if (verdict.ok) {
|
|
189
|
+
authenticated = true;
|
|
190
|
+
this.authenticated = true;
|
|
191
|
+
this.sockSecret = null; // invalidate — single-shot per instance
|
|
192
|
+
try { conn.write(JSON.stringify({ kind: 'hello_ack' }) + '\n'); } catch {}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
this.logger.warn?.(`[${this.label}] hello rejected — reason=${verdict.reason}`);
|
|
196
|
+
try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'auth' }) + '\n'); } catch {}
|
|
197
|
+
conn.end();
|
|
198
|
+
this.conn = null;
|
|
199
|
+
this.authenticated = false;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Post-auth: validate against schema, emit on success, drop+warn on fail.
|
|
204
|
+
const parsed = parseBridgeToDaemonMessage(raw);
|
|
205
|
+
if (!parsed.ok) {
|
|
206
|
+
this.logger.warn?.(
|
|
207
|
+
`[${this.label}] bridge msg schema invalid — ${parsed.error} — dropping`,
|
|
208
|
+
);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (parsed.msg.kind === 'session_init') {
|
|
212
|
+
// session_init also signals the bridge is fully ready. Emit
|
|
213
|
+
// bridge-ready BEFORE the bridge-message so listeners that gate on
|
|
214
|
+
// bridge-ready can subscribe to the message stream.
|
|
215
|
+
this.emit('session-init', parsed.msg);
|
|
216
|
+
this.emit('bridge-ready');
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (parsed.msg.kind === 'mcp-ready') {
|
|
220
|
+
// 0.12 Phase 1.6: bridge signals that claude has finished
|
|
221
|
+
// registering it as an MCP server. Polygram gates send() on this
|
|
222
|
+
// (Finding 0.3.A — cold-spawn race).
|
|
223
|
+
this.emit('mcp-ready', parsed.msg);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
this.emit('bridge-message', parsed.msg);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
conn.on('close', () => {
|
|
231
|
+
if (this.conn === conn) {
|
|
232
|
+
this.conn = null;
|
|
233
|
+
this.authenticated = false;
|
|
234
|
+
this.emit('bridge-disconnected');
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
conn.on('error', err => {
|
|
239
|
+
this.logger.warn?.(`[${this.label}] bridge conn error: ${err.message}`);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Review F#7: hello-handshake verification, extracted as a pure method so it
|
|
245
|
+
* can be exercised in isolation. Returns `{ ok: true }` on accept or
|
|
246
|
+
* `{ ok: false, reason }` on reject. Uses crypto.timingSafeEqual for the
|
|
247
|
+
* secret compare and refuses if this.sockSecret has already been consumed
|
|
248
|
+
* (post-auth rotation).
|
|
249
|
+
*
|
|
250
|
+
* @param {object} raw — parsed bridge→daemon hello payload
|
|
251
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
252
|
+
*/
|
|
253
|
+
_verifyHelloAuth(raw) {
|
|
254
|
+
if (this.sockSecret == null) {
|
|
255
|
+
return { ok: false, reason: 'secret-consumed' };
|
|
256
|
+
}
|
|
257
|
+
if (!raw || raw.kind !== 'hello') {
|
|
258
|
+
return { ok: false, reason: 'not-hello' };
|
|
259
|
+
}
|
|
260
|
+
if (raw.session_key !== this.sessionKey) {
|
|
261
|
+
return { ok: false, reason: 'wrong-session-key' };
|
|
262
|
+
}
|
|
263
|
+
if (typeof raw.secret !== 'string' || raw.secret.length === 0) {
|
|
264
|
+
return { ok: false, reason: 'no-secret' };
|
|
265
|
+
}
|
|
266
|
+
const a = Buffer.from(raw.secret, 'utf8');
|
|
267
|
+
const b = Buffer.from(this.sockSecret, 'utf8');
|
|
268
|
+
if (a.length !== b.length) {
|
|
269
|
+
// timingSafeEqual requires equal-length inputs; length mismatch is a
|
|
270
|
+
// wrong-secret signal but constant-time compares MUST short-circuit
|
|
271
|
+
// here (otherwise we'd leak the secret's length).
|
|
272
|
+
return { ok: false, reason: 'wrong-secret' };
|
|
273
|
+
}
|
|
274
|
+
if (!crypto.timingSafeEqual(a, b)) {
|
|
275
|
+
return { ok: false, reason: 'wrong-secret' };
|
|
276
|
+
}
|
|
277
|
+
return { ok: true };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { ChannelsBridgeServer };
|