@bondsmith/bondsmithd 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/README.md +139 -0
- package/dist/index.js +2840 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2840 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { hostname as hostname2 } from "os";
|
|
7
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
// ../../packages/local-agent-protocol/src/session.ts
|
|
11
|
+
var SESSION_STATUSES = ["running", "exited"];
|
|
12
|
+
|
|
13
|
+
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
14
|
+
import { z as z2 } from "zod";
|
|
15
|
+
|
|
16
|
+
// ../../packages/local-agent-protocol/src/connection-challenge.ts
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
var CONNECTION_CHALLENGE_PURPOSE = "bondsmith.connection-handshake";
|
|
19
|
+
var connectionChallengeTranscriptSchema = z.object({
|
|
20
|
+
version: z.literal(1),
|
|
21
|
+
purpose: z.literal(CONNECTION_CHALLENGE_PURPOSE),
|
|
22
|
+
// Generated by the daemon per-socket, at connection time — never
|
|
23
|
+
// client-supplied, never reused across connections or reconnects.
|
|
24
|
+
connectionId: z.string().min(1),
|
|
25
|
+
// Fresh, cryptographically random, single-use — a second handshake
|
|
26
|
+
// attempt (even with a validly-signed transcript for the same
|
|
27
|
+
// connectionId) is rejected by the daemon outright.
|
|
28
|
+
challenge: z.string().min(1),
|
|
29
|
+
daemonFingerprint: z.string().min(1),
|
|
30
|
+
controllerId: z.string().min(1),
|
|
31
|
+
// An assertion the browser makes, not independent account
|
|
32
|
+
// authentication — the daemon has no account-token layer to verify this
|
|
33
|
+
// against (unchanged from ADR-012's existing caveat on CommandEnvelope's
|
|
34
|
+
// own accountId field).
|
|
35
|
+
accountId: z.number().int().positive()
|
|
36
|
+
});
|
|
37
|
+
var signedConnectionChallengeSchema = z.object({
|
|
38
|
+
transcript: connectionChallengeTranscriptSchema,
|
|
39
|
+
signature: z.string().min(1)
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
43
|
+
var MAX_INSERT_TEXT_BYTES = 32 * 1024;
|
|
44
|
+
var localSessionSchema = z2.object({
|
|
45
|
+
id: z2.string(),
|
|
46
|
+
title: z2.string(),
|
|
47
|
+
command: z2.string(),
|
|
48
|
+
cwd: z2.string(),
|
|
49
|
+
tmuxSessionName: z2.string(),
|
|
50
|
+
status: z2.enum(SESSION_STATUSES),
|
|
51
|
+
startedAt: z2.string(),
|
|
52
|
+
branch: z2.string().nullable(),
|
|
53
|
+
repositoryFullName: z2.string().nullable(),
|
|
54
|
+
lastActivityAt: z2.string().nullable(),
|
|
55
|
+
changedFileCount: z2.number().int().nonnegative().nullable()
|
|
56
|
+
});
|
|
57
|
+
var workspaceFileEntrySchema = z2.object({
|
|
58
|
+
path: z2.string(),
|
|
59
|
+
previousPath: z2.string().optional(),
|
|
60
|
+
status: z2.enum(["added", "modified", "deleted", "renamed", "copied", "untracked"]),
|
|
61
|
+
staged: z2.boolean(),
|
|
62
|
+
additions: z2.number().int().nonnegative().nullable(),
|
|
63
|
+
deletions: z2.number().int().nonnegative().nullable(),
|
|
64
|
+
binary: z2.boolean()
|
|
65
|
+
});
|
|
66
|
+
var workspaceStatusSchema = z2.object({
|
|
67
|
+
isGitRepo: z2.boolean(),
|
|
68
|
+
branch: z2.string().nullable(),
|
|
69
|
+
files: z2.array(workspaceFileEntrySchema)
|
|
70
|
+
});
|
|
71
|
+
var workspaceFileDiffSchema = z2.object({
|
|
72
|
+
path: z2.string(),
|
|
73
|
+
staged: z2.boolean(),
|
|
74
|
+
patch: z2.string().nullable(),
|
|
75
|
+
binary: z2.boolean()
|
|
76
|
+
});
|
|
77
|
+
var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
78
|
+
z2.object({ type: z2.literal("session.list") }),
|
|
79
|
+
z2.object({
|
|
80
|
+
type: z2.literal("session.create"),
|
|
81
|
+
title: z2.string().min(1).max(200),
|
|
82
|
+
cwd: z2.string().min(1),
|
|
83
|
+
command: z2.string().min(1).max(2e3),
|
|
84
|
+
requestId: z2.string().min(1).max(200).optional()
|
|
85
|
+
}),
|
|
86
|
+
z2.object({ type: z2.literal("session.attach"), sessionId: z2.string() }),
|
|
87
|
+
z2.object({
|
|
88
|
+
type: z2.literal("session.input"),
|
|
89
|
+
sessionId: z2.string(),
|
|
90
|
+
data: z2.string()
|
|
91
|
+
}),
|
|
92
|
+
z2.object({
|
|
93
|
+
type: z2.literal("session.insertText"),
|
|
94
|
+
sessionId: z2.string(),
|
|
95
|
+
text: z2.string().max(MAX_INSERT_TEXT_BYTES)
|
|
96
|
+
}),
|
|
97
|
+
z2.object({
|
|
98
|
+
type: z2.literal("session.resize"),
|
|
99
|
+
sessionId: z2.string(),
|
|
100
|
+
cols: z2.number().int().positive(),
|
|
101
|
+
rows: z2.number().int().positive()
|
|
102
|
+
}),
|
|
103
|
+
z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
|
|
104
|
+
z2.object({
|
|
105
|
+
type: z2.literal("workspace.getStatus"),
|
|
106
|
+
sessionId: z2.string(),
|
|
107
|
+
requestId: z2.string().min(1).max(200)
|
|
108
|
+
}),
|
|
109
|
+
z2.object({
|
|
110
|
+
type: z2.literal("workspace.getDiff"),
|
|
111
|
+
sessionId: z2.string(),
|
|
112
|
+
path: z2.string().min(1).max(4096),
|
|
113
|
+
staged: z2.boolean(),
|
|
114
|
+
requestId: z2.string().min(1).max(200)
|
|
115
|
+
}),
|
|
116
|
+
z2.object({ type: z2.literal("daemon.getInfo") }),
|
|
117
|
+
// Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
|
|
118
|
+
// unsigned: the requesting browser has no trusted key yet to sign with.
|
|
119
|
+
// Authority comes entirely from explicit daemon-local user approval, not
|
|
120
|
+
// from anything in this message.
|
|
121
|
+
z2.object({
|
|
122
|
+
type: z2.literal("controller.requestEnrollment"),
|
|
123
|
+
publicKeyPem: z2.string().min(1),
|
|
124
|
+
label: z2.string().min(1).max(200)
|
|
125
|
+
}),
|
|
126
|
+
// ADR-013/BD-477: the browser's signed response to connection.challenge —
|
|
127
|
+
// carries its own signed transcript, not a CommandEnvelope, so it's a
|
|
128
|
+
// distinct BrowserMessage variant rather than a SIGNABLE_ACTIONS entry.
|
|
129
|
+
z2.object({
|
|
130
|
+
type: z2.literal("connection.authenticate"),
|
|
131
|
+
signed: z2.object({
|
|
132
|
+
transcript: connectionChallengeTranscriptSchema,
|
|
133
|
+
signature: z2.string().min(1)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
]);
|
|
137
|
+
var connectionAuthenticationFailureReasonSchema = z2.enum([
|
|
138
|
+
"bad_signature",
|
|
139
|
+
"untrusted_controller",
|
|
140
|
+
"wrong_daemon",
|
|
141
|
+
"wrong_connection",
|
|
142
|
+
"challenge_expired",
|
|
143
|
+
"already_authenticated",
|
|
144
|
+
"rate_limited"
|
|
145
|
+
]);
|
|
146
|
+
var connectionRejectionReasonSchema = z2.enum(["origin_not_allowed", "token_invalid"]);
|
|
147
|
+
var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
148
|
+
z2.object({ type: z2.literal("session.list"), sessions: z2.array(localSessionSchema) }),
|
|
149
|
+
z2.object({
|
|
150
|
+
type: z2.literal("session.created"),
|
|
151
|
+
session: localSessionSchema,
|
|
152
|
+
requestId: z2.string().optional()
|
|
153
|
+
}),
|
|
154
|
+
z2.object({
|
|
155
|
+
type: z2.literal("terminal.output"),
|
|
156
|
+
sessionId: z2.string(),
|
|
157
|
+
data: z2.string()
|
|
158
|
+
}),
|
|
159
|
+
z2.object({ type: z2.literal("session.updated"), session: localSessionSchema }),
|
|
160
|
+
// Ack for a successful session.attach — the browser must wait for this
|
|
161
|
+
// before wiring up terminal input, or a keystroke sent before the daemon
|
|
162
|
+
// actually finishes attaching gets rejected as "not attached to
|
|
163
|
+
// session" (observed during a reconnect: attachSession() was
|
|
164
|
+
// fire-and-forget, so terminal.onData could fire before attach
|
|
165
|
+
// completed).
|
|
166
|
+
z2.object({ type: z2.literal("session.attached"), sessionId: z2.string() }),
|
|
167
|
+
z2.object({ type: z2.literal("error"), message: z2.string() }),
|
|
168
|
+
z2.object({
|
|
169
|
+
type: z2.literal("workspace.status"),
|
|
170
|
+
sessionId: z2.string(),
|
|
171
|
+
requestId: z2.string(),
|
|
172
|
+
status: workspaceStatusSchema
|
|
173
|
+
}),
|
|
174
|
+
z2.object({
|
|
175
|
+
type: z2.literal("workspace.diff"),
|
|
176
|
+
sessionId: z2.string(),
|
|
177
|
+
requestId: z2.string(),
|
|
178
|
+
diff: workspaceFileDiffSchema
|
|
179
|
+
}),
|
|
180
|
+
z2.object({
|
|
181
|
+
type: z2.literal("daemon.info"),
|
|
182
|
+
hostname: z2.string(),
|
|
183
|
+
daemonFingerprint: z2.string()
|
|
184
|
+
}),
|
|
185
|
+
z2.object({
|
|
186
|
+
type: z2.literal("controller.enrollmentPending"),
|
|
187
|
+
fingerprint: z2.string()
|
|
188
|
+
}),
|
|
189
|
+
z2.object({
|
|
190
|
+
type: z2.literal("controller.enrollmentRejected"),
|
|
191
|
+
reason: z2.enum(["not_interactive", "rate_limited"])
|
|
192
|
+
}),
|
|
193
|
+
z2.object({
|
|
194
|
+
type: z2.literal("connection.challenge"),
|
|
195
|
+
connectionId: z2.string().min(1),
|
|
196
|
+
challenge: z2.string().min(1),
|
|
197
|
+
daemonFingerprint: z2.string().min(1),
|
|
198
|
+
hostname: z2.string(),
|
|
199
|
+
expiresAt: z2.number().int().positive()
|
|
200
|
+
}),
|
|
201
|
+
z2.object({
|
|
202
|
+
type: z2.literal("connection.authenticated"),
|
|
203
|
+
connectionId: z2.string().min(1)
|
|
204
|
+
}),
|
|
205
|
+
z2.object({
|
|
206
|
+
type: z2.literal("connection.authenticationFailed"),
|
|
207
|
+
reason: connectionAuthenticationFailureReasonSchema
|
|
208
|
+
}),
|
|
209
|
+
z2.object({
|
|
210
|
+
type: z2.literal("connection.rejected"),
|
|
211
|
+
reason: connectionRejectionReasonSchema
|
|
212
|
+
})
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
// ../../packages/local-agent-protocol/src/validation.ts
|
|
216
|
+
var UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
217
|
+
var TMUX_SESSION_NAME_RE = /^bondsmith-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
218
|
+
function isValidSessionId(id) {
|
|
219
|
+
return UUID_V4_RE.test(id);
|
|
220
|
+
}
|
|
221
|
+
function tmuxSessionNameFor(id) {
|
|
222
|
+
return `bondsmith-${id}`;
|
|
223
|
+
}
|
|
224
|
+
function isBondsmithTmuxSessionName(name) {
|
|
225
|
+
return TMUX_SESSION_NAME_RE.test(name);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ../../packages/local-agent-protocol/src/fingerprint.ts
|
|
229
|
+
async function formatDaemonFingerprint(publicKeyPem) {
|
|
230
|
+
const data = new TextEncoder().encode(publicKeyPem);
|
|
231
|
+
const digestBuffer = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
232
|
+
const digest = Array.from(new Uint8Array(digestBuffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
233
|
+
const truncated = digest.slice(0, 32);
|
|
234
|
+
return truncated.match(/.{1,4}/g).join("-");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ../../packages/local-agent-protocol/src/controller-id.ts
|
|
238
|
+
async function controllerIdFromPublicKeyPem(publicKeyPem) {
|
|
239
|
+
const data = new TextEncoder().encode(publicKeyPem);
|
|
240
|
+
const digestBuffer = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
241
|
+
const hex = Array.from(new Uint8Array(digestBuffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
242
|
+
return hex.slice(0, 32);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ../../packages/local-agent-protocol/src/canonicalize.ts
|
|
246
|
+
function canonicalize(value) {
|
|
247
|
+
return serialize(value);
|
|
248
|
+
}
|
|
249
|
+
function serialize(value) {
|
|
250
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
|
251
|
+
return JSON.stringify(value);
|
|
252
|
+
}
|
|
253
|
+
if (typeof value === "string") {
|
|
254
|
+
return JSON.stringify(value);
|
|
255
|
+
}
|
|
256
|
+
if (value === void 0) {
|
|
257
|
+
throw new TypeError("canonicalize: undefined is not representable");
|
|
258
|
+
}
|
|
259
|
+
if (Array.isArray(value)) {
|
|
260
|
+
return `[${value.map(serialize).join(",")}]`;
|
|
261
|
+
}
|
|
262
|
+
if (typeof value === "object") {
|
|
263
|
+
const keys = Object.keys(value).sort();
|
|
264
|
+
const entries = keys.map((key) => {
|
|
265
|
+
const entryValue = value[key];
|
|
266
|
+
return `${JSON.stringify(key)}:${serialize(entryValue)}`;
|
|
267
|
+
});
|
|
268
|
+
return `{${entries.join(",")}}`;
|
|
269
|
+
}
|
|
270
|
+
throw new TypeError(`canonicalize: unsupported value type ${typeof value}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ../../packages/local-agent-protocol/src/envelope.ts
|
|
274
|
+
import { z as z3 } from "zod";
|
|
275
|
+
var SIGNABLE_ACTIONS = [
|
|
276
|
+
"session.create",
|
|
277
|
+
"session.attach",
|
|
278
|
+
"session.input",
|
|
279
|
+
"session.insertText",
|
|
280
|
+
"session.resize",
|
|
281
|
+
"session.stop",
|
|
282
|
+
"session.list",
|
|
283
|
+
"workspace.getStatus",
|
|
284
|
+
"workspace.getDiff"
|
|
285
|
+
];
|
|
286
|
+
var envelopeSchema = z3.object({
|
|
287
|
+
version: z3.literal(1),
|
|
288
|
+
controllerId: z3.string().min(1),
|
|
289
|
+
accountId: z3.number().int().positive(),
|
|
290
|
+
daemonId: z3.string().min(1),
|
|
291
|
+
// The specific WebSocket connection this envelope was signed for (ADR-013,
|
|
292
|
+
// BD-477) — issued by the daemon during that connection's handshake
|
|
293
|
+
// (connection-challenge.ts), never reused across connections. `sequence`
|
|
294
|
+
// below is only ever compared against other envelopes carrying the SAME
|
|
295
|
+
// connectionId; an envelope for a stale or foreign connectionId is
|
|
296
|
+
// rejected before sequence is even considered (verify-command.ts).
|
|
297
|
+
connectionId: z3.string().min(1),
|
|
298
|
+
sessionId: z3.string().nullable(),
|
|
299
|
+
action: z3.enum(SIGNABLE_ACTIONS),
|
|
300
|
+
payload: z3.unknown(),
|
|
301
|
+
issuedAt: z3.number().int().positive(),
|
|
302
|
+
expiresAt: z3.number().int().positive(),
|
|
303
|
+
// Strictly increasing WITHIN one connectionId's sequence space, starting
|
|
304
|
+
// at 1 for every new authenticated connection — never a persisted,
|
|
305
|
+
// global-per-controller counter (that design broke on page reload; see
|
|
306
|
+
// ADR-013). Verified and advanced in-memory, per connection, never
|
|
307
|
+
// written to disk.
|
|
308
|
+
sequence: z3.number().int().positive()
|
|
309
|
+
});
|
|
310
|
+
var signedCommandSchema = z3.object({
|
|
311
|
+
envelope: envelopeSchema,
|
|
312
|
+
signature: z3.string().min(1)
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ../../packages/local-agent-protocol/src/pairing-challenge.ts
|
|
316
|
+
import { z as z4 } from "zod";
|
|
317
|
+
var PAIRING_CHALLENGE_PURPOSE = "bondsmith.initial-controller-pairing";
|
|
318
|
+
var pairingChallengeTranscriptSchema = z4.object({
|
|
319
|
+
version: z4.literal(1),
|
|
320
|
+
purpose: z4.literal(PAIRING_CHALLENGE_PURPOSE),
|
|
321
|
+
pairingAttemptId: z4.string().min(1),
|
|
322
|
+
challenge: z4.string().min(1),
|
|
323
|
+
daemonFingerprint: z4.string().min(1),
|
|
324
|
+
controllerPublicKeyPem: z4.string().min(1),
|
|
325
|
+
accountId: z4.number().int().positive()
|
|
326
|
+
});
|
|
327
|
+
var signedPairingChallengeSchema = z4.object({
|
|
328
|
+
transcript: pairingChallengeTranscriptSchema,
|
|
329
|
+
signature: z4.string().min(1)
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ../../packages/local-agent-protocol/src/relay-register-transcript.ts
|
|
333
|
+
import { z as z5 } from "zod";
|
|
334
|
+
var RELAY_REGISTER_PURPOSE = "bondsmith.relay.register";
|
|
335
|
+
var relayRegisterTranscriptSchema = z5.object({
|
|
336
|
+
version: z5.literal(1),
|
|
337
|
+
purpose: z5.literal(RELAY_REGISTER_PURPOSE),
|
|
338
|
+
// Relay-issued, single-use — a second registration attempt against the
|
|
339
|
+
// same challenge is meaningless since a daemon reconnecting always gets
|
|
340
|
+
// a fresh challenge from a fresh relay.challenge message; this field
|
|
341
|
+
// exists so a captured signed transcript can't be replayed later against
|
|
342
|
+
// a still-open relay listener.
|
|
343
|
+
challenge: z5.string().min(1),
|
|
344
|
+
daemonFingerprint: z5.string().min(1),
|
|
345
|
+
issuedAt: z5.number().int().positive(),
|
|
346
|
+
expiresAt: z5.number().int().positive(),
|
|
347
|
+
// Binds this transcript to one specific relay deployment (e.g.
|
|
348
|
+
// "relay.bondsmith.dev") — same reasoning as CommandEnvelope's own
|
|
349
|
+
// daemonId field binding a command to one specific daemon; prevents a
|
|
350
|
+
// transcript signed for one relay audience from being replayed against
|
|
351
|
+
// a different one.
|
|
352
|
+
relayAudience: z5.string().min(1)
|
|
353
|
+
});
|
|
354
|
+
var signedRelayRegisterSchema = z5.object({
|
|
355
|
+
// The daemon's full public key PEM, transmitted alongside the transcript
|
|
356
|
+
// so the relay can independently derive daemonFingerprint from it and
|
|
357
|
+
// verify the signature against it — see this file's own doc comment for
|
|
358
|
+
// why this must travel on the wire rather than being assumed.
|
|
359
|
+
daemonPublicKeyPem: z5.string().min(1),
|
|
360
|
+
transcript: relayRegisterTranscriptSchema,
|
|
361
|
+
signature: z5.string().min(1)
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ../../packages/local-agent-protocol/src/relay-ticket.ts
|
|
365
|
+
import { z as z6 } from "zod";
|
|
366
|
+
var RELAY_TICKET_PURPOSE = "bondsmith.relay.attach";
|
|
367
|
+
var relayTicketSchema = z6.object({
|
|
368
|
+
version: z6.literal(1),
|
|
369
|
+
purpose: z6.literal(RELAY_TICKET_PURPOSE),
|
|
370
|
+
keyId: z6.string().min(1),
|
|
371
|
+
accountId: z6.number().int().positive(),
|
|
372
|
+
userId: z6.string().min(1),
|
|
373
|
+
daemonFingerprint: z6.string().min(1),
|
|
374
|
+
issuedAt: z6.number().int().positive(),
|
|
375
|
+
// Deliberately short — single-digit minutes, enough for one attach
|
|
376
|
+
// attempt, not a durable credential.
|
|
377
|
+
expiresAt: z6.number().int().positive(),
|
|
378
|
+
// Crypto-random, single-use — consumed atomically by the relay on
|
|
379
|
+
// successful attach (an in-memory, TTL-expiring record; see the relay
|
|
380
|
+
// architecture doc's own correction against an unbounded Set). A
|
|
381
|
+
// replayed ticket (same nonce, still within its own expiry window)
|
|
382
|
+
// fails closed.
|
|
383
|
+
nonce: z6.string().min(1)
|
|
384
|
+
});
|
|
385
|
+
var signedRelayTicketSchema = z6.object({
|
|
386
|
+
ticket: relayTicketSchema,
|
|
387
|
+
signature: z6.string().min(1)
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ../../packages/local-agent-protocol/src/relay-messages.ts
|
|
391
|
+
import { z as z7 } from "zod";
|
|
392
|
+
var relayFailureReasonSchema = z7.enum([
|
|
393
|
+
// relay.registrationFailed reasons (daemon side)
|
|
394
|
+
"bad_signature",
|
|
395
|
+
"wrong_fingerprint",
|
|
396
|
+
"wrong_purpose",
|
|
397
|
+
"challenge_expired",
|
|
398
|
+
"challenge_invalid",
|
|
399
|
+
"wrong_audience",
|
|
400
|
+
"untrusted_daemon",
|
|
401
|
+
// relay.attachFailed reasons (browser side)
|
|
402
|
+
"invalid_origin",
|
|
403
|
+
"unknown_key_id",
|
|
404
|
+
"ticket_invalid",
|
|
405
|
+
"ticket_expired",
|
|
406
|
+
"ticket_reused",
|
|
407
|
+
"daemon_offline",
|
|
408
|
+
"session_open_timeout",
|
|
409
|
+
// BD-485: the target daemon's CURRENT registration already holds
|
|
410
|
+
// MAX_SESSIONS_PER_REGISTRATION concurrent relay sessions — an
|
|
411
|
+
// independent, relay-side resource limit, not a re-statement of the
|
|
412
|
+
// daemon's own client-side cap (see session-registry.ts's doc comment).
|
|
413
|
+
"session_limit_exceeded"
|
|
414
|
+
]);
|
|
415
|
+
var relayFrameSchema = z7.object({
|
|
416
|
+
relaySessionId: z7.string().min(1),
|
|
417
|
+
// Opaque to the relay — the existing inner BrowserMessage/DaemonMessage
|
|
418
|
+
// JSON, unparsed and unvalidated by anything in this file. Typed as
|
|
419
|
+
// unknown here deliberately; callers narrow it via browserMessageSchema/
|
|
420
|
+
// daemonMessageSchema from messages.ts/schemas.ts after routing.
|
|
421
|
+
payload: z7.unknown()
|
|
422
|
+
});
|
|
423
|
+
var relayDaemonMessageSchema = z7.discriminatedUnion("type", [
|
|
424
|
+
z7.object({ type: z7.literal("relay.register"), signed: signedRelayRegisterSchema }),
|
|
425
|
+
// Sent in response to relay.sessionOpened, once ConnectionAuthState has
|
|
426
|
+
// actually been allocated — see relay-architecture doc §6's correction:
|
|
427
|
+
// the relay cannot assume this happened just because it sent the
|
|
428
|
+
// message, since bondsmithd is a remote process over a real WSS socket.
|
|
429
|
+
z7.object({ type: z7.literal("relay.sessionOpenAck"), relaySessionId: z7.string().min(1) }),
|
|
430
|
+
z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema }),
|
|
431
|
+
// Daemon-initiated close — used when BD-434's existing
|
|
432
|
+
// revokeControllerConnections/revokeAllConnections destroys a
|
|
433
|
+
// ConnectionAuthState locally (controller revocation, daemon unpairing)
|
|
434
|
+
// and needs the relay to close the matching browser socket too. The
|
|
435
|
+
// daemon has already stopped honoring this relaySessionId's commands by
|
|
436
|
+
// the time this is sent (revocation's actual authorization guarantee
|
|
437
|
+
// never depends on the relay's cooperation) — this is cleanup, not the
|
|
438
|
+
// authorization boundary.
|
|
439
|
+
z7.object({ type: z7.literal("relay.closeSession"), relaySessionId: z7.string().min(1) })
|
|
440
|
+
]);
|
|
441
|
+
var relayToDaemonMessageSchema = z7.discriminatedUnion("type", [
|
|
442
|
+
z7.object({
|
|
443
|
+
type: z7.literal("relay.challenge"),
|
|
444
|
+
challenge: z7.string().min(1),
|
|
445
|
+
expiresAt: z7.number().int().positive(),
|
|
446
|
+
relayAudience: z7.string().min(1)
|
|
447
|
+
}),
|
|
448
|
+
z7.object({ type: z7.literal("relay.registered"), daemonFingerprint: z7.string().min(1) }),
|
|
449
|
+
z7.object({ type: z7.literal("relay.registrationFailed"), reason: relayFailureReasonSchema }),
|
|
450
|
+
z7.object({ type: z7.literal("relay.sessionOpened"), relaySessionId: z7.string().min(1) }),
|
|
451
|
+
z7.object({ type: z7.literal("relay.sessionClosed"), relaySessionId: z7.string().min(1) }),
|
|
452
|
+
z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema })
|
|
453
|
+
]);
|
|
454
|
+
var relayBrowserMessageSchema = z7.discriminatedUnion("type", [
|
|
455
|
+
z7.object({ type: z7.literal("relay.attach"), signed: signedRelayTicketSchema }),
|
|
456
|
+
z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema })
|
|
457
|
+
]);
|
|
458
|
+
var relayToBrowserMessageSchema = z7.discriminatedUnion("type", [
|
|
459
|
+
z7.object({ type: z7.literal("relay.attached"), relaySessionId: z7.string().min(1) }),
|
|
460
|
+
z7.object({ type: z7.literal("relay.attachFailed"), reason: relayFailureReasonSchema }),
|
|
461
|
+
z7.object({ type: z7.literal("relay.daemonOffline") }),
|
|
462
|
+
// Sent when the daemon registration a session was opened against gets
|
|
463
|
+
// superseded (the daemon reconnected) — the session is dead, the browser
|
|
464
|
+
// must perform a fresh relay.attach. "daemon_reconnected" covers V1's
|
|
465
|
+
// single-process supersession case (see relay-architecture.md §4); kept
|
|
466
|
+
// as its own reason (not folded into relay.daemonOffline) since the
|
|
467
|
+
// daemon IS reachable again, just under a new registration.
|
|
468
|
+
z7.object({
|
|
469
|
+
type: z7.literal("relay.sessionInvalidated"),
|
|
470
|
+
relaySessionId: z7.string().min(1),
|
|
471
|
+
reason: z7.literal("daemon_reconnected")
|
|
472
|
+
}),
|
|
473
|
+
z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema })
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
// src/index.ts
|
|
477
|
+
import { config as loadEnv } from "dotenv";
|
|
478
|
+
|
|
479
|
+
// src/config/environment.ts
|
|
480
|
+
import { z as z8 } from "zod";
|
|
481
|
+
var environmentSchema = z8.object({
|
|
482
|
+
PORT: z8.coerce.number().int().min(1).max(65535).default(4577),
|
|
483
|
+
ALLOWED_ORIGINS: z8.string().default(
|
|
484
|
+
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:4000,http://127.0.0.1:4000"
|
|
485
|
+
).transform(
|
|
486
|
+
(value) => value.split(",").map((origin) => origin.trim()).filter(Boolean)
|
|
487
|
+
),
|
|
488
|
+
// Dev-tunnel spike only: when set, every WebSocket connection must present
|
|
489
|
+
// this token (via the Sec-WebSocket-Protocol header) or be rejected. Unset
|
|
490
|
+
// by default so plain localhost usage is unaffected. Never a substitute for
|
|
491
|
+
// real auth — see apps/bondsmithd/README.md's dev-tunnel section.
|
|
492
|
+
BONDSMITH_DEV_TUNNEL_TOKEN: z8.string().min(1).optional(),
|
|
493
|
+
// Base URL of the Bondsmith backend this daemon pairs itself against
|
|
494
|
+
// (BD-432) — defaults to production so a real install works with zero
|
|
495
|
+
// config; overridden to the local dev server (e.g. http://localhost:4000)
|
|
496
|
+
// when developing bondsmithd itself against a local web app.
|
|
497
|
+
BONDSMITH_API_URL: z8.string().url().default("https://app.bondsmith.dev"),
|
|
498
|
+
// Interface the WebSocket/HTTP server binds to. Defaults to loopback-only
|
|
499
|
+
// (never reachable from outside this machine) — real access control is
|
|
500
|
+
// ALLOWED_ORIGINS + the dev-tunnel token, not the bind address, but
|
|
501
|
+
// loopback-only is a reasonable default so the daemon isn't exposed on
|
|
502
|
+
// the network by accident. Only reason to widen this: a local dev setup
|
|
503
|
+
// that puts something (e.g. Docker's host-gateway route for an ngrok
|
|
504
|
+
// tunnel) between the daemon and the browser — not something a normal
|
|
505
|
+
// install needs to touch.
|
|
506
|
+
BONDSMITH_BIND_HOST: z8.string().min(1).default("127.0.0.1"),
|
|
507
|
+
// BD-450/BD-483/BD-484: connect outbound to the Bondsmith relay instead of
|
|
508
|
+
// (or alongside) listening inbound. ON by default — local dev is meant to
|
|
509
|
+
// mirror production's actual topology (browser -> relay -> daemon), not
|
|
510
|
+
// diverge from it, so plain `pnpm --filter @bondsmith/bondsmithd dev`
|
|
511
|
+
// connects outbound to a locally-running apps/relay (its own `pnpm dev`
|
|
512
|
+
// on the default port below) exactly like a real deployment would.
|
|
513
|
+
// BONDSMITH_RELAY_URL defaults to that same local apps/relay instance for
|
|
514
|
+
// the same reason. The existing inbound listener is completely
|
|
515
|
+
// unaffected either way — it keeps running regardless, so direct-
|
|
516
|
+
// connection debugging (bypassing the relay entirely) is still possible
|
|
517
|
+
// by setting BONDSMITH_RELAY_ENABLED=false explicitly.
|
|
518
|
+
BONDSMITH_RELAY_ENABLED: z8.string().default("true").transform((value) => value === "true" || value === "1"),
|
|
519
|
+
BONDSMITH_RELAY_URL: z8.string().url().default("ws://127.0.0.1:8787")
|
|
520
|
+
});
|
|
521
|
+
function getDaemonConfig(env = process.env) {
|
|
522
|
+
const parsed = environmentSchema.parse(env);
|
|
523
|
+
return {
|
|
524
|
+
port: parsed.PORT,
|
|
525
|
+
allowedOrigins: parsed.ALLOWED_ORIGINS,
|
|
526
|
+
devTunnelToken: parsed.BONDSMITH_DEV_TUNNEL_TOKEN,
|
|
527
|
+
apiUrl: parsed.BONDSMITH_API_URL,
|
|
528
|
+
bindHost: parsed.BONDSMITH_BIND_HOST,
|
|
529
|
+
relayEnabled: parsed.BONDSMITH_RELAY_ENABLED,
|
|
530
|
+
relayUrl: parsed.BONDSMITH_RELAY_URL
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/pairing/open-browser.ts
|
|
535
|
+
import { spawn } from "child_process";
|
|
536
|
+
function openBrowser(url) {
|
|
537
|
+
try {
|
|
538
|
+
const { command, args } = commandFor(url);
|
|
539
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
540
|
+
child.unref();
|
|
541
|
+
child.on("error", () => {
|
|
542
|
+
});
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function commandFor(url) {
|
|
547
|
+
switch (process.platform) {
|
|
548
|
+
case "darwin":
|
|
549
|
+
return { command: "open", args: [url] };
|
|
550
|
+
case "win32":
|
|
551
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
552
|
+
default:
|
|
553
|
+
return { command: "xdg-open", args: [url] };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/pairing/pairing.ts
|
|
558
|
+
import { verify } from "crypto";
|
|
559
|
+
|
|
560
|
+
// src/pairing/trpc-client.ts
|
|
561
|
+
var TRPCCallError = class extends Error {
|
|
562
|
+
constructor(message, procedure) {
|
|
563
|
+
super(message);
|
|
564
|
+
this.procedure = procedure;
|
|
565
|
+
this.name = "TRPCCallError";
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function procedureUrl(baseUrl, procedure) {
|
|
569
|
+
return `${baseUrl.replace(/\/$/, "")}/api/trpc/${procedure}`;
|
|
570
|
+
}
|
|
571
|
+
async function parseTRPCResponse(response, procedure) {
|
|
572
|
+
let body;
|
|
573
|
+
try {
|
|
574
|
+
body = await response.json();
|
|
575
|
+
} catch {
|
|
576
|
+
throw new TRPCCallError(
|
|
577
|
+
`Unexpected response from ${procedure} (status ${response.status}, not valid JSON)`,
|
|
578
|
+
procedure
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (isErrorEnvelope(body)) {
|
|
582
|
+
throw new TRPCCallError(body.error.json.message, procedure);
|
|
583
|
+
}
|
|
584
|
+
if (!response.ok) {
|
|
585
|
+
throw new TRPCCallError(`HTTP ${response.status}`, procedure);
|
|
586
|
+
}
|
|
587
|
+
if (!isResultEnvelope(body)) {
|
|
588
|
+
throw new TRPCCallError(`Malformed response from ${procedure}`, procedure);
|
|
589
|
+
}
|
|
590
|
+
return body.result.data.json;
|
|
591
|
+
}
|
|
592
|
+
function isErrorEnvelope(body) {
|
|
593
|
+
if (typeof body !== "object" || body === null || !("error" in body)) return false;
|
|
594
|
+
const error2 = body.error;
|
|
595
|
+
if (typeof error2 !== "object" || error2 === null || !("json" in error2)) return false;
|
|
596
|
+
const json = error2.json;
|
|
597
|
+
return typeof json === "object" && json !== null && "message" in json && typeof json.message === "string";
|
|
598
|
+
}
|
|
599
|
+
function isResultEnvelope(body) {
|
|
600
|
+
return typeof body === "object" && body !== null && "result" in body && typeof body.result === "object";
|
|
601
|
+
}
|
|
602
|
+
async function callTRPCQuery(baseUrl, procedure, input) {
|
|
603
|
+
const encodedInput = encodeURIComponent(JSON.stringify({ json: input }));
|
|
604
|
+
const url = `${procedureUrl(baseUrl, procedure)}?input=${encodedInput}`;
|
|
605
|
+
const response = await fetch(url, { method: "GET" });
|
|
606
|
+
return parseTRPCResponse(response, procedure);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/pairing/pairing.ts
|
|
610
|
+
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
611
|
+
function sleep(ms) {
|
|
612
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
613
|
+
}
|
|
614
|
+
async function ensurePaired(options) {
|
|
615
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
616
|
+
const { paired } = await callTRPCQuery(
|
|
617
|
+
options.apiUrl,
|
|
618
|
+
"daemonPairing.checkOwnPairingStatus",
|
|
619
|
+
{ daemonPublicKey: options.publicKeyPem }
|
|
620
|
+
);
|
|
621
|
+
if (paired) return;
|
|
622
|
+
const { code, challenge } = await callTRPCQuery(
|
|
623
|
+
options.apiUrl,
|
|
624
|
+
"daemonPairing.initiatePairing",
|
|
625
|
+
{ daemonPublicKey: options.publicKeyPem, hostname: options.hostname }
|
|
626
|
+
);
|
|
627
|
+
const fingerprint = await formatDaemonFingerprint(options.publicKeyPem);
|
|
628
|
+
const pairingUrl = `${options.apiUrl.replace(/\/$/, "")}/pair?code=${code}`;
|
|
629
|
+
options.onPairingCode({ code, pairingUrl, fingerprint });
|
|
630
|
+
await pollUntilConfirmed({
|
|
631
|
+
apiUrl: options.apiUrl,
|
|
632
|
+
daemonPublicKey: options.publicKeyPem,
|
|
633
|
+
code,
|
|
634
|
+
expectedChallenge: challenge,
|
|
635
|
+
daemonFingerprint: fingerprint,
|
|
636
|
+
pollIntervalMs,
|
|
637
|
+
pendingAuthorizations: options.pendingAuthorizations,
|
|
638
|
+
presentPendingAuthorization: options.presentPendingAuthorization
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
async function pollUntilConfirmed(params) {
|
|
642
|
+
const { apiUrl, code, pollIntervalMs } = params;
|
|
643
|
+
for (; ; ) {
|
|
644
|
+
await sleep(pollIntervalMs);
|
|
645
|
+
let response;
|
|
646
|
+
try {
|
|
647
|
+
response = await callTRPCQuery(
|
|
648
|
+
apiUrl,
|
|
649
|
+
"daemonPairing.checkPairingStatus",
|
|
650
|
+
{ code }
|
|
651
|
+
);
|
|
652
|
+
} catch (error2) {
|
|
653
|
+
if (!(error2 instanceof TRPCCallError)) throw error2;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (response.status === "confirmed") {
|
|
657
|
+
await bindFirstController(params, response);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (response.status === "not_found") {
|
|
661
|
+
throw new Error(
|
|
662
|
+
"Pairing code expired before it was confirmed. Restart bondsmithd to get a new code."
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async function bindFirstController(params, response) {
|
|
668
|
+
const {
|
|
669
|
+
apiUrl,
|
|
670
|
+
daemonPublicKey,
|
|
671
|
+
code,
|
|
672
|
+
expectedChallenge,
|
|
673
|
+
daemonFingerprint,
|
|
674
|
+
pendingAuthorizations,
|
|
675
|
+
presentPendingAuthorization
|
|
676
|
+
} = params;
|
|
677
|
+
const { transcript, signature } = response;
|
|
678
|
+
const parsed = signedPairingChallengeSchema.safeParse({ transcript, signature });
|
|
679
|
+
if (!parsed.success) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
"Pairing confirmed, but the signed transcript is malformed. Refusing to trust this browser as a controller \u2014 restart bondsmithd to get a new code and try again."
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
let signatureValid;
|
|
685
|
+
try {
|
|
686
|
+
signatureValid = verify(
|
|
687
|
+
"sha256",
|
|
688
|
+
Buffer.from(canonicalize(transcript), "utf8"),
|
|
689
|
+
// WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
|
|
690
|
+
// DER encoding node:crypto defaults to for EC keys.
|
|
691
|
+
{ key: transcript.controllerPublicKeyPem, dsaEncoding: "ieee-p1363" },
|
|
692
|
+
Buffer.from(signature, "base64")
|
|
693
|
+
);
|
|
694
|
+
} catch {
|
|
695
|
+
signatureValid = false;
|
|
696
|
+
}
|
|
697
|
+
if (!signatureValid) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
"Pairing confirmed, but the controller signature over the pairing transcript did not verify. Refusing to trust this browser as a controller \u2014 restart bondsmithd to get a new code and try again."
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (transcript.purpose !== PAIRING_CHALLENGE_PURPOSE) {
|
|
703
|
+
throw new Error(
|
|
704
|
+
"Pairing confirmed, but the signed transcript has the wrong purpose. Refusing to trust this browser as a controller."
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (transcript.pairingAttemptId !== code) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
"Pairing confirmed, but the signed transcript is for a different pairing attempt. Refusing to trust this browser as a controller."
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (transcript.challenge !== expectedChallenge) {
|
|
713
|
+
throw new Error(
|
|
714
|
+
"Pairing confirmed, but the signed transcript's challenge does not match. Refusing to trust this browser as a controller \u2014 restart bondsmithd to get a new code and try again."
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (transcript.daemonFingerprint !== daemonFingerprint) {
|
|
718
|
+
throw new Error(
|
|
719
|
+
"Pairing confirmed, but the signed transcript is addressed to a different daemon. Refusing to trust this browser as a controller."
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const enrollmentResult = await pendingAuthorizations.requestEnrollment({
|
|
723
|
+
publicKeyPem: transcript.controllerPublicKeyPem,
|
|
724
|
+
label: "First controller (pairing)"
|
|
725
|
+
});
|
|
726
|
+
if (!enrollmentResult.ok) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
enrollmentResult.reason === "not_interactive" ? "Pairing requires local approval, but bondsmithd is not running interactively. Restart bondsmithd in a terminal to complete pairing." : "Too many pairing attempts in a short window. Wait a moment and restart bondsmithd to try again."
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
presentPendingAuthorization(enrollmentResult.pending);
|
|
732
|
+
const outcome = await enrollmentResult.result;
|
|
733
|
+
if (outcome !== "approved") {
|
|
734
|
+
throw new Error(
|
|
735
|
+
outcome === "denied" ? "Pairing was not approved at the local terminal prompt. Restart bondsmithd to get a new code and try again." : "Pairing approval was not completed in time. Restart bondsmithd to get a new code and try again."
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
await callTRPCQuery(apiUrl, "daemonPairing.reportControllerBound", {
|
|
740
|
+
daemonPublicKey,
|
|
741
|
+
controllerPublicKey: transcript.controllerPublicKeyPem,
|
|
742
|
+
label: "First controller (pairing)"
|
|
743
|
+
});
|
|
744
|
+
} catch (error2) {
|
|
745
|
+
console.error("Failed to report the new controller to Bondsmith (non-fatal)", error2);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/relay/relay-client.ts
|
|
750
|
+
import { sign } from "crypto";
|
|
751
|
+
import WebSocket from "ws";
|
|
752
|
+
|
|
753
|
+
// src/sessions/tmux/client.ts
|
|
754
|
+
import { execFile as execFileCb } from "child_process";
|
|
755
|
+
import { promisify } from "util";
|
|
756
|
+
var execFile = promisify(execFileCb);
|
|
757
|
+
var TmuxNoServerRunningError = class extends Error {
|
|
758
|
+
constructor() {
|
|
759
|
+
super("no tmux server running");
|
|
760
|
+
this.name = "TmuxNoServerRunningError";
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
|
|
764
|
+
function filterSpawnEnv(env) {
|
|
765
|
+
const filtered = { ...env };
|
|
766
|
+
for (const key of SENSITIVE_ENV_KEYS) delete filtered[key];
|
|
767
|
+
return filtered;
|
|
768
|
+
}
|
|
769
|
+
async function runTmux(args, options) {
|
|
770
|
+
const { stdout } = await execFile("tmux", args, { ...options, encoding: "utf8" });
|
|
771
|
+
return stdout;
|
|
772
|
+
}
|
|
773
|
+
async function sendKeysLiteral(tmuxSessionName, text) {
|
|
774
|
+
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
|
|
775
|
+
}
|
|
776
|
+
async function capturePane(tmuxSessionName) {
|
|
777
|
+
return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
|
|
778
|
+
}
|
|
779
|
+
async function getPaneActivity(tmuxSessionName) {
|
|
780
|
+
try {
|
|
781
|
+
const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{pane_activity}"])).trim();
|
|
782
|
+
const seconds = Number(out);
|
|
783
|
+
if (!out || Number.isNaN(seconds)) return null;
|
|
784
|
+
return new Date(seconds * 1e3).toISOString();
|
|
785
|
+
} catch {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async function listTmuxSessions(formatString) {
|
|
790
|
+
try {
|
|
791
|
+
return await runTmux(["list-sessions", "-F", formatString]);
|
|
792
|
+
} catch (error2) {
|
|
793
|
+
if (isNoServerError(error2)) throw new TmuxNoServerRunningError();
|
|
794
|
+
throw error2;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function isNoServerError(error2) {
|
|
798
|
+
if (typeof error2 !== "object" || error2 === null) return true;
|
|
799
|
+
return error2.code !== "ENOENT";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/security/verify-command.ts
|
|
803
|
+
import { verify as verify2 } from "crypto";
|
|
804
|
+
|
|
805
|
+
// src/security/trusted-controllers.ts
|
|
806
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
807
|
+
import { homedir } from "os";
|
|
808
|
+
import { dirname, join } from "path";
|
|
809
|
+
import { z as z9 } from "zod";
|
|
810
|
+
var trustedControllerSchema = z9.object({
|
|
811
|
+
id: z9.string(),
|
|
812
|
+
publicKeyPem: z9.string(),
|
|
813
|
+
label: z9.string(),
|
|
814
|
+
addedAt: z9.string(),
|
|
815
|
+
lastUsedAt: z9.string().nullable(),
|
|
816
|
+
// Obsolete as of ADR-013/BD-477 — replay protection moved to an
|
|
817
|
+
// ephemeral, connection-scoped sequence (connection-auth.ts), never
|
|
818
|
+
// persisted. Accepted-but-ignored here (`.optional()`, not required, and
|
|
819
|
+
// never read into TrustedController above) purely for backward
|
|
820
|
+
// compatibility: a trusted-controllers.json written by a daemon from
|
|
821
|
+
// before this change still parses correctly — controller trust and
|
|
822
|
+
// revocation state is never lost — instead of throwing on an "unknown"
|
|
823
|
+
// shape. Never written by this file's own persist() calls going forward.
|
|
824
|
+
lastSeenSequence: z9.number().int().nonnegative().optional()
|
|
825
|
+
});
|
|
826
|
+
var trustedControllersFileSchema = z9.object({
|
|
827
|
+
version: z9.literal(1),
|
|
828
|
+
controllers: z9.array(trustedControllerSchema)
|
|
829
|
+
});
|
|
830
|
+
function defaultTrustedControllersPath() {
|
|
831
|
+
return join(homedir(), ".bondsmith", "trusted-controllers.json");
|
|
832
|
+
}
|
|
833
|
+
async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
834
|
+
let raw;
|
|
835
|
+
try {
|
|
836
|
+
raw = await readFile(path, "utf8");
|
|
837
|
+
} catch {
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
const parsed = trustedControllersFileSchema.safeParse(JSON.parse(raw));
|
|
841
|
+
if (!parsed.success) {
|
|
842
|
+
throw new Error(
|
|
843
|
+
`trusted-controllers: file at ${path} exists but failed to parse \u2014 refusing to silently treat an already-trusted set as empty. Move or delete it manually to reset. (${parsed.error.message})`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
return parsed.data.controllers.map(({ id, publicKeyPem, label, addedAt, lastUsedAt }) => ({
|
|
847
|
+
id,
|
|
848
|
+
publicKeyPem,
|
|
849
|
+
label,
|
|
850
|
+
addedAt,
|
|
851
|
+
lastUsedAt
|
|
852
|
+
}));
|
|
853
|
+
}
|
|
854
|
+
async function getTrustedController(controllerId, path = defaultTrustedControllersPath()) {
|
|
855
|
+
const controllers = await loadTrustedControllers(path);
|
|
856
|
+
return controllers.find((c) => c.id === controllerId) ?? null;
|
|
857
|
+
}
|
|
858
|
+
async function addTrustedController(params, path = defaultTrustedControllersPath()) {
|
|
859
|
+
return withTrustFileLock(path, async () => {
|
|
860
|
+
const controllers = await loadTrustedControllers(path);
|
|
861
|
+
const id = await controllerIdFromPublicKeyPem(params.publicKeyPem);
|
|
862
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
863
|
+
const existingIndex = controllers.findIndex((c) => c.id === id);
|
|
864
|
+
const entry = {
|
|
865
|
+
id,
|
|
866
|
+
publicKeyPem: params.publicKeyPem,
|
|
867
|
+
label: params.label,
|
|
868
|
+
addedAt: existingIndex >= 0 ? controllers[existingIndex].addedAt : now,
|
|
869
|
+
lastUsedAt: existingIndex >= 0 ? controllers[existingIndex].lastUsedAt : null
|
|
870
|
+
};
|
|
871
|
+
const next = existingIndex >= 0 ? controllers.map((c, i) => i === existingIndex ? entry : c) : [...controllers, entry];
|
|
872
|
+
await persist(path, next);
|
|
873
|
+
return entry;
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
async function revokeTrustedController(controllerId, path = defaultTrustedControllersPath()) {
|
|
877
|
+
return withTrustFileLock(path, async () => {
|
|
878
|
+
const controllers = await loadTrustedControllers(path);
|
|
879
|
+
const next = controllers.filter((c) => c.id !== controllerId);
|
|
880
|
+
await persist(path, next);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
async function revokeAllTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
884
|
+
return withTrustFileLock(path, () => persist(path, []));
|
|
885
|
+
}
|
|
886
|
+
async function recordControllerHandshake(controllerId, path = defaultTrustedControllersPath()) {
|
|
887
|
+
return withTrustFileLock(path, async () => {
|
|
888
|
+
const controllers = await loadTrustedControllers(path);
|
|
889
|
+
const next = controllers.map(
|
|
890
|
+
(c) => c.id === controllerId ? { ...c, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() } : c
|
|
891
|
+
);
|
|
892
|
+
await persist(path, next);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
var trustFileLocks = /* @__PURE__ */ new Map();
|
|
896
|
+
function withTrustFileLock(path, fn) {
|
|
897
|
+
const previous = trustFileLocks.get(path) ?? Promise.resolve();
|
|
898
|
+
const next = previous.catch(() => {
|
|
899
|
+
}).then(fn);
|
|
900
|
+
trustFileLocks.set(
|
|
901
|
+
path,
|
|
902
|
+
next.catch(() => {
|
|
903
|
+
})
|
|
904
|
+
);
|
|
905
|
+
return next;
|
|
906
|
+
}
|
|
907
|
+
async function persist(path, controllers) {
|
|
908
|
+
await mkdir(dirname(path), { recursive: true });
|
|
909
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
910
|
+
const contents = JSON.stringify({ version: 1, controllers }, null, 2);
|
|
911
|
+
try {
|
|
912
|
+
await writeFile(tmpPath, contents, { mode: 384 });
|
|
913
|
+
await rename(tmpPath, path);
|
|
914
|
+
} catch (error2) {
|
|
915
|
+
await rm(tmpPath, { force: true });
|
|
916
|
+
throw error2;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/security/verify-command.ts
|
|
921
|
+
var ENVELOPE_MAX_AGE_MS = 6e4;
|
|
922
|
+
var CLOCK_SKEW_TOLERANCE_MS = 5e3;
|
|
923
|
+
async function verifyCommand(command, daemonFingerprint, connectionState, trustedControllersPath) {
|
|
924
|
+
const { envelope, signature } = command;
|
|
925
|
+
const identity = connectionState.getAuthenticatedIdentity();
|
|
926
|
+
if (!identity) {
|
|
927
|
+
return { ok: false, reason: "not_authenticated" };
|
|
928
|
+
}
|
|
929
|
+
if (envelope.connectionId !== connectionState.connectionId) {
|
|
930
|
+
return { ok: false, reason: "wrong_connection" };
|
|
931
|
+
}
|
|
932
|
+
if (envelope.controllerId !== identity.controllerId) {
|
|
933
|
+
return { ok: false, reason: "wrong_connection" };
|
|
934
|
+
}
|
|
935
|
+
const controller = await getTrustedController(envelope.controllerId, trustedControllersPath);
|
|
936
|
+
if (!controller) {
|
|
937
|
+
return { ok: false, reason: "untrusted_controller" };
|
|
938
|
+
}
|
|
939
|
+
const canonicalBytes = Buffer.from(canonicalize(envelope), "utf8");
|
|
940
|
+
let signatureValid;
|
|
941
|
+
try {
|
|
942
|
+
signatureValid = verify2(
|
|
943
|
+
"sha256",
|
|
944
|
+
canonicalBytes,
|
|
945
|
+
// WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
|
|
946
|
+
// DER encoding node:crypto defaults to for EC keys — without this
|
|
947
|
+
// option every browser-produced signature would fail to verify.
|
|
948
|
+
{ key: controller.publicKeyPem, dsaEncoding: "ieee-p1363" },
|
|
949
|
+
Buffer.from(signature, "base64")
|
|
950
|
+
);
|
|
951
|
+
} catch {
|
|
952
|
+
signatureValid = false;
|
|
953
|
+
}
|
|
954
|
+
if (!signatureValid) {
|
|
955
|
+
return { ok: false, reason: "bad_signature" };
|
|
956
|
+
}
|
|
957
|
+
if (envelope.daemonId !== daemonFingerprint) {
|
|
958
|
+
return { ok: false, reason: "wrong_daemon" };
|
|
959
|
+
}
|
|
960
|
+
const now = Date.now();
|
|
961
|
+
if (envelope.issuedAt > now + CLOCK_SKEW_TOLERANCE_MS) {
|
|
962
|
+
return { ok: false, reason: "expired" };
|
|
963
|
+
}
|
|
964
|
+
if (envelope.expiresAt <= now) {
|
|
965
|
+
return { ok: false, reason: "expired" };
|
|
966
|
+
}
|
|
967
|
+
if (envelope.expiresAt - envelope.issuedAt > ENVELOPE_MAX_AGE_MS) {
|
|
968
|
+
return { ok: false, reason: "expired" };
|
|
969
|
+
}
|
|
970
|
+
const sequenceResult = await connectionState.checkAndAdvanceSequence(envelope.sequence);
|
|
971
|
+
if (!sequenceResult.ok) {
|
|
972
|
+
return {
|
|
973
|
+
ok: false,
|
|
974
|
+
reason: sequenceResult.reason === "replayed" ? "replayed" : "not_authenticated"
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
return { ok: true };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/ws/router.ts
|
|
981
|
+
var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
|
|
982
|
+
function isSignableAction(type) {
|
|
983
|
+
return SIGNABLE_ACTION_SET.has(type);
|
|
984
|
+
}
|
|
985
|
+
function verifyRejectionMessage(reason) {
|
|
986
|
+
switch (reason) {
|
|
987
|
+
case "untrusted_controller":
|
|
988
|
+
return "controller not trusted by this daemon";
|
|
989
|
+
case "bad_signature":
|
|
990
|
+
return "invalid command signature";
|
|
991
|
+
case "expired":
|
|
992
|
+
return "command envelope expired";
|
|
993
|
+
case "replayed":
|
|
994
|
+
return "command already used (replay rejected)";
|
|
995
|
+
case "wrong_daemon":
|
|
996
|
+
return "command not addressed to this daemon";
|
|
997
|
+
case "wrong_connection":
|
|
998
|
+
return "command not addressed to this connection";
|
|
999
|
+
case "not_authenticated":
|
|
1000
|
+
return "connection has not completed its handshake";
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
|
|
1004
|
+
var BRACKETED_PASTE_START = "\x1B[200~";
|
|
1005
|
+
var BRACKETED_PASTE_END = "\x1B[201~";
|
|
1006
|
+
function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments) {
|
|
1007
|
+
const outputListeners = /* @__PURE__ */ new Map();
|
|
1008
|
+
const conn = {
|
|
1009
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
1010
|
+
send,
|
|
1011
|
+
registerOutputListener: (sessionId, listener) => {
|
|
1012
|
+
outputListeners.set(sessionId, listener);
|
|
1013
|
+
},
|
|
1014
|
+
getOutputListener: (sessionId) => outputListeners.get(sessionId),
|
|
1015
|
+
connectionAuth,
|
|
1016
|
+
markControllerId
|
|
1017
|
+
};
|
|
1018
|
+
function releaseAllSubscriptions() {
|
|
1019
|
+
for (const sessionId of conn.subscriptions) {
|
|
1020
|
+
const listener = outputListeners.get(sessionId);
|
|
1021
|
+
if (listener) ptyAttachments.release(sessionId, listener);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return { conn, releaseAllSubscriptions };
|
|
1025
|
+
}
|
|
1026
|
+
function error(message) {
|
|
1027
|
+
return { type: "error", message };
|
|
1028
|
+
}
|
|
1029
|
+
function errorMessageOf(err, fallback) {
|
|
1030
|
+
return err instanceof Error ? err.message : fallback;
|
|
1031
|
+
}
|
|
1032
|
+
function getSessionOrError(sessionManager, conn, sessionId) {
|
|
1033
|
+
const session = sessionManager.get(sessionId);
|
|
1034
|
+
if (!session) conn.send(error("unknown session"));
|
|
1035
|
+
return session;
|
|
1036
|
+
}
|
|
1037
|
+
async function handleBrowserMessage(conn, raw, deps) {
|
|
1038
|
+
const signedCommand = signedCommandSchema.safeParse(raw);
|
|
1039
|
+
if (signedCommand.success) {
|
|
1040
|
+
const { envelope } = signedCommand.data;
|
|
1041
|
+
const innerMessage = browserMessageSchema.safeParse(envelope.payload);
|
|
1042
|
+
if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
|
|
1043
|
+
conn.send(error("invalid message"));
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const verdict = await verifyCommand(
|
|
1047
|
+
signedCommand.data,
|
|
1048
|
+
deps.daemonFingerprint,
|
|
1049
|
+
conn.connectionAuth,
|
|
1050
|
+
deps.trustedControllersPath
|
|
1051
|
+
);
|
|
1052
|
+
if (!verdict.ok) {
|
|
1053
|
+
conn.send(error(verifyRejectionMessage(verdict.reason)));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
if (conn.connectionAuth.isRevoked()) {
|
|
1057
|
+
conn.send(error(REVOKED_REJECTION_MESSAGE));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
await dispatch(conn, innerMessage.data, deps);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const parsed = browserMessageSchema.safeParse(raw);
|
|
1064
|
+
if (!parsed.success) {
|
|
1065
|
+
conn.send(error("invalid message"));
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (parsed.data.type === "connection.authenticate") {
|
|
1069
|
+
const result = await conn.connectionAuth.authenticate(
|
|
1070
|
+
parsed.data.signed,
|
|
1071
|
+
deps.trustedControllersPath
|
|
1072
|
+
);
|
|
1073
|
+
if (!result.ok) {
|
|
1074
|
+
conn.send({ type: "connection.authenticationFailed", reason: result.reason });
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
conn.markControllerId(result.controllerId);
|
|
1078
|
+
conn.send({ type: "connection.authenticated", connectionId: conn.connectionAuth.connectionId });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (isSignableAction(parsed.data.type)) {
|
|
1082
|
+
conn.send(error("signature required for this command"));
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
await dispatch(conn, parsed.data, deps);
|
|
1086
|
+
}
|
|
1087
|
+
async function dispatch(conn, message, {
|
|
1088
|
+
sessionManager,
|
|
1089
|
+
ptyAttachments,
|
|
1090
|
+
broadcast,
|
|
1091
|
+
getWorkspaceStatus: getWorkspaceStatus2,
|
|
1092
|
+
getWorkspaceDiff: getWorkspaceDiff2,
|
|
1093
|
+
hostname: hostname3,
|
|
1094
|
+
daemonFingerprint,
|
|
1095
|
+
pendingAuthorizations,
|
|
1096
|
+
presentPendingAuthorization
|
|
1097
|
+
}) {
|
|
1098
|
+
switch (message.type) {
|
|
1099
|
+
case "session.list": {
|
|
1100
|
+
conn.send({ type: "session.list", sessions: sessionManager.list() });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
case "session.create": {
|
|
1104
|
+
const { title, cwd, command, requestId } = message;
|
|
1105
|
+
try {
|
|
1106
|
+
const session = await sessionManager.create({ title, cwd, command });
|
|
1107
|
+
broadcast({ type: "session.created", session, requestId });
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
conn.send(error(errorMessageOf(err, "failed to create session")));
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
case "session.attach": {
|
|
1114
|
+
if (!isValidSessionId(message.sessionId)) {
|
|
1115
|
+
conn.send(error("invalid session id"));
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1119
|
+
if (!session) return;
|
|
1120
|
+
const previousListener = conn.getOutputListener(message.sessionId);
|
|
1121
|
+
const onData = (data) => {
|
|
1122
|
+
conn.send({ type: "terminal.output", sessionId: message.sessionId, data });
|
|
1123
|
+
};
|
|
1124
|
+
if (previousListener && await ptyAttachments.swapListener(
|
|
1125
|
+
message.sessionId,
|
|
1126
|
+
session.tmuxSessionName,
|
|
1127
|
+
previousListener,
|
|
1128
|
+
onData
|
|
1129
|
+
)) {
|
|
1130
|
+
conn.subscriptions.add(message.sessionId);
|
|
1131
|
+
conn.registerOutputListener(message.sessionId, onData);
|
|
1132
|
+
conn.send({ type: "session.attached", sessionId: message.sessionId });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (previousListener) {
|
|
1136
|
+
ptyAttachments.release(message.sessionId, previousListener);
|
|
1137
|
+
}
|
|
1138
|
+
const attached = await ptyAttachments.ensureAttached(
|
|
1139
|
+
message.sessionId,
|
|
1140
|
+
session.tmuxSessionName,
|
|
1141
|
+
onData
|
|
1142
|
+
);
|
|
1143
|
+
if (!attached) {
|
|
1144
|
+
conn.send(error("failed to attach to session"));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
conn.subscriptions.add(message.sessionId);
|
|
1148
|
+
conn.registerOutputListener(message.sessionId, onData);
|
|
1149
|
+
conn.send({ type: "session.attached", sessionId: message.sessionId });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
case "session.input": {
|
|
1153
|
+
if (!conn.subscriptions.has(message.sessionId)) {
|
|
1154
|
+
conn.send(error("not attached to session"));
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
ptyAttachments.write(message.sessionId, message.data);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
case "session.insertText": {
|
|
1161
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1162
|
+
if (!session) return;
|
|
1163
|
+
try {
|
|
1164
|
+
await sendKeysLiteral(
|
|
1165
|
+
session.tmuxSessionName,
|
|
1166
|
+
`${BRACKETED_PASTE_START}${message.text}${BRACKETED_PASTE_END}`
|
|
1167
|
+
);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
conn.send(error(errorMessageOf(err, "failed to insert text")));
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
case "session.resize": {
|
|
1174
|
+
if (!conn.subscriptions.has(message.sessionId)) {
|
|
1175
|
+
conn.send(error("not attached to session"));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
ptyAttachments.resize(message.sessionId, message.cols, message.rows);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
case "session.stop": {
|
|
1182
|
+
try {
|
|
1183
|
+
if (!await sessionManager.stop(message.sessionId)) {
|
|
1184
|
+
conn.send(error("unknown session"));
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
conn.send(error(errorMessageOf(err, "failed to stop session")));
|
|
1189
|
+
}
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
case "workspace.getStatus": {
|
|
1193
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1194
|
+
if (!session) return;
|
|
1195
|
+
try {
|
|
1196
|
+
const status = await getWorkspaceStatus2(session.cwd);
|
|
1197
|
+
conn.send({
|
|
1198
|
+
type: "workspace.status",
|
|
1199
|
+
sessionId: message.sessionId,
|
|
1200
|
+
requestId: message.requestId,
|
|
1201
|
+
status
|
|
1202
|
+
});
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
conn.send(error(errorMessageOf(err, "failed to read workspace status")));
|
|
1205
|
+
}
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
case "workspace.getDiff": {
|
|
1209
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1210
|
+
if (!session) return;
|
|
1211
|
+
try {
|
|
1212
|
+
const diff = await getWorkspaceDiff2(session.cwd, message.path, message.staged);
|
|
1213
|
+
conn.send({
|
|
1214
|
+
type: "workspace.diff",
|
|
1215
|
+
sessionId: message.sessionId,
|
|
1216
|
+
requestId: message.requestId,
|
|
1217
|
+
diff
|
|
1218
|
+
});
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
conn.send(error(errorMessageOf(err, "failed to read workspace diff")));
|
|
1221
|
+
}
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
case "daemon.getInfo": {
|
|
1225
|
+
conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
case "controller.requestEnrollment": {
|
|
1229
|
+
const result = await pendingAuthorizations.requestEnrollment({
|
|
1230
|
+
publicKeyPem: message.publicKeyPem,
|
|
1231
|
+
label: message.label
|
|
1232
|
+
});
|
|
1233
|
+
if (!result.ok) {
|
|
1234
|
+
conn.send({ type: "controller.enrollmentRejected", reason: result.reason });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
conn.send({ type: "controller.enrollmentPending", fingerprint: result.pending.fingerprint });
|
|
1238
|
+
if (!result.coalesced) {
|
|
1239
|
+
presentPendingAuthorization(result.pending);
|
|
1240
|
+
}
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/security/connection-auth.ts
|
|
1247
|
+
import { randomBytes, verify as verify3 } from "crypto";
|
|
1248
|
+
var CHALLENGE_LENGTH_BYTES = 32;
|
|
1249
|
+
var CONNECTION_ID_LENGTH_BYTES = 24;
|
|
1250
|
+
var CHALLENGE_LIFETIME_MS = 3e4;
|
|
1251
|
+
var MAX_HANDSHAKE_ATTEMPTS_PER_WINDOW = 30;
|
|
1252
|
+
var HANDSHAKE_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1253
|
+
var handshakeAttemptTimestamps = [];
|
|
1254
|
+
function isHandshakeRateLimited() {
|
|
1255
|
+
const windowStart = Date.now() - HANDSHAKE_RATE_LIMIT_WINDOW_MS;
|
|
1256
|
+
handshakeAttemptTimestamps = handshakeAttemptTimestamps.filter((t) => t > windowStart);
|
|
1257
|
+
return handshakeAttemptTimestamps.length >= MAX_HANDSHAKE_ATTEMPTS_PER_WINDOW;
|
|
1258
|
+
}
|
|
1259
|
+
function recordHandshakeAttempt() {
|
|
1260
|
+
handshakeAttemptTimestamps.push(Date.now());
|
|
1261
|
+
}
|
|
1262
|
+
var ConnectionAuthState = class {
|
|
1263
|
+
constructor(daemonFingerprint) {
|
|
1264
|
+
this.daemonFingerprint = daemonFingerprint;
|
|
1265
|
+
this.connectionId = randomBytes(CONNECTION_ID_LENGTH_BYTES).toString("base64url");
|
|
1266
|
+
this.challenge = randomBytes(CHALLENGE_LENGTH_BYTES).toString("base64url");
|
|
1267
|
+
this.challengeExpiresAt = Date.now() + CHALLENGE_LIFETIME_MS;
|
|
1268
|
+
}
|
|
1269
|
+
connectionId;
|
|
1270
|
+
challenge;
|
|
1271
|
+
challengeExpiresAt;
|
|
1272
|
+
authenticated = null;
|
|
1273
|
+
lastSequence = 0;
|
|
1274
|
+
// BD-487: set synchronously by revocation (server.ts's
|
|
1275
|
+
// revokeControllerConnections/revokeAllConnections, or relay-client.ts's
|
|
1276
|
+
// identically-named functions), checked synchronously again in
|
|
1277
|
+
// router.ts's handleBrowserMessage immediately before dispatch() runs —
|
|
1278
|
+
// closes the gap where a command already past verifyCommand's async
|
|
1279
|
+
// awaits (getTrustedController's disk read, checkAndAdvanceSequence)
|
|
1280
|
+
// would otherwise still execute even though revocation was supposed to
|
|
1281
|
+
// have already ended this connection's authority. Deliberately a plain
|
|
1282
|
+
// synchronous flag, not itself awaited anywhere: the race this closes is
|
|
1283
|
+
// specifically the gap between an async check's awaits, so the fix must
|
|
1284
|
+
// be a check with zero await distance from dispatch(), not another
|
|
1285
|
+
// async lookup that reopens the same kind of window.
|
|
1286
|
+
revoked = false;
|
|
1287
|
+
// Serializes sequence verify-and-advance within this one connection —
|
|
1288
|
+
// without this, two commands signed close together could have their
|
|
1289
|
+
// async verification interleave and both read the same "last sequence"
|
|
1290
|
+
// before either advances it, letting a lower/equal sequence slip through
|
|
1291
|
+
// as if it were the next one. A per-connection promise chain (same
|
|
1292
|
+
// pattern as trusted-controllers.ts's withTrustFileLock, but in-memory
|
|
1293
|
+
// and scoped to one socket instead of a file path) is sufficient — never
|
|
1294
|
+
// contends across connections, and there is no file I/O to race.
|
|
1295
|
+
sequenceChain = Promise.resolve();
|
|
1296
|
+
// Serializes authenticate() the same way sequenceChain serializes
|
|
1297
|
+
// checkAndAdvanceSequence — adversarial review (BD-477 follow-up) found
|
|
1298
|
+
// that two connection.authenticate frames arriving back-to-back on one
|
|
1299
|
+
// socket could both pass the `this.authenticated === null` check before
|
|
1300
|
+
// either reached the assignment at the end of the async verification
|
|
1301
|
+
// chain, since getTrustedController is a real await gap. Both attempts
|
|
1302
|
+
// still require a validly-signed transcript (this was never an auth
|
|
1303
|
+
// bypass), but the second could silently overwrite the first's identity
|
|
1304
|
+
// and double-register the socket in server.ts's connectionsByController
|
|
1305
|
+
// map. Without a chain here, the fix would need a synchronous
|
|
1306
|
+
// compare-and-set instead, which would need to happen before the
|
|
1307
|
+
// signature is even checked — this chain is simpler and matches the
|
|
1308
|
+
// sequence check's own established pattern.
|
|
1309
|
+
authenticateChain = Promise.resolve();
|
|
1310
|
+
getChallenge() {
|
|
1311
|
+
return {
|
|
1312
|
+
connectionId: this.connectionId,
|
|
1313
|
+
challenge: this.challenge,
|
|
1314
|
+
expiresAt: this.challengeExpiresAt
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
isAuthenticated() {
|
|
1318
|
+
return this.authenticated !== null;
|
|
1319
|
+
}
|
|
1320
|
+
/** BD-487: marks this connection's authority as ended — called by revocation, checked by router.ts immediately before dispatch(). Idempotent; a connection is never un-revoked. */
|
|
1321
|
+
revoke() {
|
|
1322
|
+
this.revoked = true;
|
|
1323
|
+
}
|
|
1324
|
+
isRevoked() {
|
|
1325
|
+
return this.revoked;
|
|
1326
|
+
}
|
|
1327
|
+
/** The controller/account this connection authenticated as, or null before a successful handshake. Used by verify-command.ts to reject an envelope signed by a DIFFERENT controller than the one that authenticated this connection. */
|
|
1328
|
+
getAuthenticatedIdentity() {
|
|
1329
|
+
return this.authenticated;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Verifies a signed connection-handshake transcript and, on success,
|
|
1333
|
+
* marks this connection authenticated — exactly once, including under
|
|
1334
|
+
* concurrent calls. A second call (whether the first succeeded or
|
|
1335
|
+
* failed, and regardless of whether the two calls overlap in time) is
|
|
1336
|
+
* rejected with "already_authenticated": the challenge is bound to this
|
|
1337
|
+
* ConnectionAuthState instance and is never reissued or extended, so a
|
|
1338
|
+
* duplicate, delayed, or concurrent handshake attempt can never
|
|
1339
|
+
* re-authenticate or reset this connection's sequence counter.
|
|
1340
|
+
*
|
|
1341
|
+
* Serialized via authenticateChain (same pattern as
|
|
1342
|
+
* checkAndAdvanceSequence's sequenceChain) so two connection.authenticate
|
|
1343
|
+
* frames arriving close together on one socket can't both observe
|
|
1344
|
+
* `this.authenticated === null` before either finishes — without this,
|
|
1345
|
+
* both could pass the guard, both complete real signature verification
|
|
1346
|
+
* (this was never an auth bypass on its own), and the second's
|
|
1347
|
+
* assignment would silently overwrite the first's, double-registering
|
|
1348
|
+
* the socket under two controller identities.
|
|
1349
|
+
*
|
|
1350
|
+
* Checks run in a fixed order regardless of which field is
|
|
1351
|
+
* attacker-controlled, matching verify-command.ts's own discipline:
|
|
1352
|
+
* connection binding and freshness are checked before the signature is
|
|
1353
|
+
* even verified (they're cheap, local, and don't leak anything about
|
|
1354
|
+
* controller trust), then the signature, then controller trust —
|
|
1355
|
+
* signature must pass before ANY other field of the transcript is
|
|
1356
|
+
* trusted for a decision, since those fields are attacker-controlled
|
|
1357
|
+
* until the signature over the whole transcript is checked.
|
|
1358
|
+
*/
|
|
1359
|
+
authenticate(signed, trustedControllersPath) {
|
|
1360
|
+
const task = this.authenticateChain.then(
|
|
1361
|
+
() => this.authenticateOnce(signed, trustedControllersPath)
|
|
1362
|
+
);
|
|
1363
|
+
this.authenticateChain = task.catch(() => {
|
|
1364
|
+
});
|
|
1365
|
+
return task;
|
|
1366
|
+
}
|
|
1367
|
+
async authenticateOnce(signed, trustedControllersPath) {
|
|
1368
|
+
if (this.authenticated) {
|
|
1369
|
+
return { ok: false, reason: "already_authenticated" };
|
|
1370
|
+
}
|
|
1371
|
+
if (isHandshakeRateLimited()) {
|
|
1372
|
+
return { ok: false, reason: "rate_limited" };
|
|
1373
|
+
}
|
|
1374
|
+
recordHandshakeAttempt();
|
|
1375
|
+
const { transcript, signature } = signed;
|
|
1376
|
+
if (transcript.purpose !== CONNECTION_CHALLENGE_PURPOSE) {
|
|
1377
|
+
return { ok: false, reason: "bad_signature" };
|
|
1378
|
+
}
|
|
1379
|
+
if (transcript.connectionId !== this.connectionId || transcript.challenge !== this.challenge) {
|
|
1380
|
+
return { ok: false, reason: "wrong_connection" };
|
|
1381
|
+
}
|
|
1382
|
+
if (Date.now() >= this.challengeExpiresAt) {
|
|
1383
|
+
return { ok: false, reason: "challenge_expired" };
|
|
1384
|
+
}
|
|
1385
|
+
if (transcript.daemonFingerprint !== this.daemonFingerprint) {
|
|
1386
|
+
return { ok: false, reason: "wrong_daemon" };
|
|
1387
|
+
}
|
|
1388
|
+
const controller = await getTrustedController(transcript.controllerId, trustedControllersPath);
|
|
1389
|
+
if (!controller) {
|
|
1390
|
+
return { ok: false, reason: "untrusted_controller" };
|
|
1391
|
+
}
|
|
1392
|
+
let signatureValid;
|
|
1393
|
+
try {
|
|
1394
|
+
signatureValid = verify3(
|
|
1395
|
+
"sha256",
|
|
1396
|
+
Buffer.from(canonicalize(transcript), "utf8"),
|
|
1397
|
+
// WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
|
|
1398
|
+
// DER encoding node:crypto defaults to for EC keys — matches
|
|
1399
|
+
// verify-command.ts's and pairing.ts's identical handling.
|
|
1400
|
+
{ key: controller.publicKeyPem, dsaEncoding: "ieee-p1363" },
|
|
1401
|
+
Buffer.from(signature, "base64")
|
|
1402
|
+
);
|
|
1403
|
+
} catch {
|
|
1404
|
+
signatureValid = false;
|
|
1405
|
+
}
|
|
1406
|
+
if (!signatureValid) {
|
|
1407
|
+
return { ok: false, reason: "bad_signature" };
|
|
1408
|
+
}
|
|
1409
|
+
this.authenticated = { controllerId: transcript.controllerId, accountId: transcript.accountId };
|
|
1410
|
+
recordControllerHandshake(transcript.controllerId, trustedControllersPath).catch(
|
|
1411
|
+
(err) => {
|
|
1412
|
+
console.error("Failed to record controller handshake (non-fatal)", err);
|
|
1413
|
+
}
|
|
1414
|
+
);
|
|
1415
|
+
return { ok: true, controllerId: transcript.controllerId, accountId: transcript.accountId };
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Verifies a command envelope's sequence number is strictly greater than
|
|
1419
|
+
* the last one accepted on THIS connection, then advances the
|
|
1420
|
+
* counter — serialized via sequenceChain so two verify calls racing
|
|
1421
|
+
* close together can never both read the same lastSequence before
|
|
1422
|
+
* either advances it. Must only be called for an envelope whose
|
|
1423
|
+
* connectionId has already been confirmed to match this connection
|
|
1424
|
+
* (router.ts checks that before calling this) and whose controllerId
|
|
1425
|
+
* matches this connection's authenticated controller (same caller
|
|
1426
|
+
* responsibility) — this method only ever checks the sequence number
|
|
1427
|
+
* itself, not connection/controller binding, which is the caller's job.
|
|
1428
|
+
*/
|
|
1429
|
+
async checkAndAdvanceSequence(sequence) {
|
|
1430
|
+
if (!this.authenticated) {
|
|
1431
|
+
return { ok: false, reason: "not_authenticated" };
|
|
1432
|
+
}
|
|
1433
|
+
const task = this.sequenceChain.then(() => {
|
|
1434
|
+
if (sequence <= this.lastSequence) {
|
|
1435
|
+
return { ok: false, reason: "replayed" };
|
|
1436
|
+
}
|
|
1437
|
+
this.lastSequence = sequence;
|
|
1438
|
+
return { ok: true };
|
|
1439
|
+
});
|
|
1440
|
+
this.sequenceChain = task.catch(() => {
|
|
1441
|
+
});
|
|
1442
|
+
return task;
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
// src/relay/relay-session-registry.ts
|
|
1447
|
+
var MAX_CONCURRENT_RELAY_SESSIONS = 32;
|
|
1448
|
+
var RelaySessionRegistry = class {
|
|
1449
|
+
byRelaySessionId = /* @__PURE__ */ new Map();
|
|
1450
|
+
/** True if a NEW entry cannot be created right now — does not apply to a duplicate create() for an already-tracked relaySessionId, which stays idempotent regardless of the cap. */
|
|
1451
|
+
isAtCapacity() {
|
|
1452
|
+
return this.byRelaySessionId.size >= MAX_CONCURRENT_RELAY_SESSIONS;
|
|
1453
|
+
}
|
|
1454
|
+
create(relaySessionId, daemonFingerprint, buildConn) {
|
|
1455
|
+
const connectionAuth = new ConnectionAuthState(daemonFingerprint);
|
|
1456
|
+
const { conn, releaseAllSubscriptions } = buildConn(connectionAuth);
|
|
1457
|
+
const entry = {
|
|
1458
|
+
relaySessionId,
|
|
1459
|
+
connectionAuth,
|
|
1460
|
+
conn,
|
|
1461
|
+
releaseAllSubscriptions
|
|
1462
|
+
};
|
|
1463
|
+
this.byRelaySessionId.set(relaySessionId, entry);
|
|
1464
|
+
return entry;
|
|
1465
|
+
}
|
|
1466
|
+
get(relaySessionId) {
|
|
1467
|
+
return this.byRelaySessionId.get(relaySessionId);
|
|
1468
|
+
}
|
|
1469
|
+
/** Releases pty subscriptions and removes the entry — called on relay.sessionClosed (relay-initiated) or when this daemon itself gives up on a session (relay.closeSession). */
|
|
1470
|
+
delete(relaySessionId) {
|
|
1471
|
+
const entry = this.byRelaySessionId.get(relaySessionId);
|
|
1472
|
+
if (!entry) return;
|
|
1473
|
+
entry.releaseAllSubscriptions();
|
|
1474
|
+
this.byRelaySessionId.delete(relaySessionId);
|
|
1475
|
+
}
|
|
1476
|
+
/** Every currently-tracked relaySessionId — used to tear all of them down at once on a full relay-socket reconnect (a fresh outbound connection means a fresh relay.register, and the relay itself already invalidates every session bound to the old registration on its side). */
|
|
1477
|
+
deleteAll() {
|
|
1478
|
+
for (const relaySessionId of [...this.byRelaySessionId.keys()]) {
|
|
1479
|
+
this.delete(relaySessionId);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
/** Every currently-tracked entry — used to fan a SessionManager broadcast (session.updated etc.) out to every relay-connected browser session, mirroring server.ts's own wss.clients iteration for the inbound listener. */
|
|
1483
|
+
values() {
|
|
1484
|
+
return [...this.byRelaySessionId.values()];
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
// src/relay/relay-client.ts
|
|
1489
|
+
var RECONNECT_BASE_DELAY_MS = 1e3;
|
|
1490
|
+
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
1491
|
+
function signTranscript(transcript, privateKeyPem) {
|
|
1492
|
+
return sign(null, Buffer.from(canonicalize(transcript), "utf8"), privateKeyPem).toString(
|
|
1493
|
+
"base64"
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
function startRelayClient(deps) {
|
|
1497
|
+
const sessions = new RelaySessionRegistry();
|
|
1498
|
+
let stopped = false;
|
|
1499
|
+
let socket;
|
|
1500
|
+
let reconnectAttempt = 0;
|
|
1501
|
+
let reconnectTimer;
|
|
1502
|
+
function broadcast(message) {
|
|
1503
|
+
for (const entry of sessions.values()) {
|
|
1504
|
+
entry.conn.send(message);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
const routerDeps = { ...deps, broadcast };
|
|
1508
|
+
function revokeSession(relaySessionId) {
|
|
1509
|
+
sessions.get(relaySessionId)?.connectionAuth.revoke();
|
|
1510
|
+
if (socket) send(socket, { type: "relay.closeSession", relaySessionId });
|
|
1511
|
+
sessions.delete(relaySessionId);
|
|
1512
|
+
}
|
|
1513
|
+
function revokeControllerConnections(controllerId) {
|
|
1514
|
+
for (const entry of sessions.values()) {
|
|
1515
|
+
if (entry.connectionAuth.getAuthenticatedIdentity()?.controllerId === controllerId) {
|
|
1516
|
+
revokeSession(entry.relaySessionId);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
function revokeAllConnections() {
|
|
1521
|
+
for (const entry of sessions.values()) {
|
|
1522
|
+
revokeSession(entry.relaySessionId);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
function scheduleReconnect() {
|
|
1526
|
+
if (stopped) return;
|
|
1527
|
+
const base = deps.reconnectBaseDelayMs ?? RECONNECT_BASE_DELAY_MS;
|
|
1528
|
+
const max = deps.reconnectMaxDelayMs ?? RECONNECT_MAX_DELAY_MS;
|
|
1529
|
+
const delay = Math.min(base * 2 ** reconnectAttempt, max);
|
|
1530
|
+
reconnectAttempt += 1;
|
|
1531
|
+
reconnectTimer = setTimeout(() => connect(), delay);
|
|
1532
|
+
deps.onCycleSettled?.();
|
|
1533
|
+
}
|
|
1534
|
+
function send(ws, message) {
|
|
1535
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
|
|
1536
|
+
}
|
|
1537
|
+
function buildRouterConnection(relaySessionId, ws, connectionAuth) {
|
|
1538
|
+
return createRouterConnection(
|
|
1539
|
+
connectionAuth,
|
|
1540
|
+
(message) => {
|
|
1541
|
+
const frame = { relaySessionId, payload: message };
|
|
1542
|
+
send(ws, { type: "relay.frame", frame });
|
|
1543
|
+
},
|
|
1544
|
+
() => {
|
|
1545
|
+
},
|
|
1546
|
+
deps.ptyAttachments
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
function handleSessionOpened(ws, relaySessionId) {
|
|
1550
|
+
if (sessions.get(relaySessionId)) {
|
|
1551
|
+
send(ws, { type: "relay.sessionOpenAck", relaySessionId });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (sessions.isAtCapacity()) {
|
|
1555
|
+
console.error(
|
|
1556
|
+
`relay-client: refusing relay.sessionOpened for ${relaySessionId} \u2014 at MAX_CONCURRENT_RELAY_SESSIONS cap`
|
|
1557
|
+
);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
sessions.create(
|
|
1561
|
+
relaySessionId,
|
|
1562
|
+
deps.daemonFingerprint,
|
|
1563
|
+
(connectionAuth) => buildRouterConnection(relaySessionId, ws, connectionAuth)
|
|
1564
|
+
);
|
|
1565
|
+
const entry = sessions.get(relaySessionId);
|
|
1566
|
+
send(ws, { type: "relay.sessionOpenAck", relaySessionId });
|
|
1567
|
+
const challenge = entry.connectionAuth.getChallenge();
|
|
1568
|
+
entry.conn.send({
|
|
1569
|
+
type: "connection.challenge",
|
|
1570
|
+
connectionId: challenge.connectionId,
|
|
1571
|
+
challenge: challenge.challenge,
|
|
1572
|
+
daemonFingerprint: deps.daemonFingerprint,
|
|
1573
|
+
hostname: deps.hostname(),
|
|
1574
|
+
expiresAt: challenge.expiresAt
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
function handleFrame(frame) {
|
|
1578
|
+
const entry = sessions.get(frame.relaySessionId);
|
|
1579
|
+
if (!entry) return;
|
|
1580
|
+
handleBrowserMessage(entry.conn, frame.payload, routerDeps).catch((err) => {
|
|
1581
|
+
console.error("relay-client: unhandled error in handleBrowserMessage", err);
|
|
1582
|
+
entry.conn.send({
|
|
1583
|
+
type: "error",
|
|
1584
|
+
message: err instanceof Error ? err.message : "internal error"
|
|
1585
|
+
});
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
function connect() {
|
|
1589
|
+
if (stopped) return;
|
|
1590
|
+
const ws = new WebSocket(`${deps.relayUrl}/relay/daemon`);
|
|
1591
|
+
socket = ws;
|
|
1592
|
+
ws.on("message", (raw) => {
|
|
1593
|
+
let parsed;
|
|
1594
|
+
try {
|
|
1595
|
+
parsed = JSON.parse(raw.toString());
|
|
1596
|
+
} catch {
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const result = relayToDaemonMessageSchema.safeParse(parsed);
|
|
1600
|
+
if (!result.success) return;
|
|
1601
|
+
const message = result.data;
|
|
1602
|
+
switch (message.type) {
|
|
1603
|
+
case "relay.challenge": {
|
|
1604
|
+
const transcript = {
|
|
1605
|
+
version: 1,
|
|
1606
|
+
purpose: RELAY_REGISTER_PURPOSE,
|
|
1607
|
+
challenge: message.challenge,
|
|
1608
|
+
daemonFingerprint: deps.daemonFingerprint,
|
|
1609
|
+
issuedAt: Date.now(),
|
|
1610
|
+
expiresAt: message.expiresAt,
|
|
1611
|
+
relayAudience: message.relayAudience
|
|
1612
|
+
};
|
|
1613
|
+
send(ws, {
|
|
1614
|
+
type: "relay.register",
|
|
1615
|
+
signed: {
|
|
1616
|
+
daemonPublicKeyPem: deps.daemonPublicKeyPem,
|
|
1617
|
+
transcript,
|
|
1618
|
+
signature: signTranscript(transcript, deps.daemonPrivateKeyPem)
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
case "relay.registered": {
|
|
1624
|
+
reconnectAttempt = 0;
|
|
1625
|
+
deps.onCycleSettled?.();
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
case "relay.registrationFailed": {
|
|
1629
|
+
console.error(`relay-client: registration failed (${message.reason})`);
|
|
1630
|
+
ws.close();
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
case "relay.sessionOpened": {
|
|
1634
|
+
handleSessionOpened(ws, message.relaySessionId);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
case "relay.sessionClosed": {
|
|
1638
|
+
sessions.delete(message.relaySessionId);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
case "relay.frame": {
|
|
1642
|
+
handleFrame(message.frame);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
ws.on("error", () => {
|
|
1648
|
+
});
|
|
1649
|
+
ws.on("close", () => {
|
|
1650
|
+
sessions.deleteAll();
|
|
1651
|
+
if (socket === ws) socket = void 0;
|
|
1652
|
+
scheduleReconnect();
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
connect();
|
|
1656
|
+
return {
|
|
1657
|
+
stop: () => {
|
|
1658
|
+
stopped = true;
|
|
1659
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1660
|
+
sessions.deleteAll();
|
|
1661
|
+
socket?.close();
|
|
1662
|
+
},
|
|
1663
|
+
broadcast,
|
|
1664
|
+
revokeControllerConnections,
|
|
1665
|
+
revokeAllConnections
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// src/security/daemon-identity.ts
|
|
1670
|
+
import { generateKeyPairSync, sign as sign2, verify as verify4 } from "crypto";
|
|
1671
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
1672
|
+
import { homedir as homedir2 } from "os";
|
|
1673
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1674
|
+
function defaultCredentialsPath() {
|
|
1675
|
+
return join2(homedir2(), ".bondsmith", "credentials");
|
|
1676
|
+
}
|
|
1677
|
+
async function loadOrCreateDaemonIdentity(credentialsPath = defaultCredentialsPath()) {
|
|
1678
|
+
const existing = await tryReadIdentity(credentialsPath);
|
|
1679
|
+
if (existing) return existing;
|
|
1680
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
1681
|
+
const identity = {
|
|
1682
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1683
|
+
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString()
|
|
1684
|
+
};
|
|
1685
|
+
await persistIdentity(credentialsPath, identity);
|
|
1686
|
+
return identity;
|
|
1687
|
+
}
|
|
1688
|
+
async function persistIdentity(credentialsPath, identity) {
|
|
1689
|
+
await mkdir2(dirname2(credentialsPath), { recursive: true });
|
|
1690
|
+
const tmpPath = `${credentialsPath}.tmp-${process.pid}-${Date.now()}`;
|
|
1691
|
+
try {
|
|
1692
|
+
await writeFile2(tmpPath, `${identity.privateKeyPem}${identity.publicKeyPem}`, { mode: 384 });
|
|
1693
|
+
await rename2(tmpPath, credentialsPath);
|
|
1694
|
+
} catch (error2) {
|
|
1695
|
+
await rm2(tmpPath, { force: true });
|
|
1696
|
+
throw error2;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
async function tryReadIdentity(credentialsPath) {
|
|
1700
|
+
let raw;
|
|
1701
|
+
try {
|
|
1702
|
+
raw = await readFile2(credentialsPath, "utf8");
|
|
1703
|
+
} catch {
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
const privateKeyPem = extractPemBlock(raw, "PRIVATE KEY");
|
|
1707
|
+
const publicKeyPem = extractPemBlock(raw, "PUBLIC KEY");
|
|
1708
|
+
if (!privateKeyPem || !publicKeyPem) {
|
|
1709
|
+
throw new Error(
|
|
1710
|
+
`daemon-identity: credentials file at ${credentialsPath} exists but is missing a PEM block \u2014 refusing to silently regenerate a file that may already be paired. Move or delete it manually to re-pair.`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
if (!isMatchingKeyPair(publicKeyPem, privateKeyPem)) {
|
|
1714
|
+
throw new Error(
|
|
1715
|
+
`daemon-identity: credentials file at ${credentialsPath} has a private key and public key that don't correspond to the same keypair \u2014 refusing to use a mismatched identity. Move or delete it manually to re-pair.`
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
return { publicKeyPem, privateKeyPem };
|
|
1719
|
+
}
|
|
1720
|
+
function isMatchingKeyPair(publicKeyPem, privateKeyPem) {
|
|
1721
|
+
const probe = Buffer.from("daemon-identity-keypair-check");
|
|
1722
|
+
try {
|
|
1723
|
+
const signature = sign2(null, probe, privateKeyPem);
|
|
1724
|
+
return verify4(null, probe, publicKeyPem, signature);
|
|
1725
|
+
} catch {
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
function extractPemBlock(text, label) {
|
|
1730
|
+
const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----\\n?`);
|
|
1731
|
+
return text.match(pattern)?.[0] ?? null;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// src/security/dev-tunnel-token-file.ts
|
|
1735
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1736
|
+
import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
1737
|
+
import { homedir as homedir3 } from "os";
|
|
1738
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
1739
|
+
function defaultDevTunnelTokenPath() {
|
|
1740
|
+
return join3(homedir3(), ".bondsmith", "dev-tunnel-token");
|
|
1741
|
+
}
|
|
1742
|
+
async function loadOrCreateDevTunnelToken(tokenPath = defaultDevTunnelTokenPath()) {
|
|
1743
|
+
const existing = await tryReadToken(tokenPath);
|
|
1744
|
+
if (existing) return existing;
|
|
1745
|
+
const token = randomBytes2(32).toString("base64url");
|
|
1746
|
+
await persistToken(tokenPath, token);
|
|
1747
|
+
return token;
|
|
1748
|
+
}
|
|
1749
|
+
async function tryReadToken(tokenPath) {
|
|
1750
|
+
try {
|
|
1751
|
+
const raw = await readFile3(tokenPath, "utf8");
|
|
1752
|
+
const trimmed = raw.trim();
|
|
1753
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1754
|
+
} catch {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
async function persistToken(tokenPath, token) {
|
|
1759
|
+
await mkdir3(dirname3(tokenPath), { recursive: true });
|
|
1760
|
+
const tmpPath = `${tokenPath}.tmp-${process.pid}-${Date.now()}`;
|
|
1761
|
+
try {
|
|
1762
|
+
await writeFile3(tmpPath, token, { mode: 384 });
|
|
1763
|
+
await rename3(tmpPath, tokenPath);
|
|
1764
|
+
} catch (error2) {
|
|
1765
|
+
await rm3(tmpPath, { force: true });
|
|
1766
|
+
throw error2;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// src/security/pending-authorizations.ts
|
|
1771
|
+
import { createHash, randomUUID } from "crypto";
|
|
1772
|
+
function controllerIdFromPublicKeySync(publicKeyPem) {
|
|
1773
|
+
return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
|
|
1774
|
+
}
|
|
1775
|
+
var REQUEST_EXPIRY_MS = 5 * 6e4;
|
|
1776
|
+
var MAX_REQUESTS_PER_WINDOW = 5;
|
|
1777
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1778
|
+
var PendingAuthorizationStore = class {
|
|
1779
|
+
// Invariant: at most one PendingEntry per controllerId at any time. A
|
|
1780
|
+
// conflicting requestEnrollment call for a controllerId that already has
|
|
1781
|
+
// a live (unsettled) entry coalesces onto it rather than replacing it —
|
|
1782
|
+
// there is never a window where two different PendingAuthorization
|
|
1783
|
+
// instances for the same controllerId both hold outstanding promises,
|
|
1784
|
+
// which is exactly what would let a late response to a stale request
|
|
1785
|
+
// resolve a newer, unrelated one.
|
|
1786
|
+
pending = /* @__PURE__ */ new Map();
|
|
1787
|
+
requestTimestamps = /* @__PURE__ */ new Map();
|
|
1788
|
+
isInteractive;
|
|
1789
|
+
trustedControllersPath;
|
|
1790
|
+
constructor(options = {}) {
|
|
1791
|
+
this.isInteractive = options.isInteractive ?? (() => process.stdin.isTTY === true);
|
|
1792
|
+
this.trustedControllersPath = options.trustedControllersPath;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Registers a new enrollment request, or coalesces onto the existing
|
|
1796
|
+
* pending entry if an identical request (same public key) is already
|
|
1797
|
+
* pending — coalescing avoids re-prompting the terminal for a requester
|
|
1798
|
+
* that retries before the user has responded, and shares the exact same
|
|
1799
|
+
* `result` promise so both callers observe the same eventual outcome.
|
|
1800
|
+
*
|
|
1801
|
+
* Fails closed with "not_interactive" if bondsmithd has no TTY attached:
|
|
1802
|
+
* this must never silently fall back to any cloud-side approval path.
|
|
1803
|
+
*
|
|
1804
|
+
* The pending-entry slot (and its result promise/settle function) is
|
|
1805
|
+
* claimed synchronously, before the only `await` in this method
|
|
1806
|
+
* (formatDaemonFingerprint's WebCrypto digest) and before the terminal
|
|
1807
|
+
* prompt is ever displayed — two concurrent requests for the same
|
|
1808
|
+
* public key can otherwise both observe "no existing entry" before
|
|
1809
|
+
* either has written one (server.ts's message handler doesn't await
|
|
1810
|
+
* handleBrowserMessage, so two in-flight calls for the same connection
|
|
1811
|
+
* or two different connections genuinely interleave), each computing
|
|
1812
|
+
* coalesced: false and each triggering a terminal prompt — exactly the
|
|
1813
|
+
* double-prompt the coalescing check exists to prevent. Registering the
|
|
1814
|
+
* promise before the prompt is displayed also closes a narrower race: a
|
|
1815
|
+
* caller awaiting `result` immediately after a synchronous-looking
|
|
1816
|
+
* `requestEnrollment` return could otherwise miss an extremely fast
|
|
1817
|
+
* resolution.
|
|
1818
|
+
*/
|
|
1819
|
+
async requestEnrollment(params) {
|
|
1820
|
+
if (!this.isInteractive()) {
|
|
1821
|
+
return { ok: false, reason: "not_interactive" };
|
|
1822
|
+
}
|
|
1823
|
+
const controllerId = controllerIdFromPublicKeySync(params.publicKeyPem);
|
|
1824
|
+
const existing = this.pending.get(controllerId);
|
|
1825
|
+
if (existing && !existing.settled && existing.authorization.expiresAt > Date.now()) {
|
|
1826
|
+
return {
|
|
1827
|
+
ok: true,
|
|
1828
|
+
pending: existing.authorization,
|
|
1829
|
+
coalesced: true,
|
|
1830
|
+
result: existing.resultPromise
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
if (this.isRateLimited(controllerId)) {
|
|
1834
|
+
return { ok: false, reason: "rate_limited" };
|
|
1835
|
+
}
|
|
1836
|
+
this.recordRequestTimestamp(controllerId);
|
|
1837
|
+
const now = Date.now();
|
|
1838
|
+
const authorization = {
|
|
1839
|
+
controllerId,
|
|
1840
|
+
publicKeyPem: params.publicKeyPem,
|
|
1841
|
+
label: params.label,
|
|
1842
|
+
fingerprint: "",
|
|
1843
|
+
requestedAt: now,
|
|
1844
|
+
expiresAt: now + REQUEST_EXPIRY_MS,
|
|
1845
|
+
requestToken: randomUUID()
|
|
1846
|
+
};
|
|
1847
|
+
let settleFn;
|
|
1848
|
+
const resultPromise = new Promise((resolve) => {
|
|
1849
|
+
settleFn = resolve;
|
|
1850
|
+
});
|
|
1851
|
+
const entry = {
|
|
1852
|
+
authorization,
|
|
1853
|
+
resultPromise,
|
|
1854
|
+
settle: (outcome) => {
|
|
1855
|
+
if (entry.settled) return;
|
|
1856
|
+
entry.settled = true;
|
|
1857
|
+
clearTimeout(entry.expiryTimer);
|
|
1858
|
+
settleFn(outcome);
|
|
1859
|
+
},
|
|
1860
|
+
settled: false,
|
|
1861
|
+
// Fails closed on its own if nobody ever answers — resolve() is
|
|
1862
|
+
// never called (e.g. bondsmithd exits before the user responds
|
|
1863
|
+
// would already tear this down via shutdown handling; this timer
|
|
1864
|
+
// covers the plain "nobody typed anything" case).
|
|
1865
|
+
expiryTimer: setTimeout(() => {
|
|
1866
|
+
this.pending.delete(controllerId);
|
|
1867
|
+
entry.settle("expired");
|
|
1868
|
+
}, REQUEST_EXPIRY_MS)
|
|
1869
|
+
};
|
|
1870
|
+
this.pending.set(controllerId, entry);
|
|
1871
|
+
authorization.fingerprint = await formatDaemonFingerprint(params.publicKeyPem);
|
|
1872
|
+
return { ok: true, pending: authorization, coalesced: false, result: resultPromise };
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Resolves a pending authorization. On approve, mutates the trusted set
|
|
1876
|
+
* (persisting the exact publicKeyPem/label captured at request time —
|
|
1877
|
+
* the same values whose fingerprint was shown to the user, so there is
|
|
1878
|
+
* no gap between what was displayed and what gets trusted); on deny or
|
|
1879
|
+
* expiry, just drops the pending entry. Callers must not surface a
|
|
1880
|
+
* distinguishable signal to the ORIGINAL REQUESTER (e.g. over WS, for
|
|
1881
|
+
* second-controller enrollment) between denied/expired/still-pending —
|
|
1882
|
+
* the requester's next enrollment attempt or command simply continues
|
|
1883
|
+
* to see no trust. This is separate from the `result` promise returned
|
|
1884
|
+
* by requestEnrollment, which DOES carry the explicit outcome — that
|
|
1885
|
+
* promise is for the daemon-local caller driving the approval flow
|
|
1886
|
+
* (e.g. initial pairing blocking on it), never relayed back over the
|
|
1887
|
+
* network to the browser that requested enrollment.
|
|
1888
|
+
*
|
|
1889
|
+
* `requestToken` must match the token on the PendingAuthorization this
|
|
1890
|
+
* caller was originally handed (from requestEnrollment's `pending`
|
|
1891
|
+
* field) — a stale/late answer for a request that has since expired or
|
|
1892
|
+
* been replaced by a new one for the same controllerId will find the
|
|
1893
|
+
* token no longer matches the current entry and safely no-op, rather
|
|
1894
|
+
* than approving whatever request happens to be live under that
|
|
1895
|
+
* controllerId now. This is the fix for a real race: two requests for
|
|
1896
|
+
* the same public key, one expired and later answered, must never be
|
|
1897
|
+
* able to approve the other.
|
|
1898
|
+
*/
|
|
1899
|
+
async resolve(controllerId, requestToken, approved) {
|
|
1900
|
+
const entry = this.pending.get(controllerId);
|
|
1901
|
+
if (!entry || entry.settled || entry.authorization.requestToken !== requestToken) return;
|
|
1902
|
+
this.pending.delete(controllerId);
|
|
1903
|
+
if (entry.authorization.expiresAt <= Date.now()) {
|
|
1904
|
+
entry.settle("expired");
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (approved) {
|
|
1908
|
+
await addTrustedController(
|
|
1909
|
+
{ publicKeyPem: entry.authorization.publicKeyPem, label: entry.authorization.label },
|
|
1910
|
+
this.trustedControllersPath
|
|
1911
|
+
);
|
|
1912
|
+
entry.settle("approved");
|
|
1913
|
+
} else {
|
|
1914
|
+
entry.settle("denied");
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Settles every still-pending authorization as "cancelled" — called on
|
|
1919
|
+
* daemon shutdown or when the terminal/stdin closes mid-prompt, so any
|
|
1920
|
+
* caller blocking on a `result` promise (initial pairing, in
|
|
1921
|
+
* particular) fails closed rather than hanging forever.
|
|
1922
|
+
*/
|
|
1923
|
+
cancelAll() {
|
|
1924
|
+
for (const [controllerId, entry] of this.pending) {
|
|
1925
|
+
this.pending.delete(controllerId);
|
|
1926
|
+
entry.settle("cancelled");
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
isRateLimited(controllerId) {
|
|
1930
|
+
const timestamps = this.requestTimestamps.get(controllerId) ?? [];
|
|
1931
|
+
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
1932
|
+
const recent = timestamps.filter((t) => t > windowStart);
|
|
1933
|
+
return recent.length >= MAX_REQUESTS_PER_WINDOW;
|
|
1934
|
+
}
|
|
1935
|
+
recordRequestTimestamp(controllerId) {
|
|
1936
|
+
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
1937
|
+
const timestamps = (this.requestTimestamps.get(controllerId) ?? []).filter(
|
|
1938
|
+
(t) => t > windowStart
|
|
1939
|
+
);
|
|
1940
|
+
timestamps.push(Date.now());
|
|
1941
|
+
this.requestTimestamps.set(controllerId, timestamps);
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
// src/security/revocation-sync.ts
|
|
1946
|
+
var POLL_INTERVAL_MS = 3e4;
|
|
1947
|
+
var POLL_JITTER_MS = 6e3;
|
|
1948
|
+
var POLL_REQUEST_TIMEOUT_MS = 1e4;
|
|
1949
|
+
var BACKOFF_BASE_MS = 2e3;
|
|
1950
|
+
var BACKOFF_MAX_MS = 2e4;
|
|
1951
|
+
var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
|
|
1952
|
+
function jitteredInterval(deps) {
|
|
1953
|
+
const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
1954
|
+
const jitter = deps.pollJitterMs ?? POLL_JITTER_MS;
|
|
1955
|
+
return interval + Math.floor(Math.random() * jitter);
|
|
1956
|
+
}
|
|
1957
|
+
async function withTimeout(promise, ms) {
|
|
1958
|
+
let timer;
|
|
1959
|
+
const timeout = new Promise((_, reject) => {
|
|
1960
|
+
timer = setTimeout(() => reject(new TRPCCallError("revocation poll timed out", "timeout")), ms);
|
|
1961
|
+
});
|
|
1962
|
+
try {
|
|
1963
|
+
return await Promise.race([promise, timeout]);
|
|
1964
|
+
} finally {
|
|
1965
|
+
clearTimeout(timer);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
async function pollOnce(deps) {
|
|
1969
|
+
const trusted = await loadTrustedControllers(deps.trustedControllersPath);
|
|
1970
|
+
if (trusted.length === 0) return true;
|
|
1971
|
+
let response;
|
|
1972
|
+
try {
|
|
1973
|
+
response = await withTimeout(
|
|
1974
|
+
callTRPCQuery(
|
|
1975
|
+
deps.apiUrl,
|
|
1976
|
+
"daemonPairing.checkRevocationStatus",
|
|
1977
|
+
{
|
|
1978
|
+
daemonPublicKey: deps.daemonPublicKey,
|
|
1979
|
+
controllerPublicKeys: trusted.map((c) => c.publicKeyPem)
|
|
1980
|
+
}
|
|
1981
|
+
),
|
|
1982
|
+
deps.pollRequestTimeoutMs ?? POLL_REQUEST_TIMEOUT_MS
|
|
1983
|
+
);
|
|
1984
|
+
} catch {
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
if (response.pairingRevoked) {
|
|
1988
|
+
await revokeAllTrustedControllers(deps.trustedControllersPath);
|
|
1989
|
+
deps.revokeAllConnections();
|
|
1990
|
+
return true;
|
|
1991
|
+
}
|
|
1992
|
+
for (const publicKeyPem of response.revokedControllerPublicKeys) {
|
|
1993
|
+
const controllerId = await controllerIdFromPublicKeyPem(publicKeyPem);
|
|
1994
|
+
await revokeTrustedController(controllerId, deps.trustedControllersPath);
|
|
1995
|
+
deps.revokeControllerConnections(controllerId);
|
|
1996
|
+
}
|
|
1997
|
+
return true;
|
|
1998
|
+
}
|
|
1999
|
+
function startRevocationSync(deps) {
|
|
2000
|
+
let stopped = false;
|
|
2001
|
+
let timer;
|
|
2002
|
+
const status = {
|
|
2003
|
+
lastSuccessfulSyncAt: null,
|
|
2004
|
+
degraded: false,
|
|
2005
|
+
consecutiveFailures: 0
|
|
2006
|
+
};
|
|
2007
|
+
function reportStatus() {
|
|
2008
|
+
deps.onStatusChange?.({ ...status });
|
|
2009
|
+
}
|
|
2010
|
+
async function tick() {
|
|
2011
|
+
if (stopped) return;
|
|
2012
|
+
let succeeded;
|
|
2013
|
+
try {
|
|
2014
|
+
succeeded = await pollOnce(deps);
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
console.error("revocation-sync: unexpected error during poll", err);
|
|
2017
|
+
succeeded = false;
|
|
2018
|
+
}
|
|
2019
|
+
if (stopped) return;
|
|
2020
|
+
if (succeeded) {
|
|
2021
|
+
status.lastSuccessfulSyncAt = /* @__PURE__ */ new Date();
|
|
2022
|
+
status.degraded = false;
|
|
2023
|
+
status.consecutiveFailures = 0;
|
|
2024
|
+
reportStatus();
|
|
2025
|
+
timer = setTimeout(() => void tickAndReport(), jitteredInterval(deps));
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
status.consecutiveFailures += 1;
|
|
2029
|
+
status.degraded = status.consecutiveFailures >= 2;
|
|
2030
|
+
reportStatus();
|
|
2031
|
+
const backoffBase = deps.backoffBaseMs ?? BACKOFF_BASE_MS;
|
|
2032
|
+
const backoffMax = deps.backoffMaxMs ?? BACKOFF_MAX_MS;
|
|
2033
|
+
const backoffIndex = Math.min(status.consecutiveFailures, MAX_CONSECUTIVE_FAILURE_BACKOFFS);
|
|
2034
|
+
const backoffMs = Math.min(backoffBase * 2 ** (backoffIndex - 1), backoffMax);
|
|
2035
|
+
timer = setTimeout(() => void tickAndReport(), backoffMs);
|
|
2036
|
+
}
|
|
2037
|
+
async function tickAndReport() {
|
|
2038
|
+
await tick();
|
|
2039
|
+
deps.onTickSettled?.();
|
|
2040
|
+
}
|
|
2041
|
+
void tickAndReport();
|
|
2042
|
+
return {
|
|
2043
|
+
stop: () => {
|
|
2044
|
+
stopped = true;
|
|
2045
|
+
if (timer) clearTimeout(timer);
|
|
2046
|
+
}
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// src/security/terminal-approval.ts
|
|
2051
|
+
import { createInterface } from "readline";
|
|
2052
|
+
function presentPendingAuthorizationOnTerminal(store) {
|
|
2053
|
+
return (pending) => {
|
|
2054
|
+
console.log("");
|
|
2055
|
+
console.log(" Bondsmith controller authorization request");
|
|
2056
|
+
console.log(` Browser: ${pending.label}`);
|
|
2057
|
+
console.log(` Fingerprint: ${pending.fingerprint}`);
|
|
2058
|
+
console.log("");
|
|
2059
|
+
console.log(" Allow this browser to control local sessions? [y/N]");
|
|
2060
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2061
|
+
rl.question(" > ", (answer) => {
|
|
2062
|
+
rl.close();
|
|
2063
|
+
const approved = answer.trim().toLowerCase() === "y";
|
|
2064
|
+
store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
|
|
2065
|
+
console.error("Failed to record controller authorization decision", error2);
|
|
2066
|
+
});
|
|
2067
|
+
console.log(approved ? " Approved." : " Denied.");
|
|
2068
|
+
console.log("");
|
|
2069
|
+
});
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// src/sessions/git/client.ts
|
|
2074
|
+
import { execFile as execFileCb2 } from "child_process";
|
|
2075
|
+
import { promisify as promisify2 } from "util";
|
|
2076
|
+
var execFile2 = promisify2(execFileCb2);
|
|
2077
|
+
var GitNotARepoError = class extends Error {
|
|
2078
|
+
constructor() {
|
|
2079
|
+
super("not a git repository");
|
|
2080
|
+
this.name = "GitNotARepoError";
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
function isNotARepoError(error2) {
|
|
2084
|
+
if (typeof error2 !== "object" || error2 === null) return false;
|
|
2085
|
+
const stderr = error2.stderr;
|
|
2086
|
+
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
2087
|
+
}
|
|
2088
|
+
async function runGit(args, cwd) {
|
|
2089
|
+
try {
|
|
2090
|
+
const { stdout } = await execFile2("git", args, { cwd });
|
|
2091
|
+
return stdout;
|
|
2092
|
+
} catch (error2) {
|
|
2093
|
+
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
2094
|
+
throw error2;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
2098
|
+
try {
|
|
2099
|
+
const { stdout } = await execFile2("git", args, { cwd });
|
|
2100
|
+
return stdout;
|
|
2101
|
+
} catch (error2) {
|
|
2102
|
+
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
2103
|
+
const code = error2.code;
|
|
2104
|
+
const stdout = error2.stdout;
|
|
2105
|
+
if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
|
|
2106
|
+
return stdout;
|
|
2107
|
+
}
|
|
2108
|
+
throw error2;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// src/sessions/git/diff.ts
|
|
2113
|
+
async function getWorkspaceDiff(cwd, path, staged) {
|
|
2114
|
+
const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
|
|
2115
|
+
const numstat = await runGit(numstatArgs, cwd);
|
|
2116
|
+
const binary = /^(-\t-\t)/.test(numstat.trim());
|
|
2117
|
+
if (binary) {
|
|
2118
|
+
return { path, staged, patch: null, binary: true };
|
|
2119
|
+
}
|
|
2120
|
+
const diffArgs = staged ? ["diff", "--cached", "--", path] : ["diff", "--", path];
|
|
2121
|
+
let patch = await runGit(diffArgs, cwd);
|
|
2122
|
+
if (patch.trim() === "" && !staged) {
|
|
2123
|
+
patch = await runGitAllowingExitCodes(["diff", "--no-index", "--", "/dev/null", path], cwd, [
|
|
2124
|
+
1
|
|
2125
|
+
]).catch(() => "");
|
|
2126
|
+
}
|
|
2127
|
+
return { path, staged, patch: patch || null, binary: false };
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// src/sessions/git/status.ts
|
|
2131
|
+
async function getCurrentBranch(cwd) {
|
|
2132
|
+
try {
|
|
2133
|
+
const out = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim();
|
|
2134
|
+
return out === "" || out === "HEAD" ? null : out;
|
|
2135
|
+
} catch (err) {
|
|
2136
|
+
if (err instanceof GitNotARepoError) return null;
|
|
2137
|
+
throw err;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
async function getRepositoryFullName(cwd) {
|
|
2141
|
+
let url;
|
|
2142
|
+
try {
|
|
2143
|
+
url = (await runGit(["remote", "get-url", "origin"], cwd)).trim();
|
|
2144
|
+
} catch {
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
if (!url) return null;
|
|
2148
|
+
const match = /[:/]([^/:]+\/[^/]+?)(\.git)?$/.exec(url);
|
|
2149
|
+
return match?.[1] ?? null;
|
|
2150
|
+
}
|
|
2151
|
+
async function isGitRepo(cwd) {
|
|
2152
|
+
try {
|
|
2153
|
+
await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
2154
|
+
return true;
|
|
2155
|
+
} catch {
|
|
2156
|
+
return false;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
function parseNumstat(output) {
|
|
2160
|
+
const map = /* @__PURE__ */ new Map();
|
|
2161
|
+
const fields = output.split("\0").filter((f) => f.length > 0);
|
|
2162
|
+
let i = 0;
|
|
2163
|
+
while (i < fields.length) {
|
|
2164
|
+
const line = fields[i];
|
|
2165
|
+
const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(line);
|
|
2166
|
+
if (!match) {
|
|
2167
|
+
i++;
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
const [, addStr, delStr, path] = match;
|
|
2171
|
+
const binary = addStr === "-" || delStr === "-";
|
|
2172
|
+
let finalPath = path ?? "";
|
|
2173
|
+
if (finalPath === "") {
|
|
2174
|
+
i++;
|
|
2175
|
+
i++;
|
|
2176
|
+
finalPath = fields[i] ?? "";
|
|
2177
|
+
}
|
|
2178
|
+
map.set(finalPath, {
|
|
2179
|
+
additions: binary ? null : Number(addStr),
|
|
2180
|
+
deletions: binary ? null : Number(delStr),
|
|
2181
|
+
binary
|
|
2182
|
+
});
|
|
2183
|
+
i++;
|
|
2184
|
+
}
|
|
2185
|
+
return map;
|
|
2186
|
+
}
|
|
2187
|
+
var STATUS_CODE_MAP = {
|
|
2188
|
+
A: "added",
|
|
2189
|
+
M: "modified",
|
|
2190
|
+
D: "deleted",
|
|
2191
|
+
R: "renamed",
|
|
2192
|
+
C: "copied",
|
|
2193
|
+
U: "modified"
|
|
2194
|
+
// unmerged — treated as modified, no conflict-resolution UI in scope
|
|
2195
|
+
};
|
|
2196
|
+
async function getWorkspaceStatus(cwd) {
|
|
2197
|
+
const branch = await getCurrentBranch(cwd);
|
|
2198
|
+
if (branch === null && !await isGitRepo(cwd)) {
|
|
2199
|
+
return { isGitRepo: false, branch: null, files: [] };
|
|
2200
|
+
}
|
|
2201
|
+
const [porcelain, unstagedRaw, stagedRaw] = await Promise.all([
|
|
2202
|
+
runGit(["status", "--porcelain=v1", "-z"], cwd),
|
|
2203
|
+
runGit(["diff", "--numstat", "-z"], cwd),
|
|
2204
|
+
runGit(["diff", "--cached", "--numstat", "-z"], cwd)
|
|
2205
|
+
]);
|
|
2206
|
+
const unstagedNumstat = parseNumstat(unstagedRaw);
|
|
2207
|
+
const stagedNumstat = parseNumstat(stagedRaw);
|
|
2208
|
+
const files = parsePorcelain(porcelain, unstagedNumstat, stagedNumstat);
|
|
2209
|
+
return { isGitRepo: true, branch, files };
|
|
2210
|
+
}
|
|
2211
|
+
function parsePorcelain(porcelain, unstagedNumstat, stagedNumstat) {
|
|
2212
|
+
const entries = [];
|
|
2213
|
+
const fields = porcelain.split("\0").filter((f) => f.length > 0);
|
|
2214
|
+
let i = 0;
|
|
2215
|
+
while (i < fields.length) {
|
|
2216
|
+
const raw = fields[i];
|
|
2217
|
+
const x = raw[0] ?? " ";
|
|
2218
|
+
const y = raw[1] ?? " ";
|
|
2219
|
+
const path = raw.slice(3);
|
|
2220
|
+
i++;
|
|
2221
|
+
if (x === "R" || x === "C" || y === "R" || y === "C") {
|
|
2222
|
+
const previousPath = fields[i];
|
|
2223
|
+
i++;
|
|
2224
|
+
if (previousPath === void 0) continue;
|
|
2225
|
+
pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
|
|
2229
|
+
}
|
|
2230
|
+
return entries;
|
|
2231
|
+
}
|
|
2232
|
+
function pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat) {
|
|
2233
|
+
if (x === "?" && y === "?") {
|
|
2234
|
+
const numstat = unstagedNumstat.get(path);
|
|
2235
|
+
entries.push({
|
|
2236
|
+
path,
|
|
2237
|
+
status: "untracked",
|
|
2238
|
+
staged: false,
|
|
2239
|
+
additions: numstat?.additions ?? null,
|
|
2240
|
+
deletions: numstat?.deletions ?? null,
|
|
2241
|
+
binary: numstat?.binary ?? false
|
|
2242
|
+
});
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (x !== " " && x !== "?") {
|
|
2246
|
+
const numstat = stagedNumstat.get(path);
|
|
2247
|
+
entries.push({
|
|
2248
|
+
path,
|
|
2249
|
+
previousPath,
|
|
2250
|
+
status: STATUS_CODE_MAP[x] ?? "modified",
|
|
2251
|
+
staged: true,
|
|
2252
|
+
additions: numstat?.additions ?? null,
|
|
2253
|
+
deletions: numstat?.deletions ?? null,
|
|
2254
|
+
binary: numstat?.binary ?? false
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
if (y !== " " && y !== "?") {
|
|
2258
|
+
const numstat = unstagedNumstat.get(path);
|
|
2259
|
+
entries.push({
|
|
2260
|
+
path,
|
|
2261
|
+
previousPath,
|
|
2262
|
+
status: STATUS_CODE_MAP[y] ?? "modified",
|
|
2263
|
+
staged: false,
|
|
2264
|
+
additions: numstat?.additions ?? null,
|
|
2265
|
+
deletions: numstat?.deletions ?? null,
|
|
2266
|
+
binary: numstat?.binary ?? false
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
async function enrichSessionWithGitMetadata(session) {
|
|
2271
|
+
const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
|
|
2272
|
+
getCurrentBranch(session.cwd),
|
|
2273
|
+
getRepositoryFullName(session.cwd),
|
|
2274
|
+
getWorkspaceStatus(session.cwd),
|
|
2275
|
+
getPaneActivity(session.tmuxSessionName)
|
|
2276
|
+
]);
|
|
2277
|
+
const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
|
|
2278
|
+
return {
|
|
2279
|
+
...session,
|
|
2280
|
+
branch,
|
|
2281
|
+
repositoryFullName,
|
|
2282
|
+
changedFileCount,
|
|
2283
|
+
lastActivityAt
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// src/ws/server.ts
|
|
2288
|
+
import { hostname } from "os";
|
|
2289
|
+
import { WebSocketServer } from "ws";
|
|
2290
|
+
|
|
2291
|
+
// src/security/dev-tunnel-token.ts
|
|
2292
|
+
import { timingSafeEqual } from "crypto";
|
|
2293
|
+
function isValidDevTunnelToken(secWebSocketProtocol, expectedToken) {
|
|
2294
|
+
if (!expectedToken) return false;
|
|
2295
|
+
if (!secWebSocketProtocol) return false;
|
|
2296
|
+
const presented = secWebSocketProtocol.split(",")[0]?.trim();
|
|
2297
|
+
if (!presented) return false;
|
|
2298
|
+
const expected = Buffer.from(expectedToken);
|
|
2299
|
+
const actual = Buffer.from(presented);
|
|
2300
|
+
if (expected.length !== actual.length) return false;
|
|
2301
|
+
return timingSafeEqual(expected, actual);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// src/security/origin-check.ts
|
|
2305
|
+
function isAllowedOrigin(origin, allowlist) {
|
|
2306
|
+
if (!origin) return false;
|
|
2307
|
+
return allowlist.includes(origin);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// src/sessions/pty-attachment.ts
|
|
2311
|
+
import * as pty from "node-pty";
|
|
2312
|
+
var PtyAttachmentManager = class {
|
|
2313
|
+
handles = /* @__PURE__ */ new Map();
|
|
2314
|
+
/**
|
|
2315
|
+
* Attempts to attach (spawning a pty if one isn't already running for this
|
|
2316
|
+
* session). Returns false, without throwing, if the pty could not be
|
|
2317
|
+
* spawned — a spawn failure here must never take down the whole daemon
|
|
2318
|
+
* process, since every other session's pty would die with it.
|
|
2319
|
+
*/
|
|
2320
|
+
async ensureAttached(sessionId, tmuxSessionName, onData) {
|
|
2321
|
+
let handle = this.handles.get(sessionId);
|
|
2322
|
+
if (!handle) {
|
|
2323
|
+
let ptyProcess;
|
|
2324
|
+
try {
|
|
2325
|
+
ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
|
|
2326
|
+
name: "xterm-256color",
|
|
2327
|
+
cols: 80,
|
|
2328
|
+
rows: 24,
|
|
2329
|
+
cwd: process.env.HOME,
|
|
2330
|
+
env: filterSpawnEnv(process.env)
|
|
2331
|
+
});
|
|
2332
|
+
} catch {
|
|
2333
|
+
return false;
|
|
2334
|
+
}
|
|
2335
|
+
handle = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
|
|
2336
|
+
this.handles.set(sessionId, handle);
|
|
2337
|
+
ptyProcess.onData((data) => {
|
|
2338
|
+
for (const listener of handle?.dataListeners ?? []) listener(data);
|
|
2339
|
+
});
|
|
2340
|
+
ptyProcess.onExit(() => {
|
|
2341
|
+
this.handles.delete(sessionId);
|
|
2342
|
+
});
|
|
2343
|
+
handle.refCount += 1;
|
|
2344
|
+
handle.dataListeners.add(onData);
|
|
2345
|
+
return true;
|
|
2346
|
+
}
|
|
2347
|
+
await this.sendPaneSnapshot(tmuxSessionName, onData);
|
|
2348
|
+
handle.refCount += 1;
|
|
2349
|
+
handle.dataListeners.add(onData);
|
|
2350
|
+
return true;
|
|
2351
|
+
}
|
|
2352
|
+
// Swaps a re-attaching connection's listener in place — avoids killing/respawning the live pty (see router.ts). Returns false if no handle exists.
|
|
2353
|
+
async swapListener(sessionId, tmuxSessionName, previousOnData, nextOnData) {
|
|
2354
|
+
const handle = this.handles.get(sessionId);
|
|
2355
|
+
if (!handle) return false;
|
|
2356
|
+
await this.sendPaneSnapshot(tmuxSessionName, nextOnData);
|
|
2357
|
+
handle.dataListeners.delete(previousOnData);
|
|
2358
|
+
handle.dataListeners.add(nextOnData);
|
|
2359
|
+
return true;
|
|
2360
|
+
}
|
|
2361
|
+
// Best-effort: a capture failure just leaves this listener blank until the next real write.
|
|
2362
|
+
async sendPaneSnapshot(tmuxSessionName, onData) {
|
|
2363
|
+
try {
|
|
2364
|
+
onData(await capturePane(tmuxSessionName));
|
|
2365
|
+
} catch {
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
release(sessionId, onData) {
|
|
2369
|
+
const handle = this.handles.get(sessionId);
|
|
2370
|
+
if (!handle) return;
|
|
2371
|
+
handle.dataListeners.delete(onData);
|
|
2372
|
+
handle.refCount -= 1;
|
|
2373
|
+
if (handle.refCount <= 0) {
|
|
2374
|
+
handle.process.kill();
|
|
2375
|
+
this.handles.delete(sessionId);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
write(sessionId, data) {
|
|
2379
|
+
this.handles.get(sessionId)?.process.write(data);
|
|
2380
|
+
}
|
|
2381
|
+
resize(sessionId, cols, rows) {
|
|
2382
|
+
this.handles.get(sessionId)?.process.resize(cols, rows);
|
|
2383
|
+
}
|
|
2384
|
+
isAttached(sessionId) {
|
|
2385
|
+
return this.handles.has(sessionId);
|
|
2386
|
+
}
|
|
2387
|
+
killAll() {
|
|
2388
|
+
for (const handle of this.handles.values()) handle.process.kill();
|
|
2389
|
+
this.handles.clear();
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
// src/sessions/session-manager.ts
|
|
2394
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2395
|
+
|
|
2396
|
+
// src/sessions/tmux/create.ts
|
|
2397
|
+
import { access } from "fs/promises";
|
|
2398
|
+
import { homedir as homedir4 } from "os";
|
|
2399
|
+
import { join as join4 } from "path";
|
|
2400
|
+
var InvalidCwdError = class extends Error {
|
|
2401
|
+
constructor(cwd) {
|
|
2402
|
+
super(`working directory does not exist: ${cwd}`);
|
|
2403
|
+
this.name = "InvalidCwdError";
|
|
2404
|
+
}
|
|
2405
|
+
};
|
|
2406
|
+
function expandHome(cwd) {
|
|
2407
|
+
if (cwd === "~") return homedir4();
|
|
2408
|
+
if (cwd.startsWith("~/")) return join4(homedir4(), cwd.slice(2));
|
|
2409
|
+
return cwd;
|
|
2410
|
+
}
|
|
2411
|
+
async function createTmuxSession(input) {
|
|
2412
|
+
const cwd = expandHome(input.cwd);
|
|
2413
|
+
try {
|
|
2414
|
+
await access(cwd);
|
|
2415
|
+
} catch {
|
|
2416
|
+
throw new InvalidCwdError(input.cwd);
|
|
2417
|
+
}
|
|
2418
|
+
const tmuxSessionName = tmuxSessionNameFor(input.id);
|
|
2419
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2420
|
+
await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, input.command], {
|
|
2421
|
+
env: filterSpawnEnv(process.env)
|
|
2422
|
+
});
|
|
2423
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-id", input.id]);
|
|
2424
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-title", input.title]);
|
|
2425
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
|
|
2426
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-cwd", cwd]);
|
|
2427
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-started-at", startedAt]);
|
|
2428
|
+
const session = {
|
|
2429
|
+
id: input.id,
|
|
2430
|
+
title: input.title,
|
|
2431
|
+
command: input.command,
|
|
2432
|
+
cwd,
|
|
2433
|
+
tmuxSessionName,
|
|
2434
|
+
status: "running",
|
|
2435
|
+
startedAt,
|
|
2436
|
+
branch: null,
|
|
2437
|
+
repositoryFullName: null,
|
|
2438
|
+
lastActivityAt: null,
|
|
2439
|
+
changedFileCount: null
|
|
2440
|
+
};
|
|
2441
|
+
return enrichSessionWithGitMetadata(session);
|
|
2442
|
+
}
|
|
2443
|
+
async function stopTmuxSession(tmuxSessionName) {
|
|
2444
|
+
await runTmux(["kill-session", "-t", tmuxSessionName]);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// src/sessions/tmux/parse.ts
|
|
2448
|
+
var FIELD_COUNT = 6;
|
|
2449
|
+
function parseTmuxSessions(output) {
|
|
2450
|
+
const sessions = [];
|
|
2451
|
+
for (const line of output.split("\n")) {
|
|
2452
|
+
if (!line.trim()) continue;
|
|
2453
|
+
const fields = line.split(" ");
|
|
2454
|
+
if (fields.length < FIELD_COUNT) continue;
|
|
2455
|
+
const [sessionName, id, title, command, cwd, startedAt] = fields;
|
|
2456
|
+
if (!isBondsmithTmuxSessionName(sessionName)) continue;
|
|
2457
|
+
if (!id || !title || !command || !cwd || !startedAt) continue;
|
|
2458
|
+
sessions.push({
|
|
2459
|
+
id,
|
|
2460
|
+
title,
|
|
2461
|
+
command,
|
|
2462
|
+
cwd,
|
|
2463
|
+
tmuxSessionName: sessionName,
|
|
2464
|
+
status: "running",
|
|
2465
|
+
startedAt,
|
|
2466
|
+
branch: null,
|
|
2467
|
+
repositoryFullName: null,
|
|
2468
|
+
lastActivityAt: null,
|
|
2469
|
+
changedFileCount: null
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
return sessions;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// src/sessions/tmux/discover.ts
|
|
2476
|
+
var LIST_FORMAT = [
|
|
2477
|
+
"#{session_name}",
|
|
2478
|
+
"#{@bondsmith-id}",
|
|
2479
|
+
"#{@bondsmith-title}",
|
|
2480
|
+
"#{@bondsmith-command}",
|
|
2481
|
+
"#{@bondsmith-cwd}",
|
|
2482
|
+
"#{@bondsmith-started-at}"
|
|
2483
|
+
].join(" ");
|
|
2484
|
+
async function discoverSessions() {
|
|
2485
|
+
try {
|
|
2486
|
+
const output = await listTmuxSessions(LIST_FORMAT);
|
|
2487
|
+
const sessions = parseTmuxSessions(output);
|
|
2488
|
+
return Promise.all(sessions.map((session) => enrichSessionWithGitMetadata(session)));
|
|
2489
|
+
} catch (error2) {
|
|
2490
|
+
if (error2 instanceof TmuxNoServerRunningError) return [];
|
|
2491
|
+
throw error2;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// src/sessions/session-manager.ts
|
|
2496
|
+
var SessionManager = class {
|
|
2497
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2498
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2499
|
+
async initialize() {
|
|
2500
|
+
const discovered = await discoverSessions();
|
|
2501
|
+
this.sessions.clear();
|
|
2502
|
+
for (const session of discovered) {
|
|
2503
|
+
this.sessions.set(session.id, session);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
list() {
|
|
2507
|
+
return Array.from(this.sessions.values());
|
|
2508
|
+
}
|
|
2509
|
+
get(sessionId) {
|
|
2510
|
+
return this.sessions.get(sessionId);
|
|
2511
|
+
}
|
|
2512
|
+
onSessionUpdated(listener) {
|
|
2513
|
+
this.listeners.add(listener);
|
|
2514
|
+
return () => this.listeners.delete(listener);
|
|
2515
|
+
}
|
|
2516
|
+
emitUpdated(session) {
|
|
2517
|
+
for (const listener of this.listeners) listener(session);
|
|
2518
|
+
}
|
|
2519
|
+
async create(input) {
|
|
2520
|
+
const id = uuidv4();
|
|
2521
|
+
const session = await createTmuxSession({ id, ...input });
|
|
2522
|
+
this.sessions.set(id, session);
|
|
2523
|
+
return session;
|
|
2524
|
+
}
|
|
2525
|
+
async stop(sessionId) {
|
|
2526
|
+
const session = this.sessions.get(sessionId);
|
|
2527
|
+
if (!session) return void 0;
|
|
2528
|
+
try {
|
|
2529
|
+
await stopTmuxSession(session.tmuxSessionName);
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
return this.removeAsExited(session);
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* Drops sessions whose underlying tmux session died *without* an explicit
|
|
2536
|
+
* stop (command inside exited on its own, `tmux kill-server`, a reboot).
|
|
2537
|
+
* tmux is the source of truth and the registry is only a cache over it,
|
|
2538
|
+
* so any cached session that no longer exists in tmux is marked exited
|
|
2539
|
+
* and removed.
|
|
2540
|
+
*
|
|
2541
|
+
* Live sessions get their git metadata re-enriched on the same tick so
|
|
2542
|
+
* branch/repo/changedFileCount track reality instead of the snapshot taken
|
|
2543
|
+
* at create/discover — otherwise the associated-work lookup (open PR whose
|
|
2544
|
+
* head = session.branch) stays pinned to a branch the developer already
|
|
2545
|
+
* left. `session.updated` is only emitted when something actually changed,
|
|
2546
|
+
* so an idle session doesn't broadcast every tick.
|
|
2547
|
+
*/
|
|
2548
|
+
async reconcile() {
|
|
2549
|
+
let liveSessionNames = /* @__PURE__ */ new Set();
|
|
2550
|
+
try {
|
|
2551
|
+
const output = await listTmuxSessions("#{session_name}");
|
|
2552
|
+
liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
|
|
2553
|
+
} catch (error2) {
|
|
2554
|
+
if (!(error2 instanceof TmuxNoServerRunningError)) return;
|
|
2555
|
+
}
|
|
2556
|
+
await Promise.all(
|
|
2557
|
+
Array.from(this.sessions.values()).map(async (session) => {
|
|
2558
|
+
if (!liveSessionNames.has(session.tmuxSessionName)) {
|
|
2559
|
+
this.removeAsExited(session);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
try {
|
|
2563
|
+
const updated = await enrichSessionWithGitMetadata(session);
|
|
2564
|
+
if (!gitSnapshotEquals(session, updated)) {
|
|
2565
|
+
this.sessions.set(session.id, updated);
|
|
2566
|
+
this.emitUpdated(updated);
|
|
2567
|
+
}
|
|
2568
|
+
} catch (error2) {
|
|
2569
|
+
console.error("Session git re-enrichment failed", {
|
|
2570
|
+
tmuxSessionName: session.tmuxSessionName,
|
|
2571
|
+
error: error2
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
})
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
removeAsExited(session) {
|
|
2578
|
+
const updated = { ...session, status: "exited" };
|
|
2579
|
+
this.sessions.delete(session.id);
|
|
2580
|
+
this.emitUpdated(updated);
|
|
2581
|
+
return updated;
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
function gitSnapshotEquals(a, b) {
|
|
2585
|
+
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// src/ws/server.ts
|
|
2589
|
+
function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pendingAuthorizations, presentPendingAuthorization, trustedControllersPath) {
|
|
2590
|
+
const sessionManager = new SessionManager();
|
|
2591
|
+
const ptyAttachments = new PtyAttachmentManager();
|
|
2592
|
+
const connectionsByController = /* @__PURE__ */ new Map();
|
|
2593
|
+
const cleanupBySocket = /* @__PURE__ */ new Map();
|
|
2594
|
+
const connectionAuthBySocket = /* @__PURE__ */ new Map();
|
|
2595
|
+
function registerControllerConnection(controllerId, socket) {
|
|
2596
|
+
let sockets = connectionsByController.get(controllerId);
|
|
2597
|
+
if (!sockets) {
|
|
2598
|
+
sockets = /* @__PURE__ */ new Set();
|
|
2599
|
+
connectionsByController.set(controllerId, sockets);
|
|
2600
|
+
}
|
|
2601
|
+
sockets.add(socket);
|
|
2602
|
+
}
|
|
2603
|
+
function unregisterControllerConnection(socket) {
|
|
2604
|
+
for (const sockets of connectionsByController.values()) {
|
|
2605
|
+
sockets.delete(socket);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
function revokeControllerConnections(controllerId) {
|
|
2609
|
+
const sockets = connectionsByController.get(controllerId);
|
|
2610
|
+
if (!sockets) return;
|
|
2611
|
+
for (const socket of sockets) {
|
|
2612
|
+
connectionAuthBySocket.get(socket)?.revoke();
|
|
2613
|
+
connectionAuthBySocket.delete(socket);
|
|
2614
|
+
cleanupBySocket.get(socket)?.();
|
|
2615
|
+
cleanupBySocket.delete(socket);
|
|
2616
|
+
socket.terminate();
|
|
2617
|
+
}
|
|
2618
|
+
connectionsByController.delete(controllerId);
|
|
2619
|
+
}
|
|
2620
|
+
function revokeAllConnections() {
|
|
2621
|
+
for (const controllerId of [...connectionsByController.keys()]) {
|
|
2622
|
+
revokeControllerConnections(controllerId);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
|
|
2626
|
+
const wss = new WebSocketServer({
|
|
2627
|
+
server: httpServer,
|
|
2628
|
+
// Client always sends the dev-tunnel token as the sole entry (see
|
|
2629
|
+
// isValidDevTunnelToken); echoing it back is required for a spec-
|
|
2630
|
+
// compliant handshake once a Sec-WebSocket-Protocol is offered.
|
|
2631
|
+
handleProtocols: (protocols) => protocols.values().next().value ?? false,
|
|
2632
|
+
verifyClient: (info, done) => {
|
|
2633
|
+
if (!isAllowedOrigin(info.origin, config.allowedOrigins)) {
|
|
2634
|
+
rejectionReasonByRequest.set(info.req, "origin_not_allowed");
|
|
2635
|
+
} else if (!isValidDevTunnelToken(info.req.headers["sec-websocket-protocol"], config.devTunnelToken)) {
|
|
2636
|
+
rejectionReasonByRequest.set(info.req, "token_invalid");
|
|
2637
|
+
}
|
|
2638
|
+
done(true);
|
|
2639
|
+
}
|
|
2640
|
+
});
|
|
2641
|
+
function broadcast(message) {
|
|
2642
|
+
const payload = JSON.stringify(message);
|
|
2643
|
+
for (const client of wss.clients) {
|
|
2644
|
+
if (client.readyState === client.OPEN) client.send(payload);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
sessionManager.onSessionUpdated((session) => {
|
|
2648
|
+
broadcast({ type: "session.updated", session });
|
|
2649
|
+
});
|
|
2650
|
+
wss.on("connection", (socket, request) => {
|
|
2651
|
+
const rejectionReason = rejectionReasonByRequest.get(request);
|
|
2652
|
+
if (rejectionReason) {
|
|
2653
|
+
rejectionReasonByRequest.delete(request);
|
|
2654
|
+
socket.send(JSON.stringify({ type: "connection.rejected", reason: rejectionReason }));
|
|
2655
|
+
socket.close();
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
const connectionAuth = new ConnectionAuthState(daemonFingerprint);
|
|
2659
|
+
const { conn, releaseAllSubscriptions } = createRouterConnection(
|
|
2660
|
+
connectionAuth,
|
|
2661
|
+
(message) => {
|
|
2662
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
2663
|
+
},
|
|
2664
|
+
(controllerId) => registerControllerConnection(controllerId, socket),
|
|
2665
|
+
ptyAttachments
|
|
2666
|
+
);
|
|
2667
|
+
cleanupBySocket.set(socket, releaseAllSubscriptions);
|
|
2668
|
+
connectionAuthBySocket.set(socket, connectionAuth);
|
|
2669
|
+
const challenge = connectionAuth.getChallenge();
|
|
2670
|
+
conn.send({
|
|
2671
|
+
type: "connection.challenge",
|
|
2672
|
+
connectionId: challenge.connectionId,
|
|
2673
|
+
challenge: challenge.challenge,
|
|
2674
|
+
daemonFingerprint,
|
|
2675
|
+
hostname: hostname(),
|
|
2676
|
+
expiresAt: challenge.expiresAt
|
|
2677
|
+
});
|
|
2678
|
+
socket.on("message", (raw) => {
|
|
2679
|
+
let parsed;
|
|
2680
|
+
try {
|
|
2681
|
+
parsed = JSON.parse(raw.toString());
|
|
2682
|
+
} catch {
|
|
2683
|
+
conn.send({ type: "error", message: "invalid JSON" });
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
handleBrowserMessage(conn, parsed, {
|
|
2687
|
+
sessionManager,
|
|
2688
|
+
ptyAttachments,
|
|
2689
|
+
broadcast,
|
|
2690
|
+
getWorkspaceStatus,
|
|
2691
|
+
getWorkspaceDiff,
|
|
2692
|
+
hostname: () => hostname(),
|
|
2693
|
+
daemonFingerprint,
|
|
2694
|
+
pendingAuthorizations,
|
|
2695
|
+
presentPendingAuthorization,
|
|
2696
|
+
trustedControllersPath
|
|
2697
|
+
}).catch((err) => {
|
|
2698
|
+
console.error("Unhandled error in handleBrowserMessage", err);
|
|
2699
|
+
conn.send({
|
|
2700
|
+
type: "error",
|
|
2701
|
+
message: err instanceof Error ? err.message : "internal error"
|
|
2702
|
+
});
|
|
2703
|
+
});
|
|
2704
|
+
});
|
|
2705
|
+
socket.on("close", () => {
|
|
2706
|
+
releaseAllSubscriptions();
|
|
2707
|
+
cleanupBySocket.delete(socket);
|
|
2708
|
+
connectionAuthBySocket.delete(socket);
|
|
2709
|
+
unregisterControllerConnection(socket);
|
|
2710
|
+
});
|
|
2711
|
+
});
|
|
2712
|
+
return {
|
|
2713
|
+
sessionManager,
|
|
2714
|
+
ptyAttachments,
|
|
2715
|
+
wss,
|
|
2716
|
+
// Exposed for revocation-sync.ts (BD-434) — the poll loop calls these
|
|
2717
|
+
// once it observes a controller/pairing was revoked in the cloud, so
|
|
2718
|
+
// an already-open connection is torn down immediately rather than
|
|
2719
|
+
// only failing its next signed command.
|
|
2720
|
+
revokeControllerConnections,
|
|
2721
|
+
revokeAllConnections
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// src/index.ts
|
|
2726
|
+
function loadRepoRootEnv() {
|
|
2727
|
+
const repoRoot = join5(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
2728
|
+
if (!existsSync(join5(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
2729
|
+
const envLocal = join5(repoRoot, ".env.local");
|
|
2730
|
+
const envDefault = join5(repoRoot, ".env");
|
|
2731
|
+
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
2732
|
+
}
|
|
2733
|
+
loadRepoRootEnv();
|
|
2734
|
+
async function start() {
|
|
2735
|
+
let config = getDaemonConfig();
|
|
2736
|
+
if (!config.devTunnelToken) {
|
|
2737
|
+
const generatedToken = await loadOrCreateDevTunnelToken();
|
|
2738
|
+
config = { ...config, devTunnelToken: generatedToken };
|
|
2739
|
+
}
|
|
2740
|
+
const identity = await loadOrCreateDaemonIdentity();
|
|
2741
|
+
const daemonFingerprint = await formatDaemonFingerprint(identity.publicKeyPem);
|
|
2742
|
+
const pendingAuthorizations = new PendingAuthorizationStore();
|
|
2743
|
+
const presentPendingAuthorization = presentPendingAuthorizationOnTerminal(pendingAuthorizations);
|
|
2744
|
+
await ensurePaired({
|
|
2745
|
+
apiUrl: config.apiUrl,
|
|
2746
|
+
publicKeyPem: identity.publicKeyPem,
|
|
2747
|
+
hostname: hostname2(),
|
|
2748
|
+
pendingAuthorizations,
|
|
2749
|
+
presentPendingAuthorization,
|
|
2750
|
+
onPairingCode: ({ code, pairingUrl, fingerprint }) => {
|
|
2751
|
+
console.log("");
|
|
2752
|
+
console.log(" bondsmithd needs to be paired with your Bondsmith account.");
|
|
2753
|
+
console.log("");
|
|
2754
|
+
console.log(` Pairing code: ${code}`);
|
|
2755
|
+
console.log(` Confirm at: ${pairingUrl}`);
|
|
2756
|
+
console.log(` Fingerprint: ${fingerprint}`);
|
|
2757
|
+
console.log("");
|
|
2758
|
+
console.log(" Verify the fingerprint shown above matches what the confirmation");
|
|
2759
|
+
console.log(" page displays before confirming \u2014 a mismatch means the code being");
|
|
2760
|
+
console.log(" confirmed did not come from this machine.");
|
|
2761
|
+
console.log("");
|
|
2762
|
+
openBrowser(pairingUrl);
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
const httpServer = createServer();
|
|
2766
|
+
const { sessionManager, ptyAttachments, revokeControllerConnections, revokeAllConnections } = createDaemonWebSocketServer(
|
|
2767
|
+
httpServer,
|
|
2768
|
+
config,
|
|
2769
|
+
daemonFingerprint,
|
|
2770
|
+
pendingAuthorizations,
|
|
2771
|
+
presentPendingAuthorization
|
|
2772
|
+
);
|
|
2773
|
+
await sessionManager.initialize();
|
|
2774
|
+
const relayClient = config.relayEnabled ? startRelayClient({
|
|
2775
|
+
relayUrl: config.relayUrl,
|
|
2776
|
+
daemonFingerprint,
|
|
2777
|
+
daemonPublicKeyPem: identity.publicKeyPem,
|
|
2778
|
+
daemonPrivateKeyPem: identity.privateKeyPem,
|
|
2779
|
+
sessionManager,
|
|
2780
|
+
ptyAttachments,
|
|
2781
|
+
getWorkspaceStatus,
|
|
2782
|
+
getWorkspaceDiff,
|
|
2783
|
+
hostname: hostname2,
|
|
2784
|
+
pendingAuthorizations,
|
|
2785
|
+
presentPendingAuthorization
|
|
2786
|
+
}) : void 0;
|
|
2787
|
+
if (relayClient) {
|
|
2788
|
+
sessionManager.onSessionUpdated((session) => {
|
|
2789
|
+
relayClient.broadcast({ type: "session.updated", session });
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
const revocationSync = startRevocationSync({
|
|
2793
|
+
apiUrl: config.apiUrl,
|
|
2794
|
+
daemonPublicKey: identity.publicKeyPem,
|
|
2795
|
+
revokeControllerConnections: (controllerId) => {
|
|
2796
|
+
revokeControllerConnections(controllerId);
|
|
2797
|
+
relayClient?.revokeControllerConnections(controllerId);
|
|
2798
|
+
},
|
|
2799
|
+
revokeAllConnections: () => {
|
|
2800
|
+
revokeAllConnections();
|
|
2801
|
+
relayClient?.revokeAllConnections();
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
const RECONCILE_INTERVAL_MS = 5e3;
|
|
2805
|
+
const reconcileTimer = setInterval(() => {
|
|
2806
|
+
sessionManager.reconcile().catch((error2) => {
|
|
2807
|
+
console.error("Session reconcile failed", error2);
|
|
2808
|
+
});
|
|
2809
|
+
}, RECONCILE_INTERVAL_MS);
|
|
2810
|
+
httpServer.listen(config.port, config.bindHost, () => {
|
|
2811
|
+
console.log(`bondsmithd listening on ${config.bindHost}:${config.port}`);
|
|
2812
|
+
});
|
|
2813
|
+
function shutdown() {
|
|
2814
|
+
console.log("bondsmithd shutting down");
|
|
2815
|
+
clearInterval(reconcileTimer);
|
|
2816
|
+
revocationSync.stop();
|
|
2817
|
+
relayClient?.stop();
|
|
2818
|
+
ptyAttachments.killAll();
|
|
2819
|
+
pendingAuthorizations.cancelAll();
|
|
2820
|
+
httpServer.close(() => process.exit(0));
|
|
2821
|
+
}
|
|
2822
|
+
process.on("SIGINT", shutdown);
|
|
2823
|
+
process.on("SIGTERM", shutdown);
|
|
2824
|
+
function crash(source, error2) {
|
|
2825
|
+
console.error(`bondsmithd: fatal ${source}, shutting down`, error2);
|
|
2826
|
+
clearInterval(reconcileTimer);
|
|
2827
|
+
revocationSync.stop();
|
|
2828
|
+
relayClient?.stop();
|
|
2829
|
+
ptyAttachments.killAll();
|
|
2830
|
+
pendingAuthorizations.cancelAll();
|
|
2831
|
+
httpServer.close(() => process.exit(1));
|
|
2832
|
+
}
|
|
2833
|
+
process.on("uncaughtException", (error2) => crash("uncaughtException", error2));
|
|
2834
|
+
process.on("unhandledRejection", (error2) => crash("unhandledRejection", error2));
|
|
2835
|
+
}
|
|
2836
|
+
start().catch((error2) => {
|
|
2837
|
+
console.error("Failed to start bondsmithd", error2);
|
|
2838
|
+
process.exit(1);
|
|
2839
|
+
});
|
|
2840
|
+
//# sourceMappingURL=index.js.map
|