@bondsmith/bondsmithd 0.1.9 → 0.1.11

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.
Files changed (3) hide show
  1. package/README.md +8 -1
  2. package/dist/index.js +949 -676
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
16
16
  var INVALID_MESSAGE_ERROR = "invalid message";
17
17
 
18
18
  // ../../packages/local-agent-protocol/src/protocol-version.ts
19
- var PROTOCOL_VERSION = 8;
19
+ var PROTOCOL_VERSION = 10;
20
20
 
21
21
  // ../../packages/local-agent-protocol/src/project-path.ts
22
22
  var PROJECT_PATH_INVALID_REASONS = [
@@ -28,32 +28,127 @@ var PROJECT_PATH_INVALID_REASONS = [
28
28
  ];
29
29
 
30
30
  // ../../packages/local-agent-protocol/src/schemas.ts
31
- import { z as z2 } from "zod";
31
+ import { z as z3 } from "zod";
32
32
 
33
33
  // ../../packages/local-agent-protocol/src/connection-challenge.ts
34
+ import { z as z2 } from "zod";
35
+
36
+ // ../../packages/local-agent-protocol/src/e2e-key.ts
34
37
  import { z } from "zod";
38
+
39
+ // ../../packages/local-agent-protocol/src/canonicalize.ts
40
+ function canonicalize(value) {
41
+ return serialize(value);
42
+ }
43
+ function serialize(value) {
44
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
45
+ return JSON.stringify(value);
46
+ }
47
+ if (typeof value === "string") {
48
+ return JSON.stringify(value);
49
+ }
50
+ if (value === void 0) {
51
+ throw new TypeError("canonicalize: undefined is not representable");
52
+ }
53
+ if (Array.isArray(value)) {
54
+ return `[${value.map(serialize).join(",")}]`;
55
+ }
56
+ if (typeof value === "object") {
57
+ const keys = Object.keys(value).sort();
58
+ const entries = keys.map((key) => {
59
+ const entryValue = value[key];
60
+ return `${JSON.stringify(key)}:${serialize(entryValue)}`;
61
+ });
62
+ return `{${entries.join(",")}}`;
63
+ }
64
+ throw new TypeError(`canonicalize: unsupported value type ${typeof value}`);
65
+ }
66
+
67
+ // ../../packages/local-agent-protocol/src/e2e-key.ts
68
+ var E2E_KEY_INFO = "bondsmith.e2e.v1";
69
+ var X25519_PUBLIC_KEY_LENGTH = 43;
70
+ var x25519PublicKeySchema = z.string().length(X25519_PUBLIC_KEY_LENGTH).regex(/^[A-Za-z0-9_-]+$/);
71
+ var KEY_BYTES = 32;
72
+ var encoder = new TextEncoder();
73
+ function toBase64Url(bytes) {
74
+ let binary = "";
75
+ for (const byte of bytes) binary += String.fromCharCode(byte);
76
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
77
+ }
78
+ function fromBase64Url(value) {
79
+ const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"));
80
+ const bytes = new Uint8Array(binary.length);
81
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
82
+ return bytes;
83
+ }
84
+ async function createEphemeralKeyExchange(role) {
85
+ const pair = await crypto.subtle.generateKey({ name: "X25519" }, false, [
86
+ "deriveBits"
87
+ ]);
88
+ const publicKey = toBase64Url(
89
+ new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey))
90
+ );
91
+ return {
92
+ publicKey,
93
+ deriveKey: async (peerPublicKey, transcript) => {
94
+ const { clientEcdhPublicKey, daemonEcdhPublicKey } = transcript;
95
+ const [ownKey, expectedPeerKey] = role === "client" ? [clientEcdhPublicKey, daemonEcdhPublicKey] : [daemonEcdhPublicKey, clientEcdhPublicKey];
96
+ if (ownKey !== publicKey || expectedPeerKey !== peerPublicKey) {
97
+ throw new Error("The signed transcript does not carry this connection's key pair");
98
+ }
99
+ const peerKey = await crypto.subtle.importKey(
100
+ "raw",
101
+ fromBase64Url(peerPublicKey),
102
+ { name: "X25519" },
103
+ false,
104
+ []
105
+ );
106
+ const secret = await crypto.subtle.deriveBits(
107
+ { name: "X25519", public: peerKey },
108
+ pair.privateKey,
109
+ KEY_BYTES * 8
110
+ );
111
+ const hkdfKey = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveBits"]);
112
+ const salt = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalize(transcript)));
113
+ return new Uint8Array(
114
+ await crypto.subtle.deriveBits(
115
+ { name: "HKDF", hash: "SHA-256", salt, info: encoder.encode(E2E_KEY_INFO) },
116
+ hkdfKey,
117
+ KEY_BYTES * 8
118
+ )
119
+ );
120
+ }
121
+ };
122
+ }
123
+
124
+ // ../../packages/local-agent-protocol/src/connection-challenge.ts
35
125
  var CONNECTION_CHALLENGE_PURPOSE = "bondsmith.connection-handshake";
36
- var connectionChallengeTranscriptSchema = z.object({
37
- version: z.literal(1),
38
- purpose: z.literal(CONNECTION_CHALLENGE_PURPOSE),
126
+ var CLIENT_NONCE_MIN_LENGTH = 16;
127
+ var connectionChallengeTranscriptSchema = z2.object({
128
+ version: z2.literal(1),
129
+ purpose: z2.literal(CONNECTION_CHALLENGE_PURPOSE),
39
130
  // Generated by the daemon per-socket, at connection time — never
40
131
  // client-supplied, never reused across connections or reconnects.
41
- connectionId: z.string().min(1),
132
+ connectionId: z2.string().min(1),
42
133
  // Fresh, cryptographically random, single-use — a second handshake
43
134
  // attempt (even with a validly-signed transcript for the same
44
135
  // connectionId) is rejected by the daemon outright.
45
- challenge: z.string().min(1),
46
- daemonFingerprint: z.string().min(1),
47
- controllerId: z.string().min(1),
136
+ challenge: z2.string().min(1),
137
+ daemonFingerprint: z2.string().min(1),
138
+ controllerId: z2.string().min(1),
48
139
  // An assertion the browser makes, not independent account
49
140
  // authentication — the daemon has no account-token layer to verify this
50
141
  // against (unchanged from ADR-012's existing caveat on CommandEnvelope's
51
142
  // own accountId field).
52
- accountId: z.number().int().positive()
143
+ accountId: z2.number().int().positive(),
144
+ // Fresh per connection, chosen by the browser: the daemon signs it back (daemon-proof.ts) so the browser knows the daemon answered THIS handshake, not a replay. Absent from older browsers.
145
+ clientNonce: z2.string().min(CLIENT_NONCE_MIN_LENGTH).optional(),
146
+ // Fresh per connection: the browser's half of the key agreement (e2e-key.ts), signed here so the relay cannot swap it.
147
+ clientEcdhPublicKey: x25519PublicKeySchema.optional()
53
148
  });
54
- var signedConnectionChallengeSchema = z.object({
149
+ var signedConnectionChallengeSchema = z2.object({
55
150
  transcript: connectionChallengeTranscriptSchema,
56
- signature: z.string().min(1)
151
+ signature: z2.string().min(1)
57
152
  });
58
153
 
59
154
  // ../../packages/local-agent-protocol/src/schemas.ts
@@ -64,77 +159,77 @@ var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
64
159
  var MAX_RANGE_LINES = 500;
65
160
  var MAX_PLAN_CHARS = 32 * 1024;
66
161
  var MAX_TERMINAL_DIMENSION = 1e3;
67
- var terminalSizeSchema = z2.object({
68
- cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
69
- rows: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION)
162
+ var terminalSizeSchema = z3.object({
163
+ cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
164
+ rows: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION)
70
165
  });
71
- var localSessionSchema = z2.object({
72
- id: z2.string(),
73
- title: z2.string(),
74
- command: z2.string(),
75
- cwd: z2.string(),
76
- tmuxSessionName: z2.string(),
77
- status: z2.enum(SESSION_STATUSES),
78
- startedAt: z2.string(),
79
- branch: z2.string().nullable(),
80
- repositoryFullName: z2.string().nullable(),
81
- lastActivityAt: z2.string().nullable(),
82
- changedFileCount: z2.number().int().nonnegative().nullable(),
83
- worktreeOf: z2.string().nullable(),
166
+ var localSessionSchema = z3.object({
167
+ id: z3.string(),
168
+ title: z3.string(),
169
+ command: z3.string(),
170
+ cwd: z3.string(),
171
+ tmuxSessionName: z3.string(),
172
+ status: z3.enum(SESSION_STATUSES),
173
+ startedAt: z3.string(),
174
+ branch: z3.string().nullable(),
175
+ repositoryFullName: z3.string().nullable(),
176
+ lastActivityAt: z3.string().nullable(),
177
+ changedFileCount: z3.number().int().nonnegative().nullable(),
178
+ worktreeOf: z3.string().nullable(),
84
179
  // .catch(undefined): a state this version doesn't know must never drop the whole session message.
85
- agentState: z2.enum(AGENT_STATES).nullable().optional().catch(void 0)
180
+ agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0)
86
181
  });
87
- var workspaceFileEntrySchema = z2.object({
88
- path: z2.string(),
89
- previousPath: z2.string().optional(),
90
- status: z2.enum(["added", "modified", "deleted", "renamed", "copied", "untracked"]),
91
- staged: z2.boolean(),
92
- additions: z2.number().int().nonnegative().nullable(),
93
- deletions: z2.number().int().nonnegative().nullable(),
94
- binary: z2.boolean()
182
+ var workspaceFileEntrySchema = z3.object({
183
+ path: z3.string(),
184
+ previousPath: z3.string().optional(),
185
+ status: z3.enum(["added", "modified", "deleted", "renamed", "copied", "untracked"]),
186
+ staged: z3.boolean(),
187
+ additions: z3.number().int().nonnegative().nullable(),
188
+ deletions: z3.number().int().nonnegative().nullable(),
189
+ binary: z3.boolean()
95
190
  });
96
- var workspaceStatusSchema = z2.object({
97
- isGitRepo: z2.boolean(),
98
- branch: z2.string().nullable(),
191
+ var workspaceStatusSchema = z3.object({
192
+ isGitRepo: z3.boolean(),
193
+ branch: z3.string().nullable(),
99
194
  // .catch(undefined): a malformed informational field must never drop the whole status message.
100
- baseRef: z2.string().nullable().optional().catch(void 0),
101
- files: z2.array(workspaceFileEntrySchema),
102
- totalFiles: z2.number().int().nonnegative().optional().catch(void 0)
195
+ baseRef: z3.string().nullable().optional().catch(void 0),
196
+ files: z3.array(workspaceFileEntrySchema),
197
+ totalFiles: z3.number().int().nonnegative().optional().catch(void 0)
103
198
  });
104
- var workspaceFileDiffSchema = z2.object({
105
- path: z2.string(),
106
- staged: z2.boolean(),
107
- patch: z2.string().nullable(),
108
- binary: z2.boolean(),
109
- tooLarge: z2.boolean().optional(),
110
- version: z2.string().optional()
199
+ var workspaceFileDiffSchema = z3.object({
200
+ path: z3.string(),
201
+ staged: z3.boolean(),
202
+ patch: z3.string().nullable(),
203
+ binary: z3.boolean(),
204
+ tooLarge: z3.boolean().optional(),
205
+ version: z3.string().optional()
111
206
  });
112
- var workspaceFileRangeSchema = z2.discriminatedUnion("ok", [
113
- z2.object({
114
- ok: z2.literal(true),
115
- path: z2.string(),
116
- startLine: z2.number().int().min(1),
117
- lines: z2.array(z2.string()).max(MAX_RANGE_LINES),
118
- totalLines: z2.number().int().nonnegative(),
119
- version: z2.string()
207
+ var workspaceFileRangeSchema = z3.discriminatedUnion("ok", [
208
+ z3.object({
209
+ ok: z3.literal(true),
210
+ path: z3.string(),
211
+ startLine: z3.number().int().min(1),
212
+ lines: z3.array(z3.string()).max(MAX_RANGE_LINES),
213
+ totalLines: z3.number().int().nonnegative(),
214
+ version: z3.string()
120
215
  }),
121
- z2.object({
122
- ok: z2.literal(false),
123
- path: z2.string(),
124
- reason: z2.enum(["changed", "missing", "binary", "too_large", "not_allowed"])
216
+ z3.object({
217
+ ok: z3.literal(false),
218
+ path: z3.string(),
219
+ reason: z3.enum(["changed", "missing", "binary", "too_large", "not_allowed"])
125
220
  })
126
221
  ]);
127
- var projectPathValidationSchema = z2.discriminatedUnion("valid", [
128
- z2.object({ valid: z2.literal(true), path: z2.string() }),
129
- z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
222
+ var projectPathValidationSchema = z3.discriminatedUnion("valid", [
223
+ z3.object({ valid: z3.literal(true), path: z3.string() }),
224
+ z3.object({ valid: z3.literal(false), reason: z3.enum(PROJECT_PATH_INVALID_REASONS) })
130
225
  ]);
131
- var browserMessageSchema = z2.discriminatedUnion("type", [
132
- z2.object({ type: z2.literal("session.list") }),
133
- z2.object({
134
- type: z2.literal("session.create"),
135
- title: z2.string().min(1).max(200),
136
- cwd: z2.string().min(1),
137
- command: z2.string().min(1).max(2e3),
226
+ var browserMessageSchema = z3.discriminatedUnion("type", [
227
+ z3.object({ type: z3.literal("session.list") }),
228
+ z3.object({
229
+ type: z3.literal("session.create"),
230
+ title: z3.string().min(1).max(200),
231
+ cwd: z3.string().min(1),
232
+ command: z3.string().min(1).max(2e3),
138
233
  // Opt-in worktree creation — see messages.ts's own field comment.
139
234
  // Most branch-name legality is left to git itself in createWorktree()
140
235
  // (whose named error classes surface a rejection back to the
@@ -144,14 +239,14 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
144
239
  // the error instead of a clean, classified one (confirmed live
145
240
  // during adversarial review) — rejected here instead, before it ever
146
241
  // reaches git.
147
- worktreeBranch: z2.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
242
+ worktreeBranch: z3.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
148
243
  message: "branch name can't start with -"
149
244
  }).optional(),
150
- requestId: z2.string().min(1).max(200).optional()
245
+ requestId: z3.string().min(1).max(200).optional()
151
246
  }),
152
- z2.object({
153
- type: z2.literal("session.attach"),
154
- sessionId: z2.string(),
247
+ z3.object({
248
+ type: z3.literal("session.attach"),
249
+ sessionId: z3.string(),
155
250
  // Optional (not required) so an older daemon build, or a stale queued
156
251
  // envelope signed before this field existed, still parses — see
157
252
  // session.attached/error's own requestId fields for why this matters:
@@ -161,187 +256,192 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
161
256
  // misattributes a daemon error for ANY other in-flight command
162
257
  // (observed: a session.resize racing ahead of its own session.attach)
163
258
  // to the wrong promise — a confirmed bug, not a hypothetical.
164
- requestId: z2.string().min(1).max(200).optional(),
259
+ requestId: z3.string().min(1).max(200).optional(),
165
260
  // Tolerant like baseRef: an unreadable size must not make the daemon drop the attach itself.
166
261
  size: terminalSizeSchema.optional().catch(void 0)
167
262
  }),
168
- z2.object({ type: z2.literal("session.detach"), sessionId: z2.string() }),
169
- z2.object({
170
- type: z2.literal("session.input"),
171
- sessionId: z2.string(),
172
- data: z2.string().max(MAX_INPUT_CHARS)
263
+ z3.object({ type: z3.literal("session.detach"), sessionId: z3.string() }),
264
+ z3.object({
265
+ type: z3.literal("session.input"),
266
+ sessionId: z3.string(),
267
+ data: z3.string().max(MAX_INPUT_CHARS)
173
268
  }),
174
- z2.object({
175
- type: z2.literal("session.insertText"),
176
- sessionId: z2.string(),
177
- text: z2.string().max(MAX_INSERT_TEXT_BYTES)
269
+ z3.object({
270
+ type: z3.literal("session.insertText"),
271
+ sessionId: z3.string(),
272
+ text: z3.string().max(MAX_INSERT_TEXT_BYTES)
178
273
  }),
179
- z2.object({
180
- type: z2.literal("session.resize"),
181
- sessionId: z2.string(),
274
+ z3.object({
275
+ type: z3.literal("session.resize"),
276
+ sessionId: z3.string(),
182
277
  ...terminalSizeSchema.shape
183
278
  }),
184
- z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
185
- z2.object({
186
- type: z2.literal("workspace.getStatus"),
187
- sessionId: z2.string(),
188
- requestId: z2.string().min(1).max(200)
279
+ z3.object({ type: z3.literal("session.stop"), sessionId: z3.string() }),
280
+ z3.object({
281
+ type: z3.literal("workspace.getStatus"),
282
+ sessionId: z3.string(),
283
+ requestId: z3.string().min(1).max(200)
189
284
  }),
190
- z2.object({
191
- type: z2.literal("workspace.readFileRange"),
192
- sessionId: z2.string(),
193
- path: z2.string().min(1).max(4096),
194
- startLine: z2.number().int().min(1),
195
- lineCount: z2.number().int().min(1).max(MAX_RANGE_LINES),
196
- version: z2.string().min(1).max(100),
197
- requestId: z2.string().min(1).max(200)
285
+ z3.object({
286
+ type: z3.literal("workspace.readFileRange"),
287
+ sessionId: z3.string(),
288
+ path: z3.string().min(1).max(4096),
289
+ startLine: z3.number().int().min(1),
290
+ lineCount: z3.number().int().min(1).max(MAX_RANGE_LINES),
291
+ version: z3.string().min(1).max(100),
292
+ requestId: z3.string().min(1).max(200)
198
293
  }),
199
- z2.object({
200
- type: z2.literal("workspace.getDiff"),
201
- sessionId: z2.string(),
202
- path: z2.string().min(1).max(4096),
203
- staged: z2.boolean(),
204
- requestId: z2.string().min(1).max(200)
294
+ z3.object({
295
+ type: z3.literal("workspace.getDiff"),
296
+ sessionId: z3.string(),
297
+ path: z3.string().min(1).max(4096),
298
+ staged: z3.boolean(),
299
+ requestId: z3.string().min(1).max(200)
205
300
  }),
206
- z2.object({
207
- type: z2.literal("workspace.getPlan"),
208
- sessionId: z2.string(),
209
- requestId: z2.string().min(1).max(200)
301
+ z3.object({
302
+ type: z3.literal("workspace.getPlan"),
303
+ sessionId: z3.string(),
304
+ requestId: z3.string().min(1).max(200)
210
305
  }),
211
- z2.object({
212
- type: z2.literal("project.validatePath"),
213
- path: z2.string().min(1).max(4096),
214
- requestId: z2.string().min(1).max(200)
306
+ z3.object({
307
+ type: z3.literal("project.validatePath"),
308
+ path: z3.string().min(1).max(4096),
309
+ requestId: z3.string().min(1).max(200)
215
310
  }),
216
- z2.object({ type: z2.literal("daemon.getInfo") }),
311
+ z3.object({ type: z3.literal("daemon.getInfo") }),
217
312
  // Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
218
313
  // unsigned: the requesting browser has no trusted key yet to sign with.
219
314
  // Authority comes entirely from explicit daemon-local user approval, not
220
315
  // from anything in this message.
221
- z2.object({
222
- type: z2.literal("controller.requestEnrollment"),
223
- publicKeyPem: z2.string().min(1),
224
- label: z2.string().min(1).max(200)
316
+ z3.object({
317
+ type: z3.literal("controller.requestEnrollment"),
318
+ publicKeyPem: z3.string().min(1),
319
+ label: z3.string().min(1).max(200)
225
320
  }),
226
321
  // ADR-013/BD-477: the browser's signed response to connection.challenge —
227
322
  // carries its own signed transcript, not a CommandEnvelope, so it's a
228
323
  // distinct BrowserMessage variant rather than a SIGNABLE_ACTIONS entry.
229
- z2.object({
230
- type: z2.literal("connection.authenticate"),
231
- signed: z2.object({
324
+ z3.object({
325
+ type: z3.literal("connection.authenticate"),
326
+ signed: z3.object({
232
327
  transcript: connectionChallengeTranscriptSchema,
233
- signature: z2.string().min(1)
328
+ signature: z3.string().min(1)
234
329
  })
235
330
  })
236
331
  ]);
237
- var connectionAuthenticationFailureReasonSchema = z2.enum([
332
+ var connectionAuthenticationFailureReasonSchema = z3.enum([
238
333
  "bad_signature",
239
334
  "untrusted_controller",
240
335
  "wrong_daemon",
241
336
  "wrong_connection",
242
337
  "challenge_expired",
243
338
  "already_authenticated",
244
- "rate_limited"
339
+ "rate_limited",
340
+ "key_agreement_failed"
245
341
  ]);
246
- var connectionRejectionReasonSchema = z2.enum(["origin_not_allowed", "token_invalid"]);
247
- var daemonMessageSchema = z2.discriminatedUnion("type", [
248
- z2.object({ type: z2.literal("session.list"), sessions: z2.array(localSessionSchema) }),
249
- z2.object({
250
- type: z2.literal("session.created"),
342
+ var connectionRejectionReasonSchema = z3.enum(["origin_not_allowed", "token_invalid"]);
343
+ var daemonMessageSchema = z3.discriminatedUnion("type", [
344
+ z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
345
+ z3.object({
346
+ type: z3.literal("session.created"),
251
347
  session: localSessionSchema,
252
- requestId: z2.string().optional()
348
+ requestId: z3.string().optional()
253
349
  }),
254
- z2.object({
255
- type: z2.literal("terminal.output"),
256
- sessionId: z2.string(),
257
- data: z2.string()
350
+ z3.object({
351
+ type: z3.literal("terminal.output"),
352
+ sessionId: z3.string(),
353
+ data: z3.string()
258
354
  }),
259
- z2.object({ type: z2.literal("session.updated"), session: localSessionSchema }),
355
+ z3.object({ type: z3.literal("session.updated"), session: localSessionSchema }),
260
356
  // Ack for a successful session.attach — the browser must wait for this
261
357
  // before wiring up terminal input, or a keystroke sent before the daemon
262
358
  // actually finishes attaching gets rejected as "not attached to
263
359
  // session" (observed during a reconnect: attachSession() was
264
360
  // fire-and-forget, so terminal.onData could fire before attach
265
361
  // completed).
266
- z2.object({
267
- type: z2.literal("session.attached"),
268
- sessionId: z2.string(),
269
- requestId: z2.string().optional()
362
+ z3.object({
363
+ type: z3.literal("session.attached"),
364
+ sessionId: z3.string(),
365
+ requestId: z3.string().optional()
270
366
  }),
271
- z2.object({
272
- type: z2.literal("error"),
273
- message: z2.string(),
367
+ z3.object({
368
+ type: z3.literal("error"),
369
+ message: z3.string(),
274
370
  // Echoes the triggering message's requestId when it had one, so the
275
371
  // browser can match this error to the exact pending request instead
276
372
  // of guessing "oldest pending create, then oldest pending attach" —
277
373
  // undefined for error paths with no requestId to echo (most session.*
278
374
  // commands still don't carry one; see session.attach above for the
279
375
  // one this was added to fix).
280
- requestId: z2.string().optional()
376
+ requestId: z3.string().optional()
281
377
  }),
282
- z2.object({
283
- type: z2.literal("workspace.status"),
284
- sessionId: z2.string(),
285
- requestId: z2.string(),
378
+ z3.object({
379
+ type: z3.literal("workspace.status"),
380
+ sessionId: z3.string(),
381
+ requestId: z3.string(),
286
382
  status: workspaceStatusSchema
287
383
  }),
288
- z2.object({
289
- type: z2.literal("workspace.fileRange"),
290
- sessionId: z2.string(),
291
- requestId: z2.string(),
384
+ z3.object({
385
+ type: z3.literal("workspace.fileRange"),
386
+ sessionId: z3.string(),
387
+ requestId: z3.string(),
292
388
  range: workspaceFileRangeSchema
293
389
  }),
294
- z2.object({
295
- type: z2.literal("workspace.diff"),
296
- sessionId: z2.string(),
297
- requestId: z2.string(),
390
+ z3.object({
391
+ type: z3.literal("workspace.diff"),
392
+ sessionId: z3.string(),
393
+ requestId: z3.string(),
298
394
  diff: workspaceFileDiffSchema
299
395
  }),
300
- z2.object({
301
- type: z2.literal("workspace.plan"),
302
- sessionId: z2.string(),
303
- requestId: z2.string(),
304
- plan: z2.string().max(MAX_PLAN_CHARS).nullable()
396
+ z3.object({
397
+ type: z3.literal("workspace.plan"),
398
+ sessionId: z3.string(),
399
+ requestId: z3.string(),
400
+ plan: z3.string().max(MAX_PLAN_CHARS).nullable()
305
401
  }),
306
- z2.object({
307
- type: z2.literal("project.pathValidated"),
308
- requestId: z2.string(),
402
+ z3.object({
403
+ type: z3.literal("project.pathValidated"),
404
+ requestId: z3.string(),
309
405
  result: projectPathValidationSchema
310
406
  }),
311
- z2.object({
312
- type: z2.literal("daemon.info"),
313
- hostname: z2.string(),
314
- daemonFingerprint: z2.string()
407
+ z3.object({
408
+ type: z3.literal("daemon.info"),
409
+ hostname: z3.string(),
410
+ daemonFingerprint: z3.string()
315
411
  }),
316
- z2.object({
317
- type: z2.literal("controller.enrollmentPending"),
318
- fingerprint: z2.string()
412
+ z3.object({
413
+ type: z3.literal("controller.enrollmentPending"),
414
+ fingerprint: z3.string()
319
415
  }),
320
- z2.object({
321
- type: z2.literal("controller.enrollmentRejected"),
322
- reason: z2.enum(["not_interactive", "rate_limited"])
416
+ z3.object({
417
+ type: z3.literal("controller.enrollmentRejected"),
418
+ reason: z3.enum(["not_interactive", "rate_limited"])
323
419
  }),
324
- z2.object({
325
- type: z2.literal("connection.challenge"),
326
- connectionId: z2.string().min(1),
327
- challenge: z2.string().min(1),
328
- daemonFingerprint: z2.string().min(1),
329
- hostname: z2.string(),
330
- expiresAt: z2.number().int().positive(),
420
+ z3.object({
421
+ type: z3.literal("connection.challenge"),
422
+ connectionId: z3.string().min(1),
423
+ challenge: z3.string().min(1),
424
+ daemonFingerprint: z3.string().min(1),
425
+ hostname: z3.string(),
426
+ expiresAt: z3.number().int().positive(),
331
427
  // .catch(undefined): a malformed informational field must never fail the whole handshake message.
332
- protocolVersion: z2.number().int().positive().optional().catch(void 0),
333
- daemonVersion: z2.string().optional().catch(void 0)
428
+ protocolVersion: z3.number().int().positive().optional().catch(void 0),
429
+ daemonVersion: z3.string().optional().catch(void 0)
334
430
  }),
335
- z2.object({
336
- type: z2.literal("connection.authenticated"),
337
- connectionId: z2.string().min(1)
431
+ z3.object({
432
+ type: z3.literal("connection.authenticated"),
433
+ connectionId: z3.string().min(1),
434
+ // Strict on purpose (no .catch): a corrupted signature must fail the message, not quietly read as "unsigned".
435
+ daemonSignature: z3.string().min(1).optional(),
436
+ // The daemon's half of the key agreement; covered by daemonSignature.
437
+ daemonEcdhPublicKey: x25519PublicKeySchema.optional()
338
438
  }),
339
- z2.object({
340
- type: z2.literal("connection.authenticationFailed"),
439
+ z3.object({
440
+ type: z3.literal("connection.authenticationFailed"),
341
441
  reason: connectionAuthenticationFailureReasonSchema
342
442
  }),
343
- z2.object({
344
- type: z2.literal("connection.rejected"),
443
+ z3.object({
444
+ type: z3.literal("connection.rejected"),
345
445
  reason: connectionRejectionReasonSchema
346
446
  })
347
447
  ]);
@@ -376,36 +476,8 @@ async function controllerIdFromPublicKeyPem(publicKeyPem) {
376
476
  return hex.slice(0, 32);
377
477
  }
378
478
 
379
- // ../../packages/local-agent-protocol/src/canonicalize.ts
380
- function canonicalize(value) {
381
- return serialize(value);
382
- }
383
- function serialize(value) {
384
- if (value === null || typeof value === "number" || typeof value === "boolean") {
385
- return JSON.stringify(value);
386
- }
387
- if (typeof value === "string") {
388
- return JSON.stringify(value);
389
- }
390
- if (value === void 0) {
391
- throw new TypeError("canonicalize: undefined is not representable");
392
- }
393
- if (Array.isArray(value)) {
394
- return `[${value.map(serialize).join(",")}]`;
395
- }
396
- if (typeof value === "object") {
397
- const keys = Object.keys(value).sort();
398
- const entries = keys.map((key) => {
399
- const entryValue = value[key];
400
- return `${JSON.stringify(key)}:${serialize(entryValue)}`;
401
- });
402
- return `{${entries.join(",")}}`;
403
- }
404
- throw new TypeError(`canonicalize: unsupported value type ${typeof value}`);
405
- }
406
-
407
479
  // ../../packages/local-agent-protocol/src/envelope.ts
408
- import { z as z3 } from "zod";
480
+ import { z as z4 } from "zod";
409
481
  var SIGNABLE_ACTIONS = [
410
482
  "session.create",
411
483
  "session.attach",
@@ -421,113 +493,145 @@ var SIGNABLE_ACTIONS = [
421
493
  "workspace.getPlan",
422
494
  "project.validatePath"
423
495
  ];
424
- var envelopeSchema = z3.object({
425
- version: z3.literal(1),
426
- controllerId: z3.string().min(1),
427
- accountId: z3.number().int().positive(),
428
- daemonId: z3.string().min(1),
496
+ var envelopeSchema = z4.object({
497
+ version: z4.literal(1),
498
+ controllerId: z4.string().min(1),
499
+ accountId: z4.number().int().positive(),
500
+ daemonId: z4.string().min(1),
429
501
  // The specific WebSocket connection this envelope was signed for (ADR-013,
430
502
  // BD-477) — issued by the daemon during that connection's handshake
431
503
  // (connection-challenge.ts), never reused across connections. `sequence`
432
504
  // below is only ever compared against other envelopes carrying the SAME
433
505
  // connectionId; an envelope for a stale or foreign connectionId is
434
506
  // rejected before sequence is even considered (verify-command.ts).
435
- connectionId: z3.string().min(1),
436
- sessionId: z3.string().nullable(),
437
- action: z3.enum(SIGNABLE_ACTIONS),
438
- payload: z3.unknown(),
439
- issuedAt: z3.number().int().positive(),
440
- expiresAt: z3.number().int().positive(),
507
+ connectionId: z4.string().min(1),
508
+ sessionId: z4.string().nullable(),
509
+ action: z4.enum(SIGNABLE_ACTIONS),
510
+ payload: z4.unknown(),
511
+ issuedAt: z4.number().int().positive(),
512
+ expiresAt: z4.number().int().positive(),
441
513
  // Strictly increasing WITHIN one connectionId's sequence space, starting
442
514
  // at 1 for every new authenticated connection — never a persisted,
443
515
  // global-per-controller counter (that design broke on page reload; see
444
516
  // ADR-013). Verified and advanced in-memory, per connection, never
445
517
  // written to disk.
446
- sequence: z3.number().int().positive()
518
+ sequence: z4.number().int().positive()
447
519
  });
448
- var signedCommandSchema = z3.object({
520
+ var signedCommandSchema = z4.object({
449
521
  envelope: envelopeSchema,
450
- signature: z3.string().min(1)
522
+ signature: z4.string().min(1)
451
523
  });
452
524
 
453
525
  // ../../packages/local-agent-protocol/src/pairing-challenge.ts
454
- import { z as z4 } from "zod";
526
+ import { z as z5 } from "zod";
455
527
  var PAIRING_CHALLENGE_PURPOSE = "bondsmith.initial-controller-pairing";
456
- var pairingChallengeTranscriptSchema = z4.object({
457
- version: z4.literal(1),
458
- purpose: z4.literal(PAIRING_CHALLENGE_PURPOSE),
459
- pairingAttemptId: z4.string().min(1),
460
- challenge: z4.string().min(1),
461
- daemonFingerprint: z4.string().min(1),
462
- controllerPublicKeyPem: z4.string().min(1),
463
- accountId: z4.number().int().positive()
528
+ var pairingChallengeTranscriptSchema = z5.object({
529
+ version: z5.literal(1),
530
+ purpose: z5.literal(PAIRING_CHALLENGE_PURPOSE),
531
+ pairingAttemptId: z5.string().min(1),
532
+ challenge: z5.string().min(1),
533
+ daemonFingerprint: z5.string().min(1),
534
+ controllerPublicKeyPem: z5.string().min(1),
535
+ accountId: z5.number().int().positive()
464
536
  });
465
- var signedPairingChallengeSchema = z4.object({
537
+ var signedPairingChallengeSchema = z5.object({
466
538
  transcript: pairingChallengeTranscriptSchema,
467
- signature: z4.string().min(1)
539
+ signature: z5.string().min(1)
540
+ });
541
+
542
+ // ../../packages/local-agent-protocol/src/daemon-proof.ts
543
+ import { z as z6 } from "zod";
544
+ var DAEMON_PROOF_PURPOSE = "bondsmith.daemon-proof";
545
+ var daemonProofTranscriptSchema = z6.object({
546
+ version: z6.literal(1),
547
+ purpose: z6.literal(DAEMON_PROOF_PURPOSE),
548
+ connectionId: z6.string().min(1),
549
+ challenge: z6.string().min(1),
550
+ daemonFingerprint: z6.string().min(1),
551
+ controllerId: z6.string().min(1),
552
+ accountId: z6.number().int().positive(),
553
+ clientNonce: z6.string().min(CLIENT_NONCE_MIN_LENGTH),
554
+ // Both halves of the key agreement (e2e-key.ts), so the daemon's signature vouches for the key the browser derives from.
555
+ clientEcdhPublicKey: x25519PublicKeySchema.optional(),
556
+ daemonEcdhPublicKey: x25519PublicKeySchema.optional()
468
557
  });
558
+ function buildDaemonProofTranscript(handshake, daemonEcdhPublicKey) {
559
+ if (handshake.clientNonce === void 0) return null;
560
+ return {
561
+ version: 1,
562
+ purpose: DAEMON_PROOF_PURPOSE,
563
+ connectionId: handshake.connectionId,
564
+ challenge: handshake.challenge,
565
+ daemonFingerprint: handshake.daemonFingerprint,
566
+ controllerId: handshake.controllerId,
567
+ accountId: handshake.accountId,
568
+ clientNonce: handshake.clientNonce,
569
+ ...handshake.clientEcdhPublicKey && { clientEcdhPublicKey: handshake.clientEcdhPublicKey },
570
+ ...daemonEcdhPublicKey && { daemonEcdhPublicKey }
571
+ };
572
+ }
469
573
 
470
574
  // ../../packages/local-agent-protocol/src/relay-register-transcript.ts
471
- import { z as z5 } from "zod";
575
+ import { z as z7 } from "zod";
472
576
  var RELAY_REGISTER_PURPOSE = "bondsmith.relay.register";
473
- var relayRegisterTranscriptSchema = z5.object({
474
- version: z5.literal(1),
475
- purpose: z5.literal(RELAY_REGISTER_PURPOSE),
577
+ var relayRegisterTranscriptSchema = z7.object({
578
+ version: z7.literal(1),
579
+ purpose: z7.literal(RELAY_REGISTER_PURPOSE),
476
580
  // Relay-issued, single-use — a second registration attempt against the
477
581
  // same challenge is meaningless since a daemon reconnecting always gets
478
582
  // a fresh challenge from a fresh relay.challenge message; this field
479
583
  // exists so a captured signed transcript can't be replayed later against
480
584
  // a still-open relay listener.
481
- challenge: z5.string().min(1),
482
- daemonFingerprint: z5.string().min(1),
483
- issuedAt: z5.number().int().positive(),
484
- expiresAt: z5.number().int().positive(),
585
+ challenge: z7.string().min(1),
586
+ daemonFingerprint: z7.string().min(1),
587
+ issuedAt: z7.number().int().positive(),
588
+ expiresAt: z7.number().int().positive(),
485
589
  // Binds this transcript to one specific relay deployment (e.g.
486
590
  // "relay.bondsmith.dev") — same reasoning as CommandEnvelope's own
487
591
  // daemonId field binding a command to one specific daemon; prevents a
488
592
  // transcript signed for one relay audience from being replayed against
489
593
  // a different one.
490
- relayAudience: z5.string().min(1)
594
+ relayAudience: z7.string().min(1)
491
595
  });
492
- var signedRelayRegisterSchema = z5.object({
596
+ var signedRelayRegisterSchema = z7.object({
493
597
  // The daemon's full public key PEM, transmitted alongside the transcript
494
598
  // so the relay can independently derive daemonFingerprint from it and
495
599
  // verify the signature against it — see this file's own doc comment for
496
600
  // why this must travel on the wire rather than being assumed.
497
- daemonPublicKeyPem: z5.string().min(1),
601
+ daemonPublicKeyPem: z7.string().min(1),
498
602
  transcript: relayRegisterTranscriptSchema,
499
- signature: z5.string().min(1)
603
+ signature: z7.string().min(1)
500
604
  });
501
605
 
502
606
  // ../../packages/local-agent-protocol/src/relay-ticket.ts
503
- import { z as z6 } from "zod";
607
+ import { z as z8 } from "zod";
504
608
  var RELAY_TICKET_PURPOSE = "bondsmith.relay.attach";
505
- var relayTicketSchema = z6.object({
506
- version: z6.literal(1),
507
- purpose: z6.literal(RELAY_TICKET_PURPOSE),
508
- keyId: z6.string().min(1),
509
- accountId: z6.number().int().positive(),
510
- userId: z6.string().min(1),
511
- daemonFingerprint: z6.string().min(1),
512
- issuedAt: z6.number().int().positive(),
609
+ var relayTicketSchema = z8.object({
610
+ version: z8.literal(1),
611
+ purpose: z8.literal(RELAY_TICKET_PURPOSE),
612
+ keyId: z8.string().min(1),
613
+ accountId: z8.number().int().positive(),
614
+ userId: z8.string().min(1),
615
+ daemonFingerprint: z8.string().min(1),
616
+ issuedAt: z8.number().int().positive(),
513
617
  // Deliberately short — single-digit minutes, enough for one attach
514
618
  // attempt, not a durable credential.
515
- expiresAt: z6.number().int().positive(),
619
+ expiresAt: z8.number().int().positive(),
516
620
  // Crypto-random, single-use — consumed atomically by the relay on
517
621
  // successful attach (an in-memory, TTL-expiring record; see the relay
518
622
  // architecture doc's own correction against an unbounded Set). A
519
623
  // replayed ticket (same nonce, still within its own expiry window)
520
624
  // fails closed.
521
- nonce: z6.string().min(1)
625
+ nonce: z8.string().min(1)
522
626
  });
523
- var signedRelayTicketSchema = z6.object({
627
+ var signedRelayTicketSchema = z8.object({
524
628
  ticket: relayTicketSchema,
525
- signature: z6.string().min(1)
629
+ signature: z8.string().min(1)
526
630
  });
527
631
 
528
632
  // ../../packages/local-agent-protocol/src/relay-messages.ts
529
- import { z as z7 } from "zod";
530
- var relayFailureReasonSchema = z7.enum([
633
+ import { z as z9 } from "zod";
634
+ var relayFailureReasonSchema = z9.enum([
531
635
  // relay.registrationFailed reasons (daemon side)
532
636
  "bad_signature",
533
637
  "wrong_fingerprint",
@@ -550,22 +654,22 @@ var relayFailureReasonSchema = z7.enum([
550
654
  // daemon's own client-side cap (see session-registry.ts's doc comment).
551
655
  "session_limit_exceeded"
552
656
  ]);
553
- var relayFrameSchema = z7.object({
554
- relaySessionId: z7.string().min(1),
657
+ var relayFrameSchema = z9.object({
658
+ relaySessionId: z9.string().min(1),
555
659
  // Opaque to the relay — the existing inner BrowserMessage/DaemonMessage
556
660
  // JSON, unparsed and unvalidated by anything in this file. Typed as
557
661
  // unknown here deliberately; callers narrow it via browserMessageSchema/
558
662
  // daemonMessageSchema from messages.ts/schemas.ts after routing.
559
- payload: z7.unknown()
663
+ payload: z9.unknown()
560
664
  });
561
- var relayDaemonMessageSchema = z7.discriminatedUnion("type", [
562
- z7.object({ type: z7.literal("relay.register"), signed: signedRelayRegisterSchema }),
665
+ var relayDaemonMessageSchema = z9.discriminatedUnion("type", [
666
+ z9.object({ type: z9.literal("relay.register"), signed: signedRelayRegisterSchema }),
563
667
  // Sent in response to relay.sessionOpened, once ConnectionAuthState has
564
668
  // actually been allocated — see relay-architecture doc §6's correction:
565
669
  // the relay cannot assume this happened just because it sent the
566
670
  // message, since bondsmithd is a remote process over a real WSS socket.
567
- z7.object({ type: z7.literal("relay.sessionOpenAck"), relaySessionId: z7.string().min(1) }),
568
- z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema }),
671
+ z9.object({ type: z9.literal("relay.sessionOpenAck"), relaySessionId: z9.string().min(1) }),
672
+ z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema }),
569
673
  // Daemon-initiated close — used when BD-434's existing
570
674
  // revokeControllerConnections/revokeAllConnections destroys a
571
675
  // ConnectionAuthState locally (controller revocation, daemon unpairing)
@@ -574,51 +678,51 @@ var relayDaemonMessageSchema = z7.discriminatedUnion("type", [
574
678
  // the time this is sent (revocation's actual authorization guarantee
575
679
  // never depends on the relay's cooperation) — this is cleanup, not the
576
680
  // authorization boundary.
577
- z7.object({ type: z7.literal("relay.closeSession"), relaySessionId: z7.string().min(1) })
681
+ z9.object({ type: z9.literal("relay.closeSession"), relaySessionId: z9.string().min(1) })
578
682
  ]);
579
- var relayToDaemonMessageSchema = z7.discriminatedUnion("type", [
580
- z7.object({
581
- type: z7.literal("relay.challenge"),
582
- challenge: z7.string().min(1),
583
- expiresAt: z7.number().int().positive(),
584
- relayAudience: z7.string().min(1)
683
+ var relayToDaemonMessageSchema = z9.discriminatedUnion("type", [
684
+ z9.object({
685
+ type: z9.literal("relay.challenge"),
686
+ challenge: z9.string().min(1),
687
+ expiresAt: z9.number().int().positive(),
688
+ relayAudience: z9.string().min(1)
585
689
  }),
586
- z7.object({ type: z7.literal("relay.registered"), daemonFingerprint: z7.string().min(1) }),
587
- z7.object({ type: z7.literal("relay.registrationFailed"), reason: relayFailureReasonSchema }),
588
- z7.object({ type: z7.literal("relay.sessionOpened"), relaySessionId: z7.string().min(1) }),
589
- z7.object({ type: z7.literal("relay.sessionClosed"), relaySessionId: z7.string().min(1) }),
590
- z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema })
690
+ z9.object({ type: z9.literal("relay.registered"), daemonFingerprint: z9.string().min(1) }),
691
+ z9.object({ type: z9.literal("relay.registrationFailed"), reason: relayFailureReasonSchema }),
692
+ z9.object({ type: z9.literal("relay.sessionOpened"), relaySessionId: z9.string().min(1) }),
693
+ z9.object({ type: z9.literal("relay.sessionClosed"), relaySessionId: z9.string().min(1) }),
694
+ z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema })
591
695
  ]);
592
- var relayBrowserMessageSchema = z7.discriminatedUnion("type", [
593
- z7.object({ type: z7.literal("relay.attach"), signed: signedRelayTicketSchema }),
594
- z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema })
696
+ var relayBrowserMessageSchema = z9.discriminatedUnion("type", [
697
+ z9.object({ type: z9.literal("relay.attach"), signed: signedRelayTicketSchema }),
698
+ z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema })
595
699
  ]);
596
- var relayToBrowserMessageSchema = z7.discriminatedUnion("type", [
597
- z7.object({ type: z7.literal("relay.attached"), relaySessionId: z7.string().min(1) }),
598
- z7.object({ type: z7.literal("relay.attachFailed"), reason: relayFailureReasonSchema }),
599
- z7.object({ type: z7.literal("relay.daemonOffline") }),
700
+ var relayToBrowserMessageSchema = z9.discriminatedUnion("type", [
701
+ z9.object({ type: z9.literal("relay.attached"), relaySessionId: z9.string().min(1) }),
702
+ z9.object({ type: z9.literal("relay.attachFailed"), reason: relayFailureReasonSchema }),
703
+ z9.object({ type: z9.literal("relay.daemonOffline") }),
600
704
  // Sent when the daemon registration a session was opened against gets
601
705
  // superseded (the daemon reconnected) — the session is dead, the browser
602
706
  // must perform a fresh relay.attach. "daemon_reconnected" covers V1's
603
707
  // single-process supersession case (see relay-architecture.md §4); kept
604
708
  // as its own reason (not folded into relay.daemonOffline) since the
605
709
  // daemon IS reachable again, just under a new registration.
606
- z7.object({
607
- type: z7.literal("relay.sessionInvalidated"),
608
- relaySessionId: z7.string().min(1),
609
- reason: z7.literal("daemon_reconnected")
710
+ z9.object({
711
+ type: z9.literal("relay.sessionInvalidated"),
712
+ relaySessionId: z9.string().min(1),
713
+ reason: z9.literal("daemon_reconnected")
610
714
  }),
611
- z7.object({ type: z7.literal("relay.frame"), frame: relayFrameSchema })
715
+ z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema })
612
716
  ]);
613
717
 
614
718
  // src/index.ts
615
719
  import { config as loadEnv } from "dotenv";
616
720
 
617
721
  // src/config/environment.ts
618
- import { z as z8 } from "zod";
619
- var environmentSchema = z8.object({
620
- PORT: z8.coerce.number().int().min(1).max(65535).default(4577),
621
- ALLOWED_ORIGINS: z8.string().default(
722
+ import { z as z10 } from "zod";
723
+ var baseEnvironmentSchema = z10.object({
724
+ PORT: z10.coerce.number().int().min(1).max(65535).default(4577),
725
+ ALLOWED_ORIGINS: z10.string().default(
622
726
  "http://localhost:3000,http://127.0.0.1:3000,http://localhost:4000,http://127.0.0.1:4000"
623
727
  ).transform(
624
728
  (value) => value.split(",").map((origin) => origin.trim()).filter(Boolean)
@@ -627,12 +731,14 @@ var environmentSchema = z8.object({
627
731
  // this token (via the Sec-WebSocket-Protocol header) or be rejected. Unset
628
732
  // by default so plain localhost usage is unaffected. Never a substitute for
629
733
  // real auth — see apps/bondsmithd/README.md's dev-tunnel section.
630
- BONDSMITH_DEV_TUNNEL_TOKEN: z8.string().min(1).optional(),
734
+ BONDSMITH_DEV_TUNNEL_TOKEN: z10.string().min(1).optional(),
631
735
  // Base URL of the Bondsmith backend this daemon pairs itself against
632
736
  // (BD-432) — defaults to production so a real install works with zero
633
737
  // config; overridden to the local dev server (e.g. http://localhost:4000)
634
738
  // when developing bondsmithd itself against a local web app.
635
- BONDSMITH_API_URL: z8.string().url().default("https://app.bondsmith.dev"),
739
+ BONDSMITH_API_URL: z10.string().url().default("https://app.bondsmith.dev").refine((value) => isSecureOrLoopbackUrl(value, "https:", "http:"), {
740
+ message: "BONDSMITH_API_URL must use https:// (plain http:// is only allowed for a loopback server such as http://localhost:4000)"
741
+ }),
636
742
  // Interface the WebSocket/HTTP server binds to. Defaults to loopback-only
637
743
  // (never reachable from outside this machine) — real access control is
638
744
  // ALLOWED_ORIGINS + the dev-tunnel token, not the bind address, but
@@ -641,22 +747,47 @@ var environmentSchema = z8.object({
641
747
  // that puts something (e.g. Docker's host-gateway route for an ngrok
642
748
  // tunnel) between the daemon and the browser — not something a normal
643
749
  // install needs to touch.
644
- BONDSMITH_BIND_HOST: z8.string().min(1).default("127.0.0.1"),
750
+ BONDSMITH_BIND_HOST: z10.string().min(1).default("127.0.0.1"),
645
751
  // BD-450/BD-483/BD-484: connect outbound to the Bondsmith relay instead of
646
752
  // (or alongside) listening inbound. ON by default. The existing inbound
647
753
  // listener is completely unaffected either way — it keeps running
648
754
  // regardless, so direct-connection debugging (bypassing the relay
649
755
  // entirely) is still possible by setting BONDSMITH_RELAY_ENABLED=false
650
756
  // explicitly.
651
- BONDSMITH_RELAY_ENABLED: z8.string().default("true").transform((value) => value === "true" || value === "1"),
757
+ BONDSMITH_RELAY_ENABLED: z10.string().default("true").transform((value) => value === "true" || value === "1"),
652
758
  // Defaults to production, mirroring BONDSMITH_API_URL above, so a real
653
759
  // install works with zero config — a bare `bondsmithd` used to silently
654
760
  // connect outbound to loopback (nothing listening there) with no error,
655
761
  // looking healthy while never reaching relay at all. Override to a local
656
762
  // apps/relay instance (its own `pnpm dev` default port) when developing
657
763
  // bondsmithd itself against a local relay.
658
- BONDSMITH_RELAY_URL: z8.string().url().default("wss://relay.bondsmith.dev")
764
+ BONDSMITH_RELAY_URL: z10.string().url().default("wss://relay.bondsmith.dev"),
765
+ // The audience a non-loopback relay must present. Defaults to the relay URL's host; set it only when the relay's RELAY_AUDIENCE differs from its hostname.
766
+ BONDSMITH_RELAY_AUDIENCE: z10.string().min(1).optional()
767
+ });
768
+ var environmentSchema = baseEnvironmentSchema.superRefine((env, ctx) => {
769
+ if (env.BONDSMITH_RELAY_ENABLED && !isSecureOrLoopbackUrl(env.BONDSMITH_RELAY_URL, "wss:", "ws:")) {
770
+ ctx.addIssue({
771
+ code: z10.ZodIssueCode.custom,
772
+ path: ["BONDSMITH_RELAY_URL"],
773
+ message: "BONDSMITH_RELAY_URL must use wss:// (plain ws:// is only allowed for a loopback relay such as ws://127.0.0.1:8787)"
774
+ });
775
+ }
659
776
  });
777
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
778
+ function isLoopbackUrl(url) {
779
+ return LOOPBACK_HOSTNAMES.has(url.hostname);
780
+ }
781
+ function isSecureOrLoopbackUrl(value, secureProtocol, plainProtocol) {
782
+ if (!URL.canParse(value)) return true;
783
+ const url = new URL(value);
784
+ return url.protocol === secureProtocol || url.protocol === plainProtocol && isLoopbackUrl(url);
785
+ }
786
+ function resolveExpectedRelayAudience(relayUrl, override) {
787
+ if (override) return override;
788
+ const url = new URL(relayUrl);
789
+ return isLoopbackUrl(url) ? null : url.hostname;
790
+ }
660
791
  function getDaemonConfig(env = process.env) {
661
792
  const parsed = environmentSchema.parse(env);
662
793
  return {
@@ -666,7 +797,11 @@ function getDaemonConfig(env = process.env) {
666
797
  apiUrl: parsed.BONDSMITH_API_URL,
667
798
  bindHost: parsed.BONDSMITH_BIND_HOST,
668
799
  relayEnabled: parsed.BONDSMITH_RELAY_ENABLED,
669
- relayUrl: parsed.BONDSMITH_RELAY_URL
800
+ relayUrl: parsed.BONDSMITH_RELAY_URL,
801
+ expectedRelayAudience: resolveExpectedRelayAudience(
802
+ parsed.BONDSMITH_RELAY_URL,
803
+ parsed.BONDSMITH_RELAY_AUDIENCE
804
+ )
670
805
  };
671
806
  }
672
807
 
@@ -904,6 +1039,7 @@ import { promisify } from "util";
904
1039
  var execFile = promisify(execFileCb);
905
1040
  var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
906
1041
  var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
1042
+ var SAFE_GIT_CONFIG = ["-c", "core.fsmonitor=false"];
907
1043
  function splitNulList(output) {
908
1044
  return output.split("\0").filter((entry) => entry.length > 0);
909
1045
  }
@@ -920,7 +1056,10 @@ function isNotARepoError(error2) {
920
1056
  }
921
1057
  async function runGit(args, cwd) {
922
1058
  try {
923
- const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
1059
+ const { stdout } = await execFile("git", [...SAFE_GIT_CONFIG, ...args], {
1060
+ cwd,
1061
+ maxBuffer: MAX_GIT_OUTPUT_BYTES
1062
+ });
924
1063
  return stdout;
925
1064
  } catch (error2) {
926
1065
  if (isNotARepoError(error2)) throw new GitNotARepoError();
@@ -929,7 +1068,10 @@ async function runGit(args, cwd) {
929
1068
  }
930
1069
  async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
931
1070
  try {
932
- const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
1071
+ const { stdout } = await execFile("git", [...SAFE_GIT_CONFIG, ...args], {
1072
+ cwd,
1073
+ maxBuffer: MAX_GIT_OUTPUT_BYTES
1074
+ });
933
1075
  return stdout;
934
1076
  } catch (error2) {
935
1077
  if (isNotARepoError(error2)) throw new GitNotARepoError();
@@ -1005,124 +1147,20 @@ async function validateProjectPath(input) {
1005
1147
  import { sign } from "crypto";
1006
1148
  import WebSocket from "ws";
1007
1149
 
1008
- // src/version.ts
1009
- var DAEMON_VERSION = true ? "0.1.9" : "dev";
1010
- var VERSION_FLAGS = ["--version", "-v"];
1011
- function versionLine() {
1012
- return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
1013
- }
1014
- function isVersionRequest(argv) {
1015
- return argv.some((arg) => VERSION_FLAGS.includes(arg));
1016
- }
1017
-
1018
- // src/sessions/tmux/client.ts
1019
- import { execFile as execFileCb2 } from "child_process";
1020
- import { promisify as promisify2 } from "util";
1021
- var execFile2 = promisify2(execFileCb2);
1022
- var TmuxNoServerRunningError = class extends Error {
1023
- constructor() {
1024
- super("no tmux server running");
1025
- this.name = "TmuxNoServerRunningError";
1026
- }
1027
- };
1028
- var TmuxNotFoundError = class extends Error {
1029
- constructor() {
1030
- super(
1031
- [
1032
- "bondsmithd requires tmux, and it was not found on PATH.",
1033
- "",
1034
- "Install it:",
1035
- " macOS (Homebrew): brew install tmux",
1036
- " Debian/Ubuntu: sudo apt-get install tmux",
1037
- " Fedora/RHEL: sudo dnf install tmux",
1038
- " Arch: sudo pacman -S tmux",
1039
- "",
1040
- "If tmux is already installed, start bondsmithd from a normal interactive shell \u2014",
1041
- "not a LaunchAgent, Docker container, or other minimal-PATH context. Those often",
1042
- "don't inherit the PATH tmux was installed into (e.g. Homebrew's /opt/homebrew/bin",
1043
- "on Apple Silicon)."
1044
- ].join("\n")
1045
- );
1046
- this.name = "TmuxNotFoundError";
1047
- }
1048
- };
1049
- var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
1050
- function filterSpawnEnv(env) {
1051
- const filtered = { ...env };
1052
- for (const key of SENSITIVE_ENV_KEYS) delete filtered[key];
1053
- return filtered;
1054
- }
1055
- async function runTmux(args, options) {
1056
- try {
1057
- const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
1058
- return stdout;
1059
- } catch (error2) {
1060
- if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
1061
- throw error2;
1062
- }
1063
- }
1064
- async function sendKeysLiteral(tmuxSessionName, text) {
1065
- await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
1066
- }
1067
- async function capturePane(tmuxSessionName) {
1068
- return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
1069
- }
1070
- async function getSessionActivity(tmuxSessionName) {
1071
- try {
1072
- const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
1073
- const seconds = Number(out);
1074
- if (!out || Number.isNaN(seconds)) return null;
1075
- return new Date(seconds * 1e3).toISOString();
1076
- } catch {
1077
- return null;
1078
- }
1079
- }
1080
- var PLAN_OPTION = "@bondsmith-plan";
1081
- async function getSessionPlan(tmuxSessionName) {
1082
- try {
1083
- const plan = (await runTmux(["show-options", "-qv", "-t", tmuxSessionName, PLAN_OPTION])).trim();
1084
- return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
1085
- } catch (error2) {
1086
- if (error2 instanceof TmuxNotFoundError) throw error2;
1087
- return null;
1088
- }
1089
- }
1090
- async function listTmuxSessions(formatString) {
1091
- try {
1092
- return await runTmux(["list-sessions", "-F", formatString]);
1093
- } catch (error2) {
1094
- if (isNoServerError(error2)) throw new TmuxNoServerRunningError();
1095
- throw error2;
1096
- }
1097
- }
1098
- var NO_SERVER_MESSAGE_PATTERNS = [
1099
- /no server running/i,
1100
- /no current session/i,
1101
- /no sessions/i,
1102
- /failed to connect to server/i,
1103
- /error connecting to .*\((no such file or directory|connection refused)\)/i
1104
- ];
1105
- function isNoServerError(error2) {
1106
- if (typeof error2 !== "object" || error2 === null) return false;
1107
- const { message, stderr } = error2;
1108
- const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
1109
- return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
1110
- }
1111
-
1112
- // src/security/verify-command.ts
1113
- import { verify as verify2 } from "crypto";
1150
+ // src/security/connection-auth.ts
1151
+ import { randomBytes, verify as verify2 } from "crypto";
1114
1152
 
1115
1153
  // src/security/trusted-controllers.ts
1116
1154
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1117
1155
  import { homedir as homedir2 } from "os";
1118
1156
  import { dirname, join as join2 } from "path";
1119
- import { z as z9 } from "zod";
1120
- var trustedControllerSchema = z9.object({
1121
- id: z9.string(),
1122
- publicKeyPem: z9.string(),
1123
- label: z9.string(),
1124
- addedAt: z9.string(),
1125
- lastUsedAt: z9.string().nullable(),
1157
+ import { z as z11 } from "zod";
1158
+ var trustedControllerSchema = z11.object({
1159
+ id: z11.string(),
1160
+ publicKeyPem: z11.string(),
1161
+ label: z11.string(),
1162
+ addedAt: z11.string(),
1163
+ lastUsedAt: z11.string().nullable(),
1126
1164
  // Obsolete as of ADR-013/BD-477 — replay protection moved to an
1127
1165
  // ephemeral, connection-scoped sequence (connection-auth.ts), never
1128
1166
  // persisted. Accepted-but-ignored here (`.optional()`, not required, and
@@ -1131,11 +1169,11 @@ var trustedControllerSchema = z9.object({
1131
1169
  // before this change still parses correctly — controller trust and
1132
1170
  // revocation state is never lost — instead of throwing on an "unknown"
1133
1171
  // shape. Never written by this file's own persist() calls going forward.
1134
- lastSeenSequence: z9.number().int().nonnegative().optional()
1172
+ lastSeenSequence: z11.number().int().nonnegative().optional()
1135
1173
  });
1136
- var trustedControllersFileSchema = z9.object({
1137
- version: z9.literal(1),
1138
- controllers: z9.array(trustedControllerSchema)
1174
+ var trustedControllersFileSchema = z11.object({
1175
+ version: z11.literal(1),
1176
+ controllers: z11.array(trustedControllerSchema)
1139
1177
  });
1140
1178
  function defaultTrustedControllersPath() {
1141
1179
  return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
@@ -1202,32 +1240,363 @@ async function recordControllerHandshake(controllerId, path = defaultTrustedCont
1202
1240
  await persist(path, next);
1203
1241
  });
1204
1242
  }
1205
- var trustFileLocks = /* @__PURE__ */ new Map();
1206
- function withTrustFileLock(path, fn) {
1207
- const previous = trustFileLocks.get(path) ?? Promise.resolve();
1208
- const next = previous.catch(() => {
1209
- }).then(fn);
1210
- trustFileLocks.set(
1211
- path,
1212
- next.catch(() => {
1213
- })
1214
- );
1215
- return next;
1243
+ var trustFileLocks = /* @__PURE__ */ new Map();
1244
+ function withTrustFileLock(path, fn) {
1245
+ const previous = trustFileLocks.get(path) ?? Promise.resolve();
1246
+ const next = previous.catch(() => {
1247
+ }).then(fn);
1248
+ trustFileLocks.set(
1249
+ path,
1250
+ next.catch(() => {
1251
+ })
1252
+ );
1253
+ return next;
1254
+ }
1255
+ async function persist(path, controllers) {
1256
+ await mkdir(dirname(path), { recursive: true });
1257
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
1258
+ const contents = JSON.stringify({ version: 1, controllers }, null, 2);
1259
+ try {
1260
+ await writeFile(tmpPath, contents, { mode: 384 });
1261
+ await rename(tmpPath, path);
1262
+ } catch (error2) {
1263
+ await rm(tmpPath, { force: true });
1264
+ throw error2;
1265
+ }
1266
+ }
1267
+
1268
+ // src/security/connection-auth.ts
1269
+ var CHALLENGE_LENGTH_BYTES = 32;
1270
+ var CONNECTION_ID_LENGTH_BYTES = 24;
1271
+ var CHALLENGE_LIFETIME_MS = 3e4;
1272
+ var UNAUTHENTICATED_CONNECTION_TIMEOUT_MS = CHALLENGE_LIFETIME_MS;
1273
+ var MAX_FAILED_HANDSHAKES_PER_CONNECTION = 3;
1274
+ var MAX_FAILED_HANDSHAKES_PER_WINDOW = 300;
1275
+ var HANDSHAKE_RATE_LIMIT_WINDOW_MS = 6e4;
1276
+ var failedHandshakeTimestamps = [];
1277
+ function isHandshakeRateLimited() {
1278
+ const windowStart = Date.now() - HANDSHAKE_RATE_LIMIT_WINDOW_MS;
1279
+ failedHandshakeTimestamps = failedHandshakeTimestamps.filter((t) => t > windowStart);
1280
+ return failedHandshakeTimestamps.length >= MAX_FAILED_HANDSHAKES_PER_WINDOW;
1281
+ }
1282
+ function recordFailedHandshake() {
1283
+ failedHandshakeTimestamps.push(Date.now());
1284
+ }
1285
+ var ConnectionAuthState = class {
1286
+ constructor(daemonFingerprint) {
1287
+ this.daemonFingerprint = daemonFingerprint;
1288
+ this.connectionId = randomBytes(CONNECTION_ID_LENGTH_BYTES).toString("base64url");
1289
+ this.challenge = randomBytes(CHALLENGE_LENGTH_BYTES).toString("base64url");
1290
+ this.challengeExpiresAt = Date.now() + CHALLENGE_LIFETIME_MS;
1291
+ }
1292
+ connectionId;
1293
+ challenge;
1294
+ challengeExpiresAt;
1295
+ authenticated = null;
1296
+ lastSequence = 0;
1297
+ connectionKey = null;
1298
+ failedHandshakes = 0;
1299
+ // BD-487: set synchronously by revocation (server.ts's
1300
+ // revokeControllerConnections/revokeAllConnections, or relay-client.ts's
1301
+ // identically-named functions), checked synchronously again in
1302
+ // router.ts's handleBrowserMessage immediately before dispatch() runs —
1303
+ // closes the gap where a command already past verifyCommand's async
1304
+ // awaits (getTrustedController's disk read, checkAndAdvanceSequence)
1305
+ // would otherwise still execute even though revocation was supposed to
1306
+ // have already ended this connection's authority. Deliberately a plain
1307
+ // synchronous flag, not itself awaited anywhere: the race this closes is
1308
+ // specifically the gap between an async check's awaits, so the fix must
1309
+ // be a check with zero await distance from dispatch(), not another
1310
+ // async lookup that reopens the same kind of window.
1311
+ revoked = false;
1312
+ // Serializes sequence verify-and-advance within this one connection —
1313
+ // without this, two commands signed close together could have their
1314
+ // async verification interleave and both read the same "last sequence"
1315
+ // before either advances it, letting a lower/equal sequence slip through
1316
+ // as if it were the next one. A per-connection promise chain (same
1317
+ // pattern as trusted-controllers.ts's withTrustFileLock, but in-memory
1318
+ // and scoped to one socket instead of a file path) is sufficient — never
1319
+ // contends across connections, and there is no file I/O to race.
1320
+ sequenceChain = Promise.resolve();
1321
+ // Serializes authenticate() the same way sequenceChain serializes
1322
+ // checkAndAdvanceSequence — adversarial review (BD-477 follow-up) found
1323
+ // that two connection.authenticate frames arriving back-to-back on one
1324
+ // socket could both pass the `this.authenticated === null` check before
1325
+ // either reached the assignment at the end of the async verification
1326
+ // chain, since getTrustedController is a real await gap. Both attempts
1327
+ // still require a validly-signed transcript (this was never an auth
1328
+ // bypass), but the second could silently overwrite the first's identity
1329
+ // and double-register the socket in server.ts's connectionsByController
1330
+ // map. Without a chain here, the fix would need a synchronous
1331
+ // compare-and-set instead, which would need to happen before the
1332
+ // signature is even checked — this chain is simpler and matches the
1333
+ // sequence check's own established pattern.
1334
+ authenticateChain = Promise.resolve();
1335
+ getChallenge() {
1336
+ return {
1337
+ connectionId: this.connectionId,
1338
+ challenge: this.challenge,
1339
+ expiresAt: this.challengeExpiresAt
1340
+ };
1341
+ }
1342
+ isAuthenticated() {
1343
+ return this.authenticated !== null;
1344
+ }
1345
+ /** The key derived from this connection's handshake, held in memory only; null when the browser offered no key. */
1346
+ setConnectionKey(key) {
1347
+ this.connectionKey = key;
1348
+ }
1349
+ getConnectionKey() {
1350
+ return this.connectionKey;
1351
+ }
1352
+ /** Ends this connection's authority and marks it for closing, since the transports close a connection once its handshake attempts are exhausted. */
1353
+ abandonAfterKeyAgreementFailure() {
1354
+ this.revoked = true;
1355
+ this.failedHandshakes = MAX_FAILED_HANDSHAKES_PER_CONNECTION;
1356
+ }
1357
+ /** 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. */
1358
+ revoke() {
1359
+ this.revoked = true;
1360
+ }
1361
+ isRevoked() {
1362
+ return this.revoked;
1363
+ }
1364
+ /** Unsolicited daemon broadcasts (session.created/updated) go only to connections that completed the handshake and still hold authority. */
1365
+ canReceiveBroadcasts() {
1366
+ return this.authenticated !== null && !this.revoked;
1367
+ }
1368
+ /** 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. */
1369
+ getAuthenticatedIdentity() {
1370
+ return this.authenticated;
1371
+ }
1372
+ /**
1373
+ * Verifies a signed connection-handshake transcript and, on success,
1374
+ * marks this connection authenticated — exactly once, including under
1375
+ * concurrent calls. A call after a SUCCESSFUL handshake (even one that
1376
+ * overlaps in time) is rejected with "already_authenticated"; a failed
1377
+ * attempt does not consume the slot, up to this connection's failure cap.
1378
+ * The challenge is bound to this ConnectionAuthState instance and is never
1379
+ * reissued or extended, so a duplicate, delayed, or concurrent attempt can
1380
+ * never re-authenticate or reset this connection's sequence counter.
1381
+ *
1382
+ * Serialized via authenticateChain (same pattern as
1383
+ * checkAndAdvanceSequence's sequenceChain) so two connection.authenticate
1384
+ * frames arriving close together on one socket can't both observe
1385
+ * `this.authenticated === null` before either finishes — without this,
1386
+ * both could pass the guard, both complete real signature verification
1387
+ * (this was never an auth bypass on its own), and the second's
1388
+ * assignment would silently overwrite the first's, double-registering
1389
+ * the socket under two controller identities.
1390
+ *
1391
+ * Checks run in a fixed order regardless of which field is
1392
+ * attacker-controlled, matching verify-command.ts's own discipline:
1393
+ * connection binding and freshness are checked before the signature is
1394
+ * even verified (they're cheap, local, and don't leak anything about
1395
+ * controller trust), then the signature, then controller trust —
1396
+ * signature must pass before ANY other field of the transcript is
1397
+ * trusted for a decision, since those fields are attacker-controlled
1398
+ * until the signature over the whole transcript is checked.
1399
+ */
1400
+ authenticate(signed, trustedControllersPath) {
1401
+ const task = this.authenticateChain.then(
1402
+ () => this.authenticateOnce(signed, trustedControllersPath)
1403
+ );
1404
+ this.authenticateChain = task.catch(() => {
1405
+ });
1406
+ return task;
1407
+ }
1408
+ async authenticateOnce(signed, trustedControllersPath) {
1409
+ if (this.authenticated) {
1410
+ return { ok: false, reason: "already_authenticated" };
1411
+ }
1412
+ if (isHandshakeRateLimited()) {
1413
+ this.failedHandshakes += 1;
1414
+ return { ok: false, reason: "rate_limited" };
1415
+ }
1416
+ const result = await this.verifyHandshake(signed, trustedControllersPath);
1417
+ if (!result.ok) {
1418
+ this.failedHandshakes += 1;
1419
+ recordFailedHandshake();
1420
+ }
1421
+ return result;
1422
+ }
1423
+ /** True once this connection has used up its failed handshakes; its transport must close it. */
1424
+ hasExhaustedHandshakeAttempts() {
1425
+ return this.failedHandshakes >= MAX_FAILED_HANDSHAKES_PER_CONNECTION;
1426
+ }
1427
+ async verifyHandshake(signed, trustedControllersPath) {
1428
+ const { transcript, signature } = signed;
1429
+ if (transcript.purpose !== CONNECTION_CHALLENGE_PURPOSE) {
1430
+ return { ok: false, reason: "bad_signature" };
1431
+ }
1432
+ if (transcript.connectionId !== this.connectionId || transcript.challenge !== this.challenge) {
1433
+ return { ok: false, reason: "wrong_connection" };
1434
+ }
1435
+ if (Date.now() >= this.challengeExpiresAt) {
1436
+ return { ok: false, reason: "challenge_expired" };
1437
+ }
1438
+ if (transcript.daemonFingerprint !== this.daemonFingerprint) {
1439
+ return { ok: false, reason: "wrong_daemon" };
1440
+ }
1441
+ const controller = await getTrustedController(transcript.controllerId, trustedControllersPath);
1442
+ if (!controller) {
1443
+ return { ok: false, reason: "untrusted_controller" };
1444
+ }
1445
+ let signatureValid;
1446
+ try {
1447
+ signatureValid = verify2(
1448
+ "sha256",
1449
+ Buffer.from(canonicalize(transcript), "utf8"),
1450
+ // WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
1451
+ // DER encoding node:crypto defaults to for EC keys — matches
1452
+ // verify-command.ts's and pairing.ts's identical handling.
1453
+ { key: controller.publicKeyPem, dsaEncoding: "ieee-p1363" },
1454
+ Buffer.from(signature, "base64")
1455
+ );
1456
+ } catch {
1457
+ signatureValid = false;
1458
+ }
1459
+ if (!signatureValid) {
1460
+ return { ok: false, reason: "bad_signature" };
1461
+ }
1462
+ this.authenticated = { controllerId: transcript.controllerId, accountId: transcript.accountId };
1463
+ recordControllerHandshake(transcript.controllerId, trustedControllersPath).catch(
1464
+ (err) => {
1465
+ console.error("Failed to record controller handshake (non-fatal)", err);
1466
+ }
1467
+ );
1468
+ return { ok: true, controllerId: transcript.controllerId, accountId: transcript.accountId };
1469
+ }
1470
+ /**
1471
+ * Verifies a command envelope's sequence number is strictly greater than
1472
+ * the last one accepted on THIS connection, then advances the
1473
+ * counter — serialized via sequenceChain so two verify calls racing
1474
+ * close together can never both read the same lastSequence before
1475
+ * either advances it. Must only be called for an envelope whose
1476
+ * connectionId has already been confirmed to match this connection
1477
+ * (router.ts checks that before calling this) and whose controllerId
1478
+ * matches this connection's authenticated controller (same caller
1479
+ * responsibility) — this method only ever checks the sequence number
1480
+ * itself, not connection/controller binding, which is the caller's job.
1481
+ */
1482
+ async checkAndAdvanceSequence(sequence) {
1483
+ if (!this.authenticated) {
1484
+ return { ok: false, reason: "not_authenticated" };
1485
+ }
1486
+ const task = this.sequenceChain.then(() => {
1487
+ if (sequence <= this.lastSequence) {
1488
+ return { ok: false, reason: "replayed" };
1489
+ }
1490
+ this.lastSequence = sequence;
1491
+ return { ok: true };
1492
+ });
1493
+ this.sequenceChain = task.catch(() => {
1494
+ });
1495
+ return task;
1496
+ }
1497
+ };
1498
+
1499
+ // src/version.ts
1500
+ var DAEMON_VERSION = true ? "0.1.11" : "dev";
1501
+ var VERSION_FLAGS = ["--version", "-v"];
1502
+ function versionLine() {
1503
+ return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
1504
+ }
1505
+ function isVersionRequest(argv) {
1506
+ return argv.some((arg) => VERSION_FLAGS.includes(arg));
1507
+ }
1508
+
1509
+ // src/sessions/tmux/client.ts
1510
+ import { execFile as execFileCb2 } from "child_process";
1511
+ import { promisify as promisify2 } from "util";
1512
+ var execFile2 = promisify2(execFileCb2);
1513
+ var TmuxNoServerRunningError = class extends Error {
1514
+ constructor() {
1515
+ super("no tmux server running");
1516
+ this.name = "TmuxNoServerRunningError";
1517
+ }
1518
+ };
1519
+ var TmuxNotFoundError = class extends Error {
1520
+ constructor() {
1521
+ super(
1522
+ [
1523
+ "bondsmithd requires tmux, and it was not found on PATH.",
1524
+ "",
1525
+ "Install it:",
1526
+ " macOS (Homebrew): brew install tmux",
1527
+ " Debian/Ubuntu: sudo apt-get install tmux",
1528
+ " Fedora/RHEL: sudo dnf install tmux",
1529
+ " Arch: sudo pacman -S tmux",
1530
+ "",
1531
+ "If tmux is already installed, start bondsmithd from a normal interactive shell \u2014",
1532
+ "not a LaunchAgent, Docker container, or other minimal-PATH context. Those often",
1533
+ "don't inherit the PATH tmux was installed into (e.g. Homebrew's /opt/homebrew/bin",
1534
+ "on Apple Silicon)."
1535
+ ].join("\n")
1536
+ );
1537
+ this.name = "TmuxNotFoundError";
1538
+ }
1539
+ };
1540
+ var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
1541
+ function filterSpawnEnv(env) {
1542
+ const filtered = { ...env };
1543
+ for (const key of SENSITIVE_ENV_KEYS) delete filtered[key];
1544
+ return filtered;
1545
+ }
1546
+ async function runTmux(args, options) {
1547
+ try {
1548
+ const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
1549
+ return stdout;
1550
+ } catch (error2) {
1551
+ if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
1552
+ throw error2;
1553
+ }
1554
+ }
1555
+ async function sendKeysLiteral(tmuxSessionName, text) {
1556
+ await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
1557
+ }
1558
+ async function capturePane(tmuxSessionName) {
1559
+ return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
1560
+ }
1561
+ async function getSessionActivity(tmuxSessionName) {
1562
+ try {
1563
+ const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
1564
+ const seconds = Number(out);
1565
+ if (!out || Number.isNaN(seconds)) return null;
1566
+ return new Date(seconds * 1e3).toISOString();
1567
+ } catch {
1568
+ return null;
1569
+ }
1570
+ }
1571
+ var PLAN_OPTION = "@bondsmith-plan";
1572
+ async function getSessionPlan(tmuxSessionName) {
1573
+ const plan = (await runTmux(["show-options", "-qv", "-t", tmuxSessionName, PLAN_OPTION])).trim();
1574
+ return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
1216
1575
  }
1217
- async function persist(path, controllers) {
1218
- await mkdir(dirname(path), { recursive: true });
1219
- const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
1220
- const contents = JSON.stringify({ version: 1, controllers }, null, 2);
1576
+ async function listTmuxSessions(formatString) {
1221
1577
  try {
1222
- await writeFile(tmpPath, contents, { mode: 384 });
1223
- await rename(tmpPath, path);
1578
+ return await runTmux(["list-sessions", "-F", formatString]);
1224
1579
  } catch (error2) {
1225
- await rm(tmpPath, { force: true });
1580
+ if (isNoServerError(error2)) throw new TmuxNoServerRunningError();
1226
1581
  throw error2;
1227
1582
  }
1228
1583
  }
1584
+ var NO_SERVER_MESSAGE_PATTERNS = [
1585
+ /no server running/i,
1586
+ /no current session/i,
1587
+ /no sessions/i,
1588
+ /failed to connect to server/i,
1589
+ /error connecting to .*\((no such file or directory|connection refused)\)/i
1590
+ ];
1591
+ function isNoServerError(error2) {
1592
+ if (typeof error2 !== "object" || error2 === null) return false;
1593
+ const { message, stderr } = error2;
1594
+ const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
1595
+ return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
1596
+ }
1229
1597
 
1230
1598
  // src/security/verify-command.ts
1599
+ import { verify as verify3 } from "crypto";
1231
1600
  var ENVELOPE_MAX_AGE_MS = 6e4;
1232
1601
  var CLOCK_SKEW_TOLERANCE_MS = 5e3;
1233
1602
  async function verifyCommand(command, daemonFingerprint, connectionState, trustedControllersPath) {
@@ -1249,7 +1618,7 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
1249
1618
  const canonicalBytes = Buffer.from(canonicalize(envelope), "utf8");
1250
1619
  let signatureValid;
1251
1620
  try {
1252
- signatureValid = verify2(
1621
+ signatureValid = verify3(
1253
1622
  "sha256",
1254
1623
  canonicalBytes,
1255
1624
  // WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
@@ -1357,6 +1726,14 @@ function createOutputPacer(options) {
1357
1726
  };
1358
1727
  }
1359
1728
 
1729
+ // src/ws/sanitize-insert-text.ts
1730
+ var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f‪-‮⁦-⁩]/g;
1731
+ function sanitizeInsertText(text) {
1732
+ const withLineFeeds = text.replace(/\r\n?/g, "\n");
1733
+ const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
1734
+ return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
1735
+ }
1736
+
1360
1737
  // src/ws/router.ts
1361
1738
  var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
1362
1739
  function isSignableAction(type) {
@@ -1526,7 +1903,30 @@ async function handleBrowserMessage(conn, raw, deps) {
1526
1903
  return;
1527
1904
  }
1528
1905
  conn.markControllerId(result.controllerId);
1529
- conn.send({ type: "connection.authenticated", connectionId: conn.connectionAuth.connectionId });
1906
+ let daemonEcdhPublicKey;
1907
+ if (signed.transcript.clientEcdhPublicKey) {
1908
+ try {
1909
+ const exchange = await createEphemeralKeyExchange("daemon");
1910
+ daemonEcdhPublicKey = exchange.publicKey;
1911
+ const keyedTranscript = buildDaemonProofTranscript(signed.transcript, daemonEcdhPublicKey);
1912
+ if (!keyedTranscript) throw new Error("A key agreement needs a clientNonce");
1913
+ conn.connectionAuth.setConnectionKey(
1914
+ await exchange.deriveKey(signed.transcript.clientEcdhPublicKey, keyedTranscript)
1915
+ );
1916
+ } catch (err) {
1917
+ console.error("Key agreement failed; ending the connection", err);
1918
+ conn.connectionAuth.abandonAfterKeyAgreementFailure();
1919
+ conn.send({ type: "connection.authenticationFailed", reason: "key_agreement_failed" });
1920
+ return;
1921
+ }
1922
+ }
1923
+ const proofTranscript = buildDaemonProofTranscript(signed.transcript, daemonEcdhPublicKey);
1924
+ conn.send({
1925
+ type: "connection.authenticated",
1926
+ connectionId: conn.connectionAuth.connectionId,
1927
+ ...proofTranscript && { daemonSignature: deps.signDaemonProof(proofTranscript) },
1928
+ ...daemonEcdhPublicKey && { daemonEcdhPublicKey }
1929
+ });
1530
1930
  return;
1531
1931
  }
1532
1932
  if (isSignableAction(parsed.data.type)) {
@@ -1640,10 +2040,14 @@ async function dispatch(conn, message, {
1640
2040
  case "session.insertText": {
1641
2041
  const session = getSessionOrError(sessionManager, conn, message.sessionId);
1642
2042
  if (!session) return;
2043
+ const { text, strippedCount } = sanitizeInsertText(message.text);
2044
+ if (strippedCount > 0) {
2045
+ console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
2046
+ }
1643
2047
  try {
1644
2048
  await sendKeysLiteral(
1645
2049
  session.tmuxSessionName,
1646
- `${BRACKETED_PASTE_START}${message.text}${BRACKETED_PASTE_END}`
2050
+ `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
1647
2051
  );
1648
2052
  } catch (err) {
1649
2053
  conn.send(error(errorMessageOf(err, "failed to insert text")));
@@ -1782,206 +2186,6 @@ async function dispatch(conn, message, {
1782
2186
  }
1783
2187
  }
1784
2188
 
1785
- // src/security/connection-auth.ts
1786
- import { randomBytes, verify as verify3 } from "crypto";
1787
- var CHALLENGE_LENGTH_BYTES = 32;
1788
- var CONNECTION_ID_LENGTH_BYTES = 24;
1789
- var CHALLENGE_LIFETIME_MS = 3e4;
1790
- var MAX_HANDSHAKE_ATTEMPTS_PER_WINDOW = 30;
1791
- var HANDSHAKE_RATE_LIMIT_WINDOW_MS = 6e4;
1792
- var handshakeAttemptTimestamps = [];
1793
- function isHandshakeRateLimited() {
1794
- const windowStart = Date.now() - HANDSHAKE_RATE_LIMIT_WINDOW_MS;
1795
- handshakeAttemptTimestamps = handshakeAttemptTimestamps.filter((t) => t > windowStart);
1796
- return handshakeAttemptTimestamps.length >= MAX_HANDSHAKE_ATTEMPTS_PER_WINDOW;
1797
- }
1798
- function recordHandshakeAttempt() {
1799
- handshakeAttemptTimestamps.push(Date.now());
1800
- }
1801
- var ConnectionAuthState = class {
1802
- constructor(daemonFingerprint) {
1803
- this.daemonFingerprint = daemonFingerprint;
1804
- this.connectionId = randomBytes(CONNECTION_ID_LENGTH_BYTES).toString("base64url");
1805
- this.challenge = randomBytes(CHALLENGE_LENGTH_BYTES).toString("base64url");
1806
- this.challengeExpiresAt = Date.now() + CHALLENGE_LIFETIME_MS;
1807
- }
1808
- connectionId;
1809
- challenge;
1810
- challengeExpiresAt;
1811
- authenticated = null;
1812
- lastSequence = 0;
1813
- // BD-487: set synchronously by revocation (server.ts's
1814
- // revokeControllerConnections/revokeAllConnections, or relay-client.ts's
1815
- // identically-named functions), checked synchronously again in
1816
- // router.ts's handleBrowserMessage immediately before dispatch() runs —
1817
- // closes the gap where a command already past verifyCommand's async
1818
- // awaits (getTrustedController's disk read, checkAndAdvanceSequence)
1819
- // would otherwise still execute even though revocation was supposed to
1820
- // have already ended this connection's authority. Deliberately a plain
1821
- // synchronous flag, not itself awaited anywhere: the race this closes is
1822
- // specifically the gap between an async check's awaits, so the fix must
1823
- // be a check with zero await distance from dispatch(), not another
1824
- // async lookup that reopens the same kind of window.
1825
- revoked = false;
1826
- // Serializes sequence verify-and-advance within this one connection —
1827
- // without this, two commands signed close together could have their
1828
- // async verification interleave and both read the same "last sequence"
1829
- // before either advances it, letting a lower/equal sequence slip through
1830
- // as if it were the next one. A per-connection promise chain (same
1831
- // pattern as trusted-controllers.ts's withTrustFileLock, but in-memory
1832
- // and scoped to one socket instead of a file path) is sufficient — never
1833
- // contends across connections, and there is no file I/O to race.
1834
- sequenceChain = Promise.resolve();
1835
- // Serializes authenticate() the same way sequenceChain serializes
1836
- // checkAndAdvanceSequence — adversarial review (BD-477 follow-up) found
1837
- // that two connection.authenticate frames arriving back-to-back on one
1838
- // socket could both pass the `this.authenticated === null` check before
1839
- // either reached the assignment at the end of the async verification
1840
- // chain, since getTrustedController is a real await gap. Both attempts
1841
- // still require a validly-signed transcript (this was never an auth
1842
- // bypass), but the second could silently overwrite the first's identity
1843
- // and double-register the socket in server.ts's connectionsByController
1844
- // map. Without a chain here, the fix would need a synchronous
1845
- // compare-and-set instead, which would need to happen before the
1846
- // signature is even checked — this chain is simpler and matches the
1847
- // sequence check's own established pattern.
1848
- authenticateChain = Promise.resolve();
1849
- getChallenge() {
1850
- return {
1851
- connectionId: this.connectionId,
1852
- challenge: this.challenge,
1853
- expiresAt: this.challengeExpiresAt
1854
- };
1855
- }
1856
- isAuthenticated() {
1857
- return this.authenticated !== null;
1858
- }
1859
- /** 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. */
1860
- revoke() {
1861
- this.revoked = true;
1862
- }
1863
- isRevoked() {
1864
- return this.revoked;
1865
- }
1866
- /** 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. */
1867
- getAuthenticatedIdentity() {
1868
- return this.authenticated;
1869
- }
1870
- /**
1871
- * Verifies a signed connection-handshake transcript and, on success,
1872
- * marks this connection authenticated — exactly once, including under
1873
- * concurrent calls. A second call (whether the first succeeded or
1874
- * failed, and regardless of whether the two calls overlap in time) is
1875
- * rejected with "already_authenticated": the challenge is bound to this
1876
- * ConnectionAuthState instance and is never reissued or extended, so a
1877
- * duplicate, delayed, or concurrent handshake attempt can never
1878
- * re-authenticate or reset this connection's sequence counter.
1879
- *
1880
- * Serialized via authenticateChain (same pattern as
1881
- * checkAndAdvanceSequence's sequenceChain) so two connection.authenticate
1882
- * frames arriving close together on one socket can't both observe
1883
- * `this.authenticated === null` before either finishes — without this,
1884
- * both could pass the guard, both complete real signature verification
1885
- * (this was never an auth bypass on its own), and the second's
1886
- * assignment would silently overwrite the first's, double-registering
1887
- * the socket under two controller identities.
1888
- *
1889
- * Checks run in a fixed order regardless of which field is
1890
- * attacker-controlled, matching verify-command.ts's own discipline:
1891
- * connection binding and freshness are checked before the signature is
1892
- * even verified (they're cheap, local, and don't leak anything about
1893
- * controller trust), then the signature, then controller trust —
1894
- * signature must pass before ANY other field of the transcript is
1895
- * trusted for a decision, since those fields are attacker-controlled
1896
- * until the signature over the whole transcript is checked.
1897
- */
1898
- authenticate(signed, trustedControllersPath) {
1899
- const task = this.authenticateChain.then(
1900
- () => this.authenticateOnce(signed, trustedControllersPath)
1901
- );
1902
- this.authenticateChain = task.catch(() => {
1903
- });
1904
- return task;
1905
- }
1906
- async authenticateOnce(signed, trustedControllersPath) {
1907
- if (this.authenticated) {
1908
- return { ok: false, reason: "already_authenticated" };
1909
- }
1910
- if (isHandshakeRateLimited()) {
1911
- return { ok: false, reason: "rate_limited" };
1912
- }
1913
- recordHandshakeAttempt();
1914
- const { transcript, signature } = signed;
1915
- if (transcript.purpose !== CONNECTION_CHALLENGE_PURPOSE) {
1916
- return { ok: false, reason: "bad_signature" };
1917
- }
1918
- if (transcript.connectionId !== this.connectionId || transcript.challenge !== this.challenge) {
1919
- return { ok: false, reason: "wrong_connection" };
1920
- }
1921
- if (Date.now() >= this.challengeExpiresAt) {
1922
- return { ok: false, reason: "challenge_expired" };
1923
- }
1924
- if (transcript.daemonFingerprint !== this.daemonFingerprint) {
1925
- return { ok: false, reason: "wrong_daemon" };
1926
- }
1927
- const controller = await getTrustedController(transcript.controllerId, trustedControllersPath);
1928
- if (!controller) {
1929
- return { ok: false, reason: "untrusted_controller" };
1930
- }
1931
- let signatureValid;
1932
- try {
1933
- signatureValid = verify3(
1934
- "sha256",
1935
- Buffer.from(canonicalize(transcript), "utf8"),
1936
- // WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
1937
- // DER encoding node:crypto defaults to for EC keys — matches
1938
- // verify-command.ts's and pairing.ts's identical handling.
1939
- { key: controller.publicKeyPem, dsaEncoding: "ieee-p1363" },
1940
- Buffer.from(signature, "base64")
1941
- );
1942
- } catch {
1943
- signatureValid = false;
1944
- }
1945
- if (!signatureValid) {
1946
- return { ok: false, reason: "bad_signature" };
1947
- }
1948
- this.authenticated = { controllerId: transcript.controllerId, accountId: transcript.accountId };
1949
- recordControllerHandshake(transcript.controllerId, trustedControllersPath).catch(
1950
- (err) => {
1951
- console.error("Failed to record controller handshake (non-fatal)", err);
1952
- }
1953
- );
1954
- return { ok: true, controllerId: transcript.controllerId, accountId: transcript.accountId };
1955
- }
1956
- /**
1957
- * Verifies a command envelope's sequence number is strictly greater than
1958
- * the last one accepted on THIS connection, then advances the
1959
- * counter — serialized via sequenceChain so two verify calls racing
1960
- * close together can never both read the same lastSequence before
1961
- * either advances it. Must only be called for an envelope whose
1962
- * connectionId has already been confirmed to match this connection
1963
- * (router.ts checks that before calling this) and whose controllerId
1964
- * matches this connection's authenticated controller (same caller
1965
- * responsibility) — this method only ever checks the sequence number
1966
- * itself, not connection/controller binding, which is the caller's job.
1967
- */
1968
- async checkAndAdvanceSequence(sequence) {
1969
- if (!this.authenticated) {
1970
- return { ok: false, reason: "not_authenticated" };
1971
- }
1972
- const task = this.sequenceChain.then(() => {
1973
- if (sequence <= this.lastSequence) {
1974
- return { ok: false, reason: "replayed" };
1975
- }
1976
- this.lastSequence = sequence;
1977
- return { ok: true };
1978
- });
1979
- this.sequenceChain = task.catch(() => {
1980
- });
1981
- return task;
1982
- }
1983
- };
1984
-
1985
2189
  // src/relay/relay-session-registry.ts
1986
2190
  var MAX_CONCURRENT_RELAY_SESSIONS = 32;
1987
2191
  var RelaySessionRegistry = class {
@@ -2034,20 +2238,31 @@ function signTranscript(transcript, privateKeyPem) {
2034
2238
  }
2035
2239
  function startRelayClient(deps) {
2036
2240
  const sessions = new RelaySessionRegistry();
2241
+ const unauthenticatedTimers = /* @__PURE__ */ new Map();
2242
+ function forgetSession(relaySessionId) {
2243
+ clearTimeout(unauthenticatedTimers.get(relaySessionId));
2244
+ unauthenticatedTimers.delete(relaySessionId);
2245
+ sessions.delete(relaySessionId);
2246
+ }
2247
+ function forgetAllSessions() {
2248
+ for (const timer of unauthenticatedTimers.values()) clearTimeout(timer);
2249
+ unauthenticatedTimers.clear();
2250
+ sessions.deleteAll();
2251
+ }
2037
2252
  let stopped = false;
2038
2253
  let socket;
2039
2254
  let reconnectAttempt = 0;
2040
2255
  let reconnectTimer;
2041
2256
  function broadcast(message) {
2042
2257
  for (const entry of sessions.values()) {
2043
- entry.conn.send(message);
2258
+ if (entry.connectionAuth.canReceiveBroadcasts()) entry.conn.send(message);
2044
2259
  }
2045
2260
  }
2046
2261
  const routerDeps = { ...deps, broadcast };
2047
2262
  function revokeSession(relaySessionId) {
2048
2263
  sessions.get(relaySessionId)?.connectionAuth.revoke();
2049
2264
  if (socket) send(socket, { type: "relay.closeSession", relaySessionId });
2050
- sessions.delete(relaySessionId);
2265
+ forgetSession(relaySessionId);
2051
2266
  }
2052
2267
  function revokeControllerConnections(controllerId) {
2053
2268
  for (const entry of sessions.values()) {
@@ -2104,6 +2319,13 @@ function startRelayClient(deps) {
2104
2319
  (connectionAuth) => buildRouterConnection(relaySessionId, ws, connectionAuth)
2105
2320
  );
2106
2321
  const entry = sessions.get(relaySessionId);
2322
+ const unauthenticatedTimer = setTimeout(() => {
2323
+ if (sessions.get(relaySessionId) === entry && !entry.connectionAuth.isAuthenticated()) {
2324
+ revokeSession(relaySessionId);
2325
+ }
2326
+ }, deps.unauthenticatedTimeoutMs ?? UNAUTHENTICATED_CONNECTION_TIMEOUT_MS);
2327
+ unauthenticatedTimer.unref();
2328
+ unauthenticatedTimers.set(relaySessionId, unauthenticatedTimer);
2107
2329
  send(ws, { type: "relay.sessionOpenAck", relaySessionId });
2108
2330
  const challenge = entry.connectionAuth.getChallenge();
2109
2331
  entry.conn.send({
@@ -2120,7 +2342,11 @@ function startRelayClient(deps) {
2120
2342
  function handleFrame(frame) {
2121
2343
  const entry = sessions.get(frame.relaySessionId);
2122
2344
  if (!entry) return;
2123
- handleBrowserMessage(entry.conn, frame.payload, routerDeps).catch((err) => {
2345
+ handleBrowserMessage(entry.conn, frame.payload, routerDeps).then(() => {
2346
+ if (entry.connectionAuth.hasExhaustedHandshakeAttempts()) {
2347
+ revokeSession(frame.relaySessionId);
2348
+ }
2349
+ }).catch((err) => {
2124
2350
  console.error("relay-client: unhandled error in handleBrowserMessage", err);
2125
2351
  entry.conn.send({
2126
2352
  type: "error",
@@ -2144,6 +2370,13 @@ function startRelayClient(deps) {
2144
2370
  const message = result.data;
2145
2371
  switch (message.type) {
2146
2372
  case "relay.challenge": {
2373
+ if (deps.expectedRelayAudience !== null && message.relayAudience !== deps.expectedRelayAudience) {
2374
+ console.error(
2375
+ `relay-client: refusing to register \u2014 relay presented audience "${message.relayAudience}", expected "${deps.expectedRelayAudience}"`
2376
+ );
2377
+ ws.close();
2378
+ return;
2379
+ }
2147
2380
  const transcript = {
2148
2381
  version: 1,
2149
2382
  purpose: RELAY_REGISTER_PURPOSE,
@@ -2178,7 +2411,7 @@ function startRelayClient(deps) {
2178
2411
  return;
2179
2412
  }
2180
2413
  case "relay.sessionClosed": {
2181
- sessions.delete(message.relaySessionId);
2414
+ forgetSession(message.relaySessionId);
2182
2415
  return;
2183
2416
  }
2184
2417
  case "relay.frame": {
@@ -2190,7 +2423,7 @@ function startRelayClient(deps) {
2190
2423
  ws.on("error", () => {
2191
2424
  });
2192
2425
  ws.on("close", () => {
2193
- sessions.deleteAll();
2426
+ forgetAllSessions();
2194
2427
  if (socket === ws) socket = void 0;
2195
2428
  scheduleReconnect();
2196
2429
  });
@@ -2200,7 +2433,7 @@ function startRelayClient(deps) {
2200
2433
  stop: () => {
2201
2434
  stopped = true;
2202
2435
  if (reconnectTimer) clearTimeout(reconnectTimer);
2203
- sessions.deleteAll();
2436
+ forgetAllSessions();
2204
2437
  socket?.close();
2205
2438
  },
2206
2439
  broadcast,
@@ -2274,6 +2507,12 @@ function extractPemBlock(text, label) {
2274
2507
  return text.match(pattern)?.[0] ?? null;
2275
2508
  }
2276
2509
 
2510
+ // src/security/daemon-proof.ts
2511
+ import { sign as sign3 } from "crypto";
2512
+ function createDaemonProofSigner(privateKeyPem) {
2513
+ return (transcript) => sign3(null, Buffer.from(canonicalize(transcript), "utf8"), privateKeyPem).toString("base64");
2514
+ }
2515
+
2277
2516
  // src/security/dev-tunnel-token-file.ts
2278
2517
  import { randomBytes as randomBytes2 } from "crypto";
2279
2518
  import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
@@ -2516,12 +2755,17 @@ var PendingAuthorizationStore = class {
2516
2755
  };
2517
2756
 
2518
2757
  // src/security/revocation-sync.ts
2758
+ import { z as z12 } from "zod";
2519
2759
  var POLL_INTERVAL_MS = 3e4;
2520
2760
  var POLL_JITTER_MS = 6e3;
2521
2761
  var POLL_REQUEST_TIMEOUT_MS = 1e4;
2522
2762
  var BACKOFF_BASE_MS = 2e3;
2523
2763
  var BACKOFF_MAX_MS = 2e4;
2524
2764
  var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
2765
+ var checkRevocationStatusResponseSchema = z12.object({
2766
+ pairingRevoked: z12.boolean(),
2767
+ revokedControllerPublicKeys: z12.array(z12.string())
2768
+ });
2525
2769
  function jitteredInterval(deps) {
2526
2770
  const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
2527
2771
  const jitter = deps.pollJitterMs ?? POLL_JITTER_MS;
@@ -2543,17 +2787,22 @@ async function pollOnce(deps) {
2543
2787
  if (trusted.length === 0) return true;
2544
2788
  let response;
2545
2789
  try {
2546
- response = await withTimeout(
2547
- callTRPCQuery(
2548
- deps.apiUrl,
2549
- "daemonPairing.checkRevocationStatus",
2550
- {
2551
- daemonPublicKey: deps.daemonPublicKey,
2552
- controllerPublicKeys: trusted.map((c) => c.publicKeyPem)
2553
- }
2554
- ),
2790
+ const raw = await withTimeout(
2791
+ callTRPCQuery(deps.apiUrl, "daemonPairing.checkRevocationStatus", {
2792
+ daemonPublicKey: deps.daemonPublicKey,
2793
+ controllerPublicKeys: trusted.map((c) => c.publicKeyPem)
2794
+ }),
2555
2795
  deps.pollRequestTimeoutMs ?? POLL_REQUEST_TIMEOUT_MS
2556
2796
  );
2797
+ const parsed = checkRevocationStatusResponseSchema.safeParse(raw);
2798
+ if (!parsed.success) {
2799
+ console.error(
2800
+ "revocation-sync: ignoring malformed checkRevocationStatus response",
2801
+ parsed.error.message
2802
+ );
2803
+ return false;
2804
+ }
2805
+ response = parsed.data;
2557
2806
  } catch {
2558
2807
  return false;
2559
2808
  }
@@ -3317,7 +3566,9 @@ function stderrOf(error2) {
3317
3566
  }
3318
3567
  async function createWorktree(sourceCwd, worktreePath, branch) {
3319
3568
  try {
3320
- await execFile3("git", ["worktree", "add", "-b", branch, worktreePath], { cwd: sourceCwd });
3569
+ await execFile3("git", [...SAFE_GIT_CONFIG, "worktree", "add", "-b", branch, worktreePath], {
3570
+ cwd: sourceCwd
3571
+ });
3321
3572
  } catch (error2) {
3322
3573
  const stderr = stderrOf(error2);
3323
3574
  if (stderr.includes("a branch named")) {
@@ -3331,7 +3582,9 @@ async function createWorktree(sourceCwd, worktreePath, branch) {
3331
3582
  }
3332
3583
  async function removeWorktree(sourceCwd, worktreePath) {
3333
3584
  try {
3334
- await execFile3("git", ["worktree", "remove", worktreePath], { cwd: sourceCwd });
3585
+ await execFile3("git", [...SAFE_GIT_CONFIG, "worktree", "remove", worktreePath], {
3586
+ cwd: sourceCwd
3587
+ });
3335
3588
  } catch (error2) {
3336
3589
  const stderr = stderrOf(error2);
3337
3590
  if (stderr.includes("contains modified or untracked files")) {
@@ -3345,6 +3598,7 @@ async function removeWorktree(sourceCwd, worktreePath) {
3345
3598
  var LIVE_SESSION_REPORTS_FORMAT = "#{session_name} #{@bondsmith-agent-state} #{window_bell_flag}";
3346
3599
  var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
3347
3600
  var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
3601
+ var WORKING_SILENCE_MS = 12e4;
3348
3602
  var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
3349
3603
  function isAgentState(value) {
3350
3604
  return AGENT_STATES.includes(value);
@@ -3366,6 +3620,10 @@ function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
3366
3620
  if (SETTLED_STATES.has(state) && lastActivityAt) {
3367
3621
  if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
3368
3622
  }
3623
+ if (state === "working" && lastActivityAt) {
3624
+ const quietSinceMs = Math.max(Date.parse(lastActivityAt), reportedAtMs);
3625
+ if (nowMs - quietSinceMs > WORKING_SILENCE_MS) return null;
3626
+ }
3369
3627
  return state;
3370
3628
  }
3371
3629
  function resolveSessionAttention(report, lastActivityAt, nowMs) {
@@ -3407,6 +3665,7 @@ async function createTmuxSession(input) {
3407
3665
  await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, input.command], {
3408
3666
  env: filterSpawnEnv(process.env)
3409
3667
  });
3668
+ await runTmux(["set-option", "-t", tmuxSessionName, "status", "off"]);
3410
3669
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-id", input.id]);
3411
3670
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-title", input.title]);
3412
3671
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
@@ -3657,7 +3916,7 @@ function snapshotEquals(a, b) {
3657
3916
  }
3658
3917
 
3659
3918
  // src/ws/server.ts
3660
- function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pendingAuthorizations, presentPendingAuthorization, trustedControllersPath) {
3919
+ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pendingAuthorizations, presentPendingAuthorization, signDaemonProof, trustedControllersPath, unauthenticatedTimeoutMs = UNAUTHENTICATED_CONNECTION_TIMEOUT_MS) {
3661
3920
  const sessionManager = new SessionManager();
3662
3921
  const ptyAttachments = new PtyAttachmentManager();
3663
3922
  const connectionsByController = /* @__PURE__ */ new Map();
@@ -3714,7 +3973,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3714
3973
  function broadcast(message) {
3715
3974
  const payload = JSON.stringify(message);
3716
3975
  for (const client of wss.clients) {
3717
- if (client.readyState === client.OPEN) client.send(payload);
3976
+ if (client.readyState === client.OPEN && connectionAuthBySocket.get(client)?.canReceiveBroadcasts()) {
3977
+ client.send(payload);
3978
+ }
3718
3979
  }
3719
3980
  }
3720
3981
  sessionManager.onSessionUpdated((session) => {
@@ -3754,6 +4015,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3754
4015
  protocolVersion: PROTOCOL_VERSION,
3755
4016
  daemonVersion: DAEMON_VERSION
3756
4017
  });
4018
+ const unauthenticatedTimer = setTimeout(() => {
4019
+ if (!connectionAuth.isAuthenticated()) socket.close();
4020
+ }, unauthenticatedTimeoutMs);
4021
+ unauthenticatedTimer.unref();
3757
4022
  socket.on("message", (raw) => {
3758
4023
  let parsed;
3759
4024
  try {
@@ -3774,7 +4039,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3774
4039
  daemonFingerprint,
3775
4040
  pendingAuthorizations,
3776
4041
  presentPendingAuthorization,
4042
+ signDaemonProof,
3777
4043
  trustedControllersPath
4044
+ }).then(() => {
4045
+ if (connectionAuth.hasExhaustedHandshakeAttempts()) socket.close();
3778
4046
  }).catch((err) => {
3779
4047
  console.error("Unhandled error in handleBrowserMessage", err);
3780
4048
  conn.send({
@@ -3784,6 +4052,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3784
4052
  });
3785
4053
  });
3786
4054
  socket.on("close", () => {
4055
+ clearTimeout(unauthenticatedTimer);
3787
4056
  releaseAllSubscriptions();
3788
4057
  cleanupBySocket.delete(socket);
3789
4058
  connectionAuthBySocket.delete(socket);
@@ -3825,6 +4094,7 @@ async function start() {
3825
4094
  }
3826
4095
  const identity = await loadOrCreateDaemonIdentity();
3827
4096
  const daemonFingerprint = await formatDaemonFingerprint(identity.publicKeyPem);
4097
+ const signDaemonProof = createDaemonProofSigner(identity.privateKeyPem);
3828
4098
  const pendingAuthorizations = new PendingAuthorizationStore();
3829
4099
  const presentPendingAuthorization = presentPendingAuthorizationOnTerminal(pendingAuthorizations);
3830
4100
  await ensurePaired({
@@ -3854,11 +4124,13 @@ async function start() {
3854
4124
  config,
3855
4125
  daemonFingerprint,
3856
4126
  pendingAuthorizations,
3857
- presentPendingAuthorization
4127
+ presentPendingAuthorization,
4128
+ signDaemonProof
3858
4129
  );
3859
4130
  await sessionManager.initialize();
3860
4131
  const relayClient = config.relayEnabled ? startRelayClient({
3861
4132
  relayUrl: config.relayUrl,
4133
+ expectedRelayAudience: config.expectedRelayAudience,
3862
4134
  daemonFingerprint,
3863
4135
  daemonPublicKeyPem: identity.publicKeyPem,
3864
4136
  daemonPrivateKeyPem: identity.privateKeyPem,
@@ -3870,7 +4142,8 @@ async function start() {
3870
4142
  validateProjectPath,
3871
4143
  hostname: hostname2,
3872
4144
  pendingAuthorizations,
3873
- presentPendingAuthorization
4145
+ presentPendingAuthorization,
4146
+ signDaemonProof
3874
4147
  }) : void 0;
3875
4148
  if (relayClient) {
3876
4149
  sessionManager.onSessionUpdated((session) => {