@bondsmith/bondsmithd 0.1.3 → 0.1.4

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 (2) hide show
  1. package/dist/index.js +443 -138
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
15
15
  var INVALID_MESSAGE_ERROR = "invalid message";
16
16
 
17
17
  // ../../packages/local-agent-protocol/src/protocol-version.ts
18
- var PROTOCOL_VERSION = 1;
18
+ var PROTOCOL_VERSION = 4;
19
19
 
20
20
  // ../../packages/local-agent-protocol/src/project-path.ts
21
21
  var PROJECT_PATH_INVALID_REASONS = [
@@ -57,6 +57,13 @@ var signedConnectionChallengeSchema = z.object({
57
57
 
58
58
  // ../../packages/local-agent-protocol/src/schemas.ts
59
59
  var MAX_INSERT_TEXT_BYTES = 32 * 1024;
60
+ var MAX_INPUT_CHARS = 16 * 1024;
61
+ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
62
+ var MAX_TERMINAL_DIMENSION = 1e3;
63
+ var terminalSizeSchema = z2.object({
64
+ cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
65
+ rows: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION)
66
+ });
60
67
  var localSessionSchema = z2.object({
61
68
  id: z2.string(),
62
69
  title: z2.string(),
@@ -83,6 +90,8 @@ var workspaceFileEntrySchema = z2.object({
83
90
  var workspaceStatusSchema = z2.object({
84
91
  isGitRepo: z2.boolean(),
85
92
  branch: z2.string().nullable(),
93
+ // .catch(undefined): a malformed informational field must never drop the whole status message.
94
+ baseRef: z2.string().nullable().optional().catch(void 0),
86
95
  files: z2.array(workspaceFileEntrySchema)
87
96
  });
88
97
  var workspaceFileDiffSchema = z2.object({
@@ -128,12 +137,15 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
128
137
  // misattributes a daemon error for ANY other in-flight command
129
138
  // (observed: a session.resize racing ahead of its own session.attach)
130
139
  // to the wrong promise — a confirmed bug, not a hypothetical.
131
- requestId: z2.string().min(1).max(200).optional()
140
+ requestId: z2.string().min(1).max(200).optional(),
141
+ // Tolerant like baseRef: an unreadable size must not make the daemon drop the attach itself.
142
+ size: terminalSizeSchema.optional().catch(void 0)
132
143
  }),
144
+ z2.object({ type: z2.literal("session.detach"), sessionId: z2.string() }),
133
145
  z2.object({
134
146
  type: z2.literal("session.input"),
135
147
  sessionId: z2.string(),
136
- data: z2.string()
148
+ data: z2.string().max(MAX_INPUT_CHARS)
137
149
  }),
138
150
  z2.object({
139
151
  type: z2.literal("session.insertText"),
@@ -143,8 +155,7 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
143
155
  z2.object({
144
156
  type: z2.literal("session.resize"),
145
157
  sessionId: z2.string(),
146
- cols: z2.number().int().positive(),
147
- rows: z2.number().int().positive()
158
+ ...terminalSizeSchema.shape
148
159
  }),
149
160
  z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
150
161
  z2.object({
@@ -348,6 +359,7 @@ import { z as z3 } from "zod";
348
359
  var SIGNABLE_ACTIONS = [
349
360
  "session.create",
350
361
  "session.attach",
362
+ "session.detach",
351
363
  "session.input",
352
364
  "session.insertText",
353
365
  "session.resize",
@@ -838,6 +850,8 @@ function expandHome(cwd) {
838
850
  import { execFile as execFileCb } from "child_process";
839
851
  import { promisify } from "util";
840
852
  var execFile = promisify(execFileCb);
853
+ var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
854
+ var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
841
855
  var GitNotARepoError = class extends Error {
842
856
  constructor() {
843
857
  super("not a git repository");
@@ -851,7 +865,7 @@ function isNotARepoError(error2) {
851
865
  }
852
866
  async function runGit(args, cwd) {
853
867
  try {
854
- const { stdout } = await execFile("git", args, { cwd });
868
+ const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
855
869
  return stdout;
856
870
  } catch (error2) {
857
871
  if (isNotARepoError(error2)) throw new GitNotARepoError();
@@ -860,7 +874,7 @@ async function runGit(args, cwd) {
860
874
  }
861
875
  async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
862
876
  try {
863
- const { stdout } = await execFile("git", args, { cwd });
877
+ const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
864
878
  return stdout;
865
879
  } catch (error2) {
866
880
  if (isNotARepoError(error2)) throw new GitNotARepoError();
@@ -872,6 +886,12 @@ async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
872
886
  throw error2;
873
887
  }
874
888
  }
889
+ function runGitDiff(args, cwd) {
890
+ return runGit(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd);
891
+ }
892
+ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
893
+ return runGitAllowingExitCodes(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd, allowedExitCodes);
894
+ }
875
895
 
876
896
  // src/projects/validate-path.ts
877
897
  var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
@@ -883,6 +903,27 @@ function invalidReasonForFsError(error2) {
883
903
  if (PERMISSION_CODES.has(code)) return "permission_denied";
884
904
  return null;
885
905
  }
906
+ function shellQuote(value) {
907
+ return `'${value.replaceAll("'", `'\\''`)}'`;
908
+ }
909
+ function gitFailure(error2, path) {
910
+ const { code, stderr } = error2;
911
+ if (code === "ENOENT") {
912
+ return new Error(
913
+ "git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
914
+ );
915
+ }
916
+ const text = typeof stderr === "string" ? stderr : "";
917
+ if (text.includes("dubious ownership")) {
918
+ return new Error(
919
+ `git doesn't trust this folder because another user owns it. If you trust it, run: git config --global --add safe.directory ${shellQuote(path)}`
920
+ );
921
+ }
922
+ const fatal = text.split("\n").find((line) => line.startsWith("fatal:"));
923
+ return new Error(
924
+ fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
925
+ );
926
+ }
886
927
  async function validateProjectPath(input) {
887
928
  const expanded = expandHome(input.trim());
888
929
  if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
@@ -900,7 +941,7 @@ async function validateProjectPath(input) {
900
941
  if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
901
942
  } catch (error2) {
902
943
  if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
903
- throw error2;
944
+ throw gitFailure(error2, path);
904
945
  }
905
946
  return { valid: true, path };
906
947
  }
@@ -910,7 +951,14 @@ import { sign } from "crypto";
910
951
  import WebSocket from "ws";
911
952
 
912
953
  // src/version.ts
913
- var DAEMON_VERSION = true ? "0.1.3" : "dev";
954
+ var DAEMON_VERSION = true ? "0.1.4" : "dev";
955
+ var VERSION_FLAGS = ["--version", "-v"];
956
+ function versionLine() {
957
+ return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
958
+ }
959
+ function isVersionRequest(argv) {
960
+ return argv.some((arg) => VERSION_FLAGS.includes(arg));
961
+ }
914
962
 
915
963
  // src/sessions/tmux/client.ts
916
964
  import { execFile as execFileCb2 } from "child_process";
@@ -982,9 +1030,18 @@ async function listTmuxSessions(formatString) {
982
1030
  throw error2;
983
1031
  }
984
1032
  }
1033
+ var NO_SERVER_MESSAGE_PATTERNS = [
1034
+ /no server running/i,
1035
+ /no current session/i,
1036
+ /no sessions/i,
1037
+ /failed to connect to server/i,
1038
+ /error connecting to .*\((no such file or directory|connection refused)\)/i
1039
+ ];
985
1040
  function isNoServerError(error2) {
986
- if (typeof error2 !== "object" || error2 === null) return true;
987
- return error2.code !== "ENOENT";
1041
+ if (typeof error2 !== "object" || error2 === null) return false;
1042
+ const { message, stderr } = error2;
1043
+ const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
1044
+ return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
988
1045
  }
989
1046
 
990
1047
  // src/security/verify-command.ts
@@ -1165,6 +1222,76 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
1165
1222
  return { ok: true };
1166
1223
  }
1167
1224
 
1225
+ // src/ws/output-pacer.ts
1226
+ var OUTPUT_HIGH_WATER_BYTES = 1024 * 1024;
1227
+ var OUTPUT_LOW_WATER_BYTES = 256 * 1024;
1228
+ var DRAIN_POLL_MS = 50;
1229
+ var MAX_CAPTURE_FAILURES = 5;
1230
+ var REPAINT_PREFIX = "\x1B[0m\x1B[2J\x1B[H";
1231
+ function createOutputPacer(options) {
1232
+ const highWater = options.highWaterBytes ?? OUTPUT_HIGH_WATER_BYTES;
1233
+ const lowWater = options.lowWaterBytes ?? OUTPUT_LOW_WATER_BYTES;
1234
+ const pollMs = options.pollMs ?? DRAIN_POLL_MS;
1235
+ let behind = false;
1236
+ let disposed = false;
1237
+ let captureFailures = 0;
1238
+ let timer;
1239
+ function scheduleRepaint() {
1240
+ timer = setTimeout(() => void repaintWhenDrained(), pollMs);
1241
+ }
1242
+ async function repaintWhenDrained() {
1243
+ timer = void 0;
1244
+ if (disposed) return;
1245
+ if (options.bufferedBytes() > lowWater) {
1246
+ scheduleRepaint();
1247
+ return;
1248
+ }
1249
+ let snapshot;
1250
+ try {
1251
+ snapshot = await options.snapshot();
1252
+ } catch (error2) {
1253
+ if (disposed) return;
1254
+ captureFailures += 1;
1255
+ if (captureFailures < MAX_CAPTURE_FAILURES) {
1256
+ scheduleRepaint();
1257
+ return;
1258
+ }
1259
+ console.warn("bondsmithd: giving up repainting a terminal after repeated capture failures", error2);
1260
+ behind = false;
1261
+ captureFailures = 0;
1262
+ return;
1263
+ }
1264
+ if (disposed) return;
1265
+ captureFailures = 0;
1266
+ if (options.bufferedBytes() > lowWater) {
1267
+ scheduleRepaint();
1268
+ return;
1269
+ }
1270
+ try {
1271
+ options.send(REPAINT_PREFIX + snapshot);
1272
+ } catch (error2) {
1273
+ console.error("bondsmithd: terminal repaint failed", error2);
1274
+ }
1275
+ behind = false;
1276
+ }
1277
+ return {
1278
+ write(data) {
1279
+ if (disposed || behind) return;
1280
+ if (options.bufferedBytes() > highWater) {
1281
+ behind = true;
1282
+ scheduleRepaint();
1283
+ return;
1284
+ }
1285
+ options.send(data);
1286
+ },
1287
+ dispose() {
1288
+ disposed = true;
1289
+ if (timer !== void 0) clearTimeout(timer);
1290
+ timer = void 0;
1291
+ }
1292
+ };
1293
+ }
1294
+
1168
1295
  // src/ws/router.ts
1169
1296
  var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
1170
1297
  function isSignableAction(type) {
@@ -1191,17 +1318,31 @@ function verifyRejectionMessage(reason) {
1191
1318
  var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
1192
1319
  var BRACKETED_PASTE_START = "\x1B[200~";
1193
1320
  var BRACKETED_PASTE_END = "\x1B[201~";
1194
- function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments) {
1321
+ function releaseSession(conn, ptyAttachments, sessionId) {
1322
+ const listener = conn.getOutputListener(sessionId);
1323
+ if (listener) ptyAttachments.release(sessionId, listener);
1324
+ conn.unregisterOutputListener(sessionId);
1325
+ conn.subscriptions.delete(sessionId);
1326
+ }
1327
+ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments, bufferedBytes) {
1195
1328
  const outputListeners = /* @__PURE__ */ new Map();
1196
1329
  const attachLocks = /* @__PURE__ */ new Map();
1330
+ let closed = false;
1197
1331
  let admissionChain = Promise.resolve();
1198
1332
  const conn = {
1199
1333
  subscriptions: /* @__PURE__ */ new Set(),
1200
1334
  send,
1201
- registerOutputListener: (sessionId, listener) => {
1202
- outputListeners.set(sessionId, listener);
1335
+ bufferedBytes,
1336
+ isClosed: () => closed,
1337
+ registerOutputListener: (sessionId, listener, dispose) => {
1338
+ outputListeners.get(sessionId)?.dispose?.();
1339
+ outputListeners.set(sessionId, { listener, dispose });
1340
+ },
1341
+ getOutputListener: (sessionId) => outputListeners.get(sessionId)?.listener,
1342
+ unregisterOutputListener: (sessionId) => {
1343
+ outputListeners.get(sessionId)?.dispose?.();
1344
+ outputListeners.delete(sessionId);
1203
1345
  },
1204
- getOutputListener: (sessionId) => outputListeners.get(sessionId),
1205
1346
  connectionAuth,
1206
1347
  markControllerId,
1207
1348
  admitInOrder: (fn) => {
@@ -1224,9 +1365,9 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1224
1365
  }
1225
1366
  };
1226
1367
  function releaseAllSubscriptions() {
1227
- for (const sessionId of conn.subscriptions) {
1228
- const listener = outputListeners.get(sessionId);
1229
- if (listener) ptyAttachments.release(sessionId, listener);
1368
+ closed = true;
1369
+ for (const sessionId of [...conn.subscriptions]) {
1370
+ releaseSession(conn, ptyAttachments, sessionId);
1230
1371
  }
1231
1372
  }
1232
1373
  return { conn, releaseAllSubscriptions };
@@ -1336,8 +1477,17 @@ async function dispatch(conn, message, {
1336
1477
  }
1337
1478
  await conn.withAttachLock(message.sessionId, async () => {
1338
1479
  const previousListener = conn.getOutputListener(message.sessionId);
1339
- const onData = (data) => {
1340
- conn.send({ type: "terminal.output", sessionId: message.sessionId, data });
1480
+ const output = createOutputPacer({
1481
+ send: (data) => conn.send({ type: "terminal.output", sessionId: message.sessionId, data }),
1482
+ bufferedBytes: conn.bufferedBytes,
1483
+ snapshot: () => ptyAttachments.paneSnapshot(session.tmuxSessionName)
1484
+ });
1485
+ const onData = output.write;
1486
+ const abandonedBecauseClosed = () => {
1487
+ if (!conn.isClosed()) return false;
1488
+ ptyAttachments.release(message.sessionId, onData);
1489
+ output.dispose();
1490
+ return true;
1341
1491
  };
1342
1492
  if (previousListener && await ptyAttachments.swapListener(
1343
1493
  message.sessionId,
@@ -1345,29 +1495,41 @@ async function dispatch(conn, message, {
1345
1495
  previousListener,
1346
1496
  onData
1347
1497
  )) {
1498
+ if (abandonedBecauseClosed()) return;
1348
1499
  conn.subscriptions.add(message.sessionId);
1349
- conn.registerOutputListener(message.sessionId, onData);
1500
+ conn.registerOutputListener(message.sessionId, onData, output.dispose);
1350
1501
  conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1351
1502
  return;
1352
1503
  }
1353
1504
  if (previousListener) {
1354
1505
  ptyAttachments.release(message.sessionId, previousListener);
1506
+ conn.unregisterOutputListener(message.sessionId);
1355
1507
  }
1356
1508
  const attached = await ptyAttachments.ensureAttached(
1357
1509
  message.sessionId,
1358
1510
  session.tmuxSessionName,
1359
- onData
1511
+ onData,
1512
+ message.size
1360
1513
  );
1361
1514
  if (!attached) {
1515
+ output.dispose();
1362
1516
  conn.send(error("failed to attach to session", requestId));
1363
1517
  return;
1364
1518
  }
1519
+ if (abandonedBecauseClosed()) return;
1365
1520
  conn.subscriptions.add(message.sessionId);
1366
- conn.registerOutputListener(message.sessionId, onData);
1521
+ conn.registerOutputListener(message.sessionId, onData, output.dispose);
1367
1522
  conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1368
1523
  });
1369
1524
  return;
1370
1525
  }
1526
+ case "session.detach": {
1527
+ if (!isValidSessionId(message.sessionId)) return;
1528
+ await conn.withAttachLock(message.sessionId, async () => {
1529
+ releaseSession(conn, ptyAttachments, message.sessionId);
1530
+ });
1531
+ return;
1532
+ }
1371
1533
  case "session.input": {
1372
1534
  if (!conn.subscriptions.has(message.sessionId)) {
1373
1535
  conn.send(error("not attached to session"));
@@ -1771,7 +1933,9 @@ function startRelayClient(deps) {
1771
1933
  },
1772
1934
  () => {
1773
1935
  },
1774
- deps.ptyAttachments
1936
+ deps.ptyAttachments,
1937
+ // One socket carries every browser session, so a backed-up relay link slows all of them together.
1938
+ () => ws.bufferedAmount
1775
1939
  );
1776
1940
  }
1777
1941
  function handleSessionOpened(ws, relaySessionId) {
@@ -1999,12 +2163,27 @@ async function persistToken(tokenPath, token) {
1999
2163
 
2000
2164
  // src/security/pending-authorizations.ts
2001
2165
  import { createHash, randomUUID } from "crypto";
2166
+
2167
+ // src/security/controller-label.ts
2168
+ var MAX_LABEL_CHARACTERS = 60;
2169
+ var MAX_STACKED_MARKS = 3;
2170
+ var FALLBACK_LABEL = "Unnamed browser";
2171
+ var EXCESS_MARKS = new RegExp(`(\\p{M}{${MAX_STACKED_MARKS}})\\p{M}+`, "gu");
2172
+ function sanitizeControllerLabel(raw) {
2173
+ const printable = raw.replace(/\p{C}/gu, " ").replace(EXCESS_MARKS, "$1").replace(/\s+/g, " ").trim();
2174
+ const clipped = Array.from(printable).slice(0, MAX_LABEL_CHARACTERS).join("").trim();
2175
+ return clipped === "" ? FALLBACK_LABEL : clipped;
2176
+ }
2177
+
2178
+ // src/security/pending-authorizations.ts
2002
2179
  function controllerIdFromPublicKeySync(publicKeyPem) {
2003
2180
  return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
2004
2181
  }
2005
2182
  var REQUEST_EXPIRY_MS = 5 * 6e4;
2006
2183
  var MAX_REQUESTS_PER_WINDOW = 5;
2007
2184
  var RATE_LIMIT_WINDOW_MS = 6e4;
2185
+ var MAX_PENDING_REQUESTS = 5;
2186
+ var MAX_NEW_REQUESTS_PER_WINDOW = 10;
2008
2187
  var PendingAuthorizationStore = class {
2009
2188
  // Invariant: at most one PendingEntry per controllerId at any time. A
2010
2189
  // conflicting requestEnrollment call for a controllerId that already has
@@ -2015,6 +2194,7 @@ var PendingAuthorizationStore = class {
2015
2194
  // resolve a newer, unrelated one.
2016
2195
  pending = /* @__PURE__ */ new Map();
2017
2196
  requestTimestamps = /* @__PURE__ */ new Map();
2197
+ allRequestTimestamps = [];
2018
2198
  isInteractive;
2019
2199
  trustedControllersPath;
2020
2200
  constructor(options = {}) {
@@ -2060,7 +2240,7 @@ var PendingAuthorizationStore = class {
2060
2240
  result: existing.resultPromise
2061
2241
  };
2062
2242
  }
2063
- if (this.isRateLimited(controllerId)) {
2243
+ if (this.isRateLimited(controllerId) || this.isOverGlobalLimits()) {
2064
2244
  return { ok: false, reason: "rate_limited" };
2065
2245
  }
2066
2246
  this.recordRequestTimestamp(controllerId);
@@ -2068,7 +2248,7 @@ var PendingAuthorizationStore = class {
2068
2248
  const authorization = {
2069
2249
  controllerId,
2070
2250
  publicKeyPem: params.publicKeyPem,
2071
- label: params.label,
2251
+ label: sanitizeControllerLabel(params.label),
2072
2252
  fingerprint: "",
2073
2253
  requestedAt: now,
2074
2254
  expiresAt: now + REQUEST_EXPIRY_MS,
@@ -2156,19 +2336,33 @@ var PendingAuthorizationStore = class {
2156
2336
  entry.settle("cancelled");
2157
2337
  }
2158
2338
  }
2339
+ /** True while this exact request is still waiting for an answer, so a queued prompt can skip one that expired or was cancelled. */
2340
+ isPending(controllerId, requestToken) {
2341
+ const entry = this.pending.get(controllerId);
2342
+ return entry !== void 0 && !entry.settled && entry.authorization.requestToken === requestToken && entry.authorization.expiresAt > Date.now();
2343
+ }
2159
2344
  isRateLimited(controllerId) {
2160
2345
  const timestamps = this.requestTimestamps.get(controllerId) ?? [];
2161
2346
  const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
2162
2347
  const recent = timestamps.filter((t) => t > windowStart);
2163
2348
  return recent.length >= MAX_REQUESTS_PER_WINDOW;
2164
2349
  }
2165
- recordRequestTimestamp(controllerId) {
2350
+ isOverGlobalLimits() {
2166
2351
  const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
2167
- const timestamps = (this.requestTimestamps.get(controllerId) ?? []).filter(
2168
- (t) => t > windowStart
2169
- );
2170
- timestamps.push(Date.now());
2171
- this.requestTimestamps.set(controllerId, timestamps);
2352
+ const recent = this.allRequestTimestamps.filter((t) => t > windowStart);
2353
+ return recent.length >= MAX_NEW_REQUESTS_PER_WINDOW || this.pending.size >= MAX_PENDING_REQUESTS;
2354
+ }
2355
+ recordRequestTimestamp(controllerId) {
2356
+ const now = Date.now();
2357
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
2358
+ this.allRequestTimestamps = this.allRequestTimestamps.filter((t) => t > windowStart);
2359
+ this.allRequestTimestamps.push(now);
2360
+ for (const [key, timestamps] of this.requestTimestamps) {
2361
+ if (!timestamps.some((t) => t > windowStart)) this.requestTimestamps.delete(key);
2362
+ }
2363
+ const recent = (this.requestTimestamps.get(controllerId) ?? []).filter((t) => t > windowStart);
2364
+ recent.push(now);
2365
+ this.requestTimestamps.set(controllerId, recent);
2172
2366
  }
2173
2367
  };
2174
2368
 
@@ -2280,42 +2474,101 @@ function startRevocationSync(deps) {
2280
2474
  // src/security/terminal-approval.ts
2281
2475
  import { createInterface } from "readline";
2282
2476
  function presentPendingAuthorizationOnTerminal(store) {
2283
- return (pending) => {
2477
+ const waiting = [];
2478
+ let prompting = false;
2479
+ let inputEnded = false;
2480
+ const record = (pending, approved) => {
2481
+ store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
2482
+ console.error("Failed to record controller authorization decision", error2);
2483
+ });
2484
+ };
2485
+ const askNext = () => {
2486
+ if (prompting) return;
2487
+ const pending = waiting.shift();
2488
+ if (!pending) return;
2489
+ if (!store.isPending(pending.controllerId, pending.requestToken)) {
2490
+ askNext();
2491
+ return;
2492
+ }
2493
+ if (inputEnded) {
2494
+ record(pending, false);
2495
+ askNext();
2496
+ return;
2497
+ }
2498
+ prompting = true;
2284
2499
  console.log("");
2285
2500
  console.log(" Bondsmith controller authorization request");
2286
- console.log(` Browser: ${pending.label}`);
2501
+ console.log(` Browser: "${pending.label}"`);
2287
2502
  console.log(` Fingerprint: ${pending.fingerprint}`);
2503
+ if (waiting.length > 0) console.log(` (${waiting.length} more waiting after this one)`);
2288
2504
  console.log("");
2289
2505
  console.log(" Allow this browser to control local sessions? [y/N]");
2290
2506
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2291
- rl.question(" > ", (answer) => {
2507
+ let answered = false;
2508
+ let expired = false;
2509
+ const expiryTimer = setTimeout(
2510
+ () => {
2511
+ expired = true;
2512
+ rl.close();
2513
+ },
2514
+ Math.max(0, pending.expiresAt - Date.now())
2515
+ );
2516
+ const finish = (approved) => {
2517
+ if (answered) return;
2518
+ answered = true;
2519
+ clearTimeout(expiryTimer);
2292
2520
  rl.close();
2293
- const approved = answer.trim().toLowerCase() === "y";
2294
- store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
2295
- console.error("Failed to record controller authorization decision", error2);
2296
- });
2297
- console.log(approved ? " Approved." : " Denied.");
2521
+ record(pending, approved);
2522
+ if (expired) console.log(" Request expired.");
2523
+ else console.log(approved ? " Approved." : " Denied.");
2298
2524
  console.log("");
2525
+ prompting = false;
2526
+ askNext();
2527
+ };
2528
+ rl.question(" > ", (answer) => finish(answer.trim().toLowerCase() === "y"));
2529
+ rl.on("SIGINT", () => finish(false));
2530
+ rl.on("close", () => {
2531
+ if (!answered && !expired) inputEnded = true;
2532
+ finish(false);
2299
2533
  });
2300
2534
  };
2535
+ return (pending) => {
2536
+ waiting.push(pending);
2537
+ askNext();
2538
+ };
2301
2539
  }
2302
2540
 
2303
- // src/sessions/git/diff.ts
2304
- async function getWorkspaceDiff(cwd, path, staged) {
2305
- const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
2306
- const numstat = await runGit(numstatArgs, cwd);
2307
- const binary = /^(-\t-\t)/.test(numstat.trim());
2308
- if (binary) {
2309
- return { path, staged, patch: null, binary: true };
2541
+ // src/sessions/git/base.ts
2542
+ var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2543
+ var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
2544
+ async function tryGit(args, cwd) {
2545
+ try {
2546
+ return (await runGit(args, cwd)).trim() || null;
2547
+ } catch {
2548
+ return null;
2310
2549
  }
2311
- const diffArgs = staged ? ["diff", "--cached", "--", path] : ["diff", "--", path];
2312
- let patch = await runGit(diffArgs, cwd);
2313
- if (patch.trim() === "" && !staged) {
2314
- patch = await runGitAllowingExitCodes(["diff", "--no-index", "--", "/dev/null", path], cwd, [
2315
- 1
2316
- ]).catch(() => "");
2550
+ }
2551
+ async function findDefaultBranchRef(cwd) {
2552
+ const remoteHead = await tryGit(
2553
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
2554
+ cwd
2555
+ );
2556
+ if (remoteHead) return remoteHead;
2557
+ for (const candidate of DEFAULT_BRANCH_CANDIDATES) {
2558
+ if (await tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], cwd)) {
2559
+ return candidate;
2560
+ }
2317
2561
  }
2318
- return { path, staged, patch: patch || null, binary: false };
2562
+ return null;
2563
+ }
2564
+ async function resolveComparisonBase(cwd) {
2565
+ if (!await tryGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)) {
2566
+ return { sha: EMPTY_TREE_SHA, ref: null };
2567
+ }
2568
+ const defaultRef = await findDefaultBranchRef(cwd);
2569
+ if (!defaultRef) return { sha: "HEAD", ref: null };
2570
+ const mergeBase = await tryGit(["merge-base", "HEAD", defaultRef], cwd);
2571
+ return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
2319
2572
  }
2320
2573
 
2321
2574
  // src/sessions/git/status.ts
@@ -2325,7 +2578,11 @@ async function getCurrentBranch(cwd) {
2325
2578
  return out === "" || out === "HEAD" ? null : out;
2326
2579
  } catch (err) {
2327
2580
  if (err instanceof GitNotARepoError) return null;
2328
- throw err;
2581
+ try {
2582
+ return (await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)).trim() || null;
2583
+ } catch {
2584
+ throw err;
2585
+ }
2329
2586
  }
2330
2587
  }
2331
2588
  async function getRepositoryFullName(cwd) {
@@ -2375,88 +2632,73 @@ function parseNumstat(output) {
2375
2632
  }
2376
2633
  return map;
2377
2634
  }
2378
- var STATUS_CODE_MAP = {
2635
+ var STATUS_BY_LETTER = {
2379
2636
  A: "added",
2380
2637
  M: "modified",
2381
2638
  D: "deleted",
2382
2639
  R: "renamed",
2383
2640
  C: "copied",
2641
+ T: "modified",
2642
+ // type change, such as a file becoming a symlink
2384
2643
  U: "modified"
2385
- // unmerged — treated as modified, no conflict-resolution UI in scope
2644
+ // unmerged: no conflict-resolution UI yet, so it reads as modified
2386
2645
  };
2387
- async function getWorkspaceStatus(cwd) {
2388
- const branch = await getCurrentBranch(cwd);
2389
- if (branch === null && !await isGitRepo(cwd)) {
2390
- return { isGitRepo: false, branch: null, files: [] };
2391
- }
2392
- const [porcelain, unstagedRaw, stagedRaw] = await Promise.all([
2393
- runGit(["status", "--porcelain=v1", "-z"], cwd),
2394
- runGit(["diff", "--numstat", "-z"], cwd),
2395
- runGit(["diff", "--cached", "--numstat", "-z"], cwd)
2396
- ]);
2397
- const unstagedNumstat = parseNumstat(unstagedRaw);
2398
- const stagedNumstat = parseNumstat(stagedRaw);
2399
- const files = parsePorcelain(porcelain, unstagedNumstat, stagedNumstat);
2400
- return { isGitRepo: true, branch, files };
2401
- }
2402
- function parsePorcelain(porcelain, unstagedNumstat, stagedNumstat) {
2403
- const entries = [];
2404
- const fields = porcelain.split("\0").filter((f) => f.length > 0);
2646
+ function isStatusLetter(letter) {
2647
+ return letter in STATUS_BY_LETTER;
2648
+ }
2649
+ function parseNameStatus(output) {
2650
+ const fields = output.split("\0").filter((f) => f.length > 0);
2651
+ const changed = [];
2405
2652
  let i = 0;
2406
2653
  while (i < fields.length) {
2407
- const raw = fields[i];
2408
- const x = raw[0] ?? " ";
2409
- const y = raw[1] ?? " ";
2410
- const path = raw.slice(3);
2411
- i++;
2412
- if (x === "R" || x === "C" || y === "R" || y === "C") {
2413
- const previousPath = fields[i];
2414
- i++;
2415
- if (previousPath === void 0) continue;
2416
- pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat);
2417
- continue;
2654
+ const letter = fields[i++][0] ?? "";
2655
+ const status = isStatusLetter(letter) ? STATUS_BY_LETTER[letter] : "modified";
2656
+ if (status === "renamed" || status === "copied") {
2657
+ const previousPath = fields[i++];
2658
+ const path = fields[i++];
2659
+ if (previousPath === void 0 || path === void 0) break;
2660
+ changed.push({ path, previousPath, status });
2661
+ } else {
2662
+ const path = fields[i++];
2663
+ if (path === void 0) break;
2664
+ changed.push({ path, status });
2418
2665
  }
2419
- pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
2420
2666
  }
2421
- return entries;
2667
+ return changed;
2422
2668
  }
2423
- function pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat) {
2424
- if (x === "?" && y === "?") {
2425
- const numstat = unstagedNumstat.get(path);
2426
- entries.push({
2427
- path,
2428
- status: "untracked",
2429
- staged: false,
2430
- additions: numstat?.additions ?? null,
2431
- deletions: numstat?.deletions ?? null,
2432
- binary: numstat?.binary ?? false
2433
- });
2434
- return;
2435
- }
2436
- if (x !== " " && x !== "?") {
2437
- const numstat = stagedNumstat.get(path);
2438
- entries.push({
2439
- path,
2440
- previousPath,
2441
- status: STATUS_CODE_MAP[x] ?? "modified",
2442
- staged: true,
2443
- additions: numstat?.additions ?? null,
2444
- deletions: numstat?.deletions ?? null,
2445
- binary: numstat?.binary ?? false
2446
- });
2669
+ async function getWorkspaceStatus(cwd) {
2670
+ const branch = await getCurrentBranch(cwd);
2671
+ if (branch === null && !await isGitRepo(cwd)) {
2672
+ return { isGitRepo: false, branch: null, files: [] };
2447
2673
  }
2448
- if (y !== " " && y !== "?") {
2449
- const numstat = unstagedNumstat.get(path);
2450
- entries.push({
2451
- path,
2452
- previousPath,
2453
- status: STATUS_CODE_MAP[y] ?? "modified",
2674
+ const base = await resolveComparisonBase(cwd);
2675
+ const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
2676
+ runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
2677
+ runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
2678
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2679
+ ]);
2680
+ const numstat = parseNumstat(numstatRaw);
2681
+ const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
2682
+ const counts = numstat.get(changed.path);
2683
+ return {
2684
+ path: changed.path,
2685
+ previousPath: changed.previousPath,
2686
+ status: changed.status,
2454
2687
  staged: false,
2455
- additions: numstat?.additions ?? null,
2456
- deletions: numstat?.deletions ?? null,
2457
- binary: numstat?.binary ?? false
2458
- });
2459
- }
2688
+ additions: counts?.additions ?? null,
2689
+ deletions: counts?.deletions ?? null,
2690
+ binary: counts?.binary ?? false
2691
+ };
2692
+ });
2693
+ const untracked = untrackedRaw.split("\0").filter((path) => path.length > 0).map((path) => ({
2694
+ path,
2695
+ status: "untracked",
2696
+ staged: false,
2697
+ additions: null,
2698
+ deletions: null,
2699
+ binary: false
2700
+ }));
2701
+ return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
2460
2702
  }
2461
2703
  async function enrichSessionWithGitMetadata(session) {
2462
2704
  const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
@@ -2475,6 +2717,33 @@ async function enrichSessionWithGitMetadata(session) {
2475
2717
  };
2476
2718
  }
2477
2719
 
2720
+ // src/sessions/git/diff.ts
2721
+ async function withRenameSource(cwd, baseSha, path) {
2722
+ const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
2723
+ const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
2724
+ return previousPath === void 0 ? [path] : [previousPath, path];
2725
+ }
2726
+ async function isUntracked(cwd, path) {
2727
+ const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2728
+ return out.trim() !== "";
2729
+ }
2730
+ async function getWorkspaceDiff(cwd, path, staged) {
2731
+ const base = await resolveComparisonBase(cwd);
2732
+ const pathspec = await withRenameSource(cwd, base.sha, path);
2733
+ const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
2734
+ const binary = /^(-\t-\t)/.test(numstat.trim());
2735
+ if (binary) {
2736
+ return { path, staged, patch: null, binary: true };
2737
+ }
2738
+ let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
2739
+ if (patch.trim() === "" && await isUntracked(cwd, path)) {
2740
+ patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2741
+ 1
2742
+ ]).catch(() => "");
2743
+ }
2744
+ return { path, staged, patch: patch || null, binary: false };
2745
+ }
2746
+
2478
2747
  // src/ws/server.ts
2479
2748
  import { hostname } from "os";
2480
2749
  import { WebSocketServer } from "ws";
@@ -2500,23 +2769,40 @@ function isAllowedOrigin(origin, allowlist) {
2500
2769
 
2501
2770
  // src/sessions/pty-attachment.ts
2502
2771
  import * as pty from "node-pty";
2772
+ var DEFAULT_PTY_SIZE = { cols: 80, rows: 24 };
2773
+ var MAX_SNAPSHOT_JSON_BYTES = 128 * 1024;
2774
+ function keepBottomRows(rows) {
2775
+ let budget = MAX_SNAPSHOT_JSON_BYTES;
2776
+ let first = rows.length;
2777
+ while (first > 0) {
2778
+ budget -= JSON.stringify(rows[first - 1]).length;
2779
+ if (budget < 0) break;
2780
+ first -= 1;
2781
+ }
2782
+ return rows.slice(first);
2783
+ }
2784
+ function snapshotToTerminalOutput(snapshot) {
2785
+ const rows = snapshot.replace(/\r?\n$/, "").split(/\r?\n/);
2786
+ return keepBottomRows(rows).join("\r\n");
2787
+ }
2503
2788
  var PtyAttachmentManager = class {
2504
2789
  handles = /* @__PURE__ */ new Map();
2505
2790
  /**
2506
2791
  * Attempts to attach (spawning a pty if one isn't already running for this
2507
2792
  * session). Returns false, without throwing, if the pty could not be
2508
2793
  * spawned — a spawn failure here must never take down the whole daemon
2509
- * process, since every other session's pty would die with it.
2794
+ * process, since every other session's pty would die with it. `size` only
2795
+ * applies to a pty spawned here; an existing one keeps its current size.
2510
2796
  */
2511
- async ensureAttached(sessionId, tmuxSessionName, onData) {
2797
+ async ensureAttached(sessionId, tmuxSessionName, onData, size = DEFAULT_PTY_SIZE) {
2512
2798
  let handle = this.handles.get(sessionId);
2513
2799
  if (!handle) {
2514
2800
  let ptyProcess;
2515
2801
  try {
2516
2802
  ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
2517
2803
  name: "xterm-256color",
2518
- cols: 80,
2519
- rows: 24,
2804
+ cols: size.cols,
2805
+ rows: size.rows,
2520
2806
  cwd: process.env.HOME,
2521
2807
  env: filterSpawnEnv(process.env)
2522
2808
  });
@@ -2528,16 +2814,16 @@ ${new TmuxNotFoundError().message}
2528
2814
  }
2529
2815
  return false;
2530
2816
  }
2531
- handle = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
2532
- this.handles.set(sessionId, handle);
2817
+ const created = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
2818
+ this.handles.set(sessionId, created);
2533
2819
  ptyProcess.onData((data) => {
2534
- for (const listener of handle?.dataListeners ?? []) listener(data);
2820
+ for (const listener of created.dataListeners) listener(data);
2535
2821
  });
2536
2822
  ptyProcess.onExit(() => {
2537
- this.handles.delete(sessionId);
2823
+ if (this.handles.get(sessionId) === created) this.handles.delete(sessionId);
2538
2824
  });
2539
- handle.refCount += 1;
2540
- handle.dataListeners.add(onData);
2825
+ created.refCount += 1;
2826
+ created.dataListeners.add(onData);
2541
2827
  return true;
2542
2828
  }
2543
2829
  await this.sendPaneSnapshot(tmuxSessionName, onData);
@@ -2554,17 +2840,20 @@ ${new TmuxNotFoundError().message}
2554
2840
  handle.dataListeners.add(nextOnData);
2555
2841
  return true;
2556
2842
  }
2843
+ /** The pane's current screen as terminal output; rejects if tmux cannot capture it. */
2844
+ async paneSnapshot(tmuxSessionName) {
2845
+ return snapshotToTerminalOutput(await capturePane(tmuxSessionName));
2846
+ }
2557
2847
  // Best-effort: a capture failure just leaves this listener blank until the next real write.
2558
2848
  async sendPaneSnapshot(tmuxSessionName, onData) {
2559
2849
  try {
2560
- onData(await capturePane(tmuxSessionName));
2850
+ onData(await this.paneSnapshot(tmuxSessionName));
2561
2851
  } catch {
2562
2852
  }
2563
2853
  }
2564
2854
  release(sessionId, onData) {
2565
2855
  const handle = this.handles.get(sessionId);
2566
- if (!handle) return;
2567
- handle.dataListeners.delete(onData);
2856
+ if (!handle?.dataListeners.delete(onData)) return;
2568
2857
  handle.refCount -= 1;
2569
2858
  if (handle.refCount <= 0) {
2570
2859
  handle.process.kill();
@@ -2837,7 +3126,12 @@ var SessionManager = class {
2837
3126
  if (error2 instanceof TmuxNotFoundError) console.error(`
2838
3127
  ${error2.message}
2839
3128
  `);
2840
- if (!(error2 instanceof TmuxNoServerRunningError)) return;
3129
+ if (!(error2 instanceof TmuxNoServerRunningError)) {
3130
+ if (!(error2 instanceof TmuxNotFoundError)) {
3131
+ console.error("tmux list-sessions failed; skipping reconcile", error2);
3132
+ }
3133
+ return;
3134
+ }
2841
3135
  }
2842
3136
  await Promise.all(
2843
3137
  Array.from(this.sessions.values()).map(async (session) => {
@@ -2911,6 +3205,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2911
3205
  const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
2912
3206
  const wss = new WebSocketServer({
2913
3207
  server: httpServer,
3208
+ // ws defaults to 100 MiB per frame, parsed before any signature is checked.
3209
+ maxPayload: MAX_WIRE_MESSAGE_BYTES,
2914
3210
  // Client always sends the dev-tunnel token as the sole entry (see
2915
3211
  // isValidDevTunnelToken); echoing it back is required for a spec-
2916
3212
  // compliant handshake once a Sec-WebSocket-Protocol is offered.
@@ -2934,6 +3230,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2934
3230
  broadcast({ type: "session.updated", session });
2935
3231
  });
2936
3232
  wss.on("connection", (socket, request) => {
3233
+ socket.on("error", (error2) => {
3234
+ console.warn("bondsmithd: browser socket error", error2.message);
3235
+ });
2937
3236
  const rejectionReason = rejectionReasonByRequest.get(request);
2938
3237
  if (rejectionReason) {
2939
3238
  rejectionReasonByRequest.delete(request);
@@ -2948,7 +3247,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2948
3247
  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
2949
3248
  },
2950
3249
  (controllerId) => registerControllerConnection(controllerId, socket),
2951
- ptyAttachments
3250
+ ptyAttachments,
3251
+ () => socket.bufferedAmount
2952
3252
  );
2953
3253
  cleanupBySocket.set(socket, releaseAllSubscriptions);
2954
3254
  connectionAuthBySocket.set(socket, connectionAuth);
@@ -3012,6 +3312,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3012
3312
  }
3013
3313
 
3014
3314
  // src/index.ts
3315
+ if (isVersionRequest(process.argv.slice(2))) {
3316
+ console.log(versionLine());
3317
+ process.exit(0);
3318
+ }
3015
3319
  function loadRepoRootEnv() {
3016
3320
  const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3017
3321
  if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
@@ -3021,6 +3325,7 @@ function loadRepoRootEnv() {
3021
3325
  }
3022
3326
  loadRepoRootEnv();
3023
3327
  async function start() {
3328
+ console.log(versionLine());
3024
3329
  let config = getDaemonConfig();
3025
3330
  if (!config.devTunnelToken) {
3026
3331
  const generatedToken = await loadOrCreateDevTunnelToken();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "Local daemon for Bondsmith agent sessions — runs on the user's own machine, pairs with a Bondsmith account, and exposes tmux-backed terminal sessions to the Bondsmith web app over a signed WebSocket protocol.",
6
6
  "homepage": "https://bondsmith.dev",