@bondsmith/bondsmithd 0.1.2 → 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.
- package/README.md +14 -0
- package/dist/index.js +774 -237
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createServer } from "http";
|
|
5
5
|
import { existsSync } from "fs";
|
|
6
6
|
import { hostname as hostname2 } from "os";
|
|
7
|
-
import { dirname as dirname4, join as
|
|
7
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
|
|
10
10
|
// ../../packages/local-agent-protocol/src/session.ts
|
|
@@ -12,6 +12,19 @@ var SESSION_STATUSES = ["running", "exited"];
|
|
|
12
12
|
|
|
13
13
|
// ../../packages/local-agent-protocol/src/messages.ts
|
|
14
14
|
var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
15
|
+
var INVALID_MESSAGE_ERROR = "invalid message";
|
|
16
|
+
|
|
17
|
+
// ../../packages/local-agent-protocol/src/protocol-version.ts
|
|
18
|
+
var PROTOCOL_VERSION = 4;
|
|
19
|
+
|
|
20
|
+
// ../../packages/local-agent-protocol/src/project-path.ts
|
|
21
|
+
var PROJECT_PATH_INVALID_REASONS = [
|
|
22
|
+
"relative_path",
|
|
23
|
+
"not_found",
|
|
24
|
+
"not_a_directory",
|
|
25
|
+
"not_a_git_repo",
|
|
26
|
+
"permission_denied"
|
|
27
|
+
];
|
|
15
28
|
|
|
16
29
|
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
17
30
|
import { z as z2 } from "zod";
|
|
@@ -44,6 +57,13 @@ var signedConnectionChallengeSchema = z.object({
|
|
|
44
57
|
|
|
45
58
|
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
46
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
|
+
});
|
|
47
67
|
var localSessionSchema = z2.object({
|
|
48
68
|
id: z2.string(),
|
|
49
69
|
title: z2.string(),
|
|
@@ -55,7 +75,8 @@ var localSessionSchema = z2.object({
|
|
|
55
75
|
branch: z2.string().nullable(),
|
|
56
76
|
repositoryFullName: z2.string().nullable(),
|
|
57
77
|
lastActivityAt: z2.string().nullable(),
|
|
58
|
-
changedFileCount: z2.number().int().nonnegative().nullable()
|
|
78
|
+
changedFileCount: z2.number().int().nonnegative().nullable(),
|
|
79
|
+
worktreeOf: z2.string().nullable()
|
|
59
80
|
});
|
|
60
81
|
var workspaceFileEntrySchema = z2.object({
|
|
61
82
|
path: z2.string(),
|
|
@@ -69,6 +90,8 @@ var workspaceFileEntrySchema = z2.object({
|
|
|
69
90
|
var workspaceStatusSchema = z2.object({
|
|
70
91
|
isGitRepo: z2.boolean(),
|
|
71
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),
|
|
72
95
|
files: z2.array(workspaceFileEntrySchema)
|
|
73
96
|
});
|
|
74
97
|
var workspaceFileDiffSchema = z2.object({
|
|
@@ -77,6 +100,10 @@ var workspaceFileDiffSchema = z2.object({
|
|
|
77
100
|
patch: z2.string().nullable(),
|
|
78
101
|
binary: z2.boolean()
|
|
79
102
|
});
|
|
103
|
+
var projectPathValidationSchema = z2.discriminatedUnion("valid", [
|
|
104
|
+
z2.object({ valid: z2.literal(true), path: z2.string() }),
|
|
105
|
+
z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
|
|
106
|
+
]);
|
|
80
107
|
var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
81
108
|
z2.object({ type: z2.literal("session.list") }),
|
|
82
109
|
z2.object({
|
|
@@ -84,13 +111,41 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
84
111
|
title: z2.string().min(1).max(200),
|
|
85
112
|
cwd: z2.string().min(1),
|
|
86
113
|
command: z2.string().min(1).max(2e3),
|
|
114
|
+
// Opt-in worktree creation — see messages.ts's own field comment.
|
|
115
|
+
// Most branch-name legality is left to git itself in createWorktree()
|
|
116
|
+
// (whose named error classes surface a rejection back to the
|
|
117
|
+
// browser), EXCEPT a leading "-": git's own argv parsing for
|
|
118
|
+
// `worktree add -b <branch>` treats a leading "-" as a flag, not a
|
|
119
|
+
// branch name, producing a raw multi-line `git branch` usage dump as
|
|
120
|
+
// the error instead of a clean, classified one (confirmed live
|
|
121
|
+
// during adversarial review) — rejected here instead, before it ever
|
|
122
|
+
// reaches git.
|
|
123
|
+
worktreeBranch: z2.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
|
|
124
|
+
message: "branch name can't start with -"
|
|
125
|
+
}).optional(),
|
|
87
126
|
requestId: z2.string().min(1).max(200).optional()
|
|
88
127
|
}),
|
|
89
|
-
z2.object({
|
|
128
|
+
z2.object({
|
|
129
|
+
type: z2.literal("session.attach"),
|
|
130
|
+
sessionId: z2.string(),
|
|
131
|
+
// Optional (not required) so an older daemon build, or a stale queued
|
|
132
|
+
// envelope signed before this field existed, still parses — see
|
|
133
|
+
// session.attached/error's own requestId fields for why this matters:
|
|
134
|
+
// without a requestId round-tripped on both the ack and the error
|
|
135
|
+
// path, the browser can only guess which pending attachSession() call
|
|
136
|
+
// an incoming error/ack belongs to (oldest-pending heuristic), which
|
|
137
|
+
// misattributes a daemon error for ANY other in-flight command
|
|
138
|
+
// (observed: a session.resize racing ahead of its own session.attach)
|
|
139
|
+
// to the wrong promise — a confirmed bug, not a hypothetical.
|
|
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)
|
|
143
|
+
}),
|
|
144
|
+
z2.object({ type: z2.literal("session.detach"), sessionId: z2.string() }),
|
|
90
145
|
z2.object({
|
|
91
146
|
type: z2.literal("session.input"),
|
|
92
147
|
sessionId: z2.string(),
|
|
93
|
-
data: z2.string()
|
|
148
|
+
data: z2.string().max(MAX_INPUT_CHARS)
|
|
94
149
|
}),
|
|
95
150
|
z2.object({
|
|
96
151
|
type: z2.literal("session.insertText"),
|
|
@@ -100,8 +155,7 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
100
155
|
z2.object({
|
|
101
156
|
type: z2.literal("session.resize"),
|
|
102
157
|
sessionId: z2.string(),
|
|
103
|
-
|
|
104
|
-
rows: z2.number().int().positive()
|
|
158
|
+
...terminalSizeSchema.shape
|
|
105
159
|
}),
|
|
106
160
|
z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
|
|
107
161
|
z2.object({
|
|
@@ -116,6 +170,11 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
116
170
|
staged: z2.boolean(),
|
|
117
171
|
requestId: z2.string().min(1).max(200)
|
|
118
172
|
}),
|
|
173
|
+
z2.object({
|
|
174
|
+
type: z2.literal("project.validatePath"),
|
|
175
|
+
path: z2.string().min(1).max(4096),
|
|
176
|
+
requestId: z2.string().min(1).max(200)
|
|
177
|
+
}),
|
|
119
178
|
z2.object({ type: z2.literal("daemon.getInfo") }),
|
|
120
179
|
// Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
|
|
121
180
|
// unsigned: the requesting browser has no trusted key yet to sign with.
|
|
@@ -166,8 +225,22 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
166
225
|
// session" (observed during a reconnect: attachSession() was
|
|
167
226
|
// fire-and-forget, so terminal.onData could fire before attach
|
|
168
227
|
// completed).
|
|
169
|
-
z2.object({
|
|
170
|
-
|
|
228
|
+
z2.object({
|
|
229
|
+
type: z2.literal("session.attached"),
|
|
230
|
+
sessionId: z2.string(),
|
|
231
|
+
requestId: z2.string().optional()
|
|
232
|
+
}),
|
|
233
|
+
z2.object({
|
|
234
|
+
type: z2.literal("error"),
|
|
235
|
+
message: z2.string(),
|
|
236
|
+
// Echoes the triggering message's requestId when it had one, so the
|
|
237
|
+
// browser can match this error to the exact pending request instead
|
|
238
|
+
// of guessing "oldest pending create, then oldest pending attach" —
|
|
239
|
+
// undefined for error paths with no requestId to echo (most session.*
|
|
240
|
+
// commands still don't carry one; see session.attach above for the
|
|
241
|
+
// one this was added to fix).
|
|
242
|
+
requestId: z2.string().optional()
|
|
243
|
+
}),
|
|
171
244
|
z2.object({
|
|
172
245
|
type: z2.literal("workspace.status"),
|
|
173
246
|
sessionId: z2.string(),
|
|
@@ -180,6 +253,11 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
180
253
|
requestId: z2.string(),
|
|
181
254
|
diff: workspaceFileDiffSchema
|
|
182
255
|
}),
|
|
256
|
+
z2.object({
|
|
257
|
+
type: z2.literal("project.pathValidated"),
|
|
258
|
+
requestId: z2.string(),
|
|
259
|
+
result: projectPathValidationSchema
|
|
260
|
+
}),
|
|
183
261
|
z2.object({
|
|
184
262
|
type: z2.literal("daemon.info"),
|
|
185
263
|
hostname: z2.string(),
|
|
@@ -199,7 +277,10 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
199
277
|
challenge: z2.string().min(1),
|
|
200
278
|
daemonFingerprint: z2.string().min(1),
|
|
201
279
|
hostname: z2.string(),
|
|
202
|
-
expiresAt: z2.number().int().positive()
|
|
280
|
+
expiresAt: z2.number().int().positive(),
|
|
281
|
+
// .catch(undefined): a malformed informational field must never fail the whole handshake message.
|
|
282
|
+
protocolVersion: z2.number().int().positive().optional().catch(void 0),
|
|
283
|
+
daemonVersion: z2.string().optional().catch(void 0)
|
|
203
284
|
}),
|
|
204
285
|
z2.object({
|
|
205
286
|
type: z2.literal("connection.authenticated"),
|
|
@@ -278,13 +359,15 @@ import { z as z3 } from "zod";
|
|
|
278
359
|
var SIGNABLE_ACTIONS = [
|
|
279
360
|
"session.create",
|
|
280
361
|
"session.attach",
|
|
362
|
+
"session.detach",
|
|
281
363
|
"session.input",
|
|
282
364
|
"session.insertText",
|
|
283
365
|
"session.resize",
|
|
284
366
|
"session.stop",
|
|
285
367
|
"session.list",
|
|
286
368
|
"workspace.getStatus",
|
|
287
|
-
"workspace.getDiff"
|
|
369
|
+
"workspace.getDiff",
|
|
370
|
+
"project.validatePath"
|
|
288
371
|
];
|
|
289
372
|
var envelopeSchema = z3.object({
|
|
290
373
|
version: z3.literal(1),
|
|
@@ -613,7 +696,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
|
|
|
613
696
|
// src/pairing/pairing.ts
|
|
614
697
|
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
615
698
|
function sleep(ms) {
|
|
616
|
-
return new Promise((
|
|
699
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
617
700
|
}
|
|
618
701
|
async function ensurePaired(options) {
|
|
619
702
|
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -750,14 +833,137 @@ async function bindFirstController(params, response) {
|
|
|
750
833
|
}
|
|
751
834
|
}
|
|
752
835
|
|
|
836
|
+
// src/projects/validate-path.ts
|
|
837
|
+
import { stat } from "fs/promises";
|
|
838
|
+
import { isAbsolute, resolve } from "path";
|
|
839
|
+
|
|
840
|
+
// src/sessions/expand-home.ts
|
|
841
|
+
import { homedir } from "os";
|
|
842
|
+
import { join } from "path";
|
|
843
|
+
function expandHome(cwd) {
|
|
844
|
+
if (cwd === "~") return homedir();
|
|
845
|
+
if (cwd.startsWith("~/")) return join(homedir(), cwd.slice(2));
|
|
846
|
+
return cwd;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/sessions/git/client.ts
|
|
850
|
+
import { execFile as execFileCb } from "child_process";
|
|
851
|
+
import { promisify } from "util";
|
|
852
|
+
var execFile = promisify(execFileCb);
|
|
853
|
+
var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
854
|
+
var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
|
|
855
|
+
var GitNotARepoError = class extends Error {
|
|
856
|
+
constructor() {
|
|
857
|
+
super("not a git repository");
|
|
858
|
+
this.name = "GitNotARepoError";
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
function isNotARepoError(error2) {
|
|
862
|
+
if (typeof error2 !== "object" || error2 === null) return false;
|
|
863
|
+
const stderr = error2.stderr;
|
|
864
|
+
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
865
|
+
}
|
|
866
|
+
async function runGit(args, cwd) {
|
|
867
|
+
try {
|
|
868
|
+
const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
|
|
869
|
+
return stdout;
|
|
870
|
+
} catch (error2) {
|
|
871
|
+
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
872
|
+
throw error2;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
876
|
+
try {
|
|
877
|
+
const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
|
|
878
|
+
return stdout;
|
|
879
|
+
} catch (error2) {
|
|
880
|
+
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
881
|
+
const code = error2.code;
|
|
882
|
+
const stdout = error2.stdout;
|
|
883
|
+
if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
|
|
884
|
+
return stdout;
|
|
885
|
+
}
|
|
886
|
+
throw error2;
|
|
887
|
+
}
|
|
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
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/projects/validate-path.ts
|
|
897
|
+
var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
|
|
898
|
+
var PERMISSION_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
|
|
899
|
+
function invalidReasonForFsError(error2) {
|
|
900
|
+
const code = error2?.code;
|
|
901
|
+
if (typeof code !== "string") return null;
|
|
902
|
+
if (NOT_FOUND_CODES.has(code)) return "not_found";
|
|
903
|
+
if (PERMISSION_CODES.has(code)) return "permission_denied";
|
|
904
|
+
return null;
|
|
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
|
+
}
|
|
927
|
+
async function validateProjectPath(input) {
|
|
928
|
+
const expanded = expandHome(input.trim());
|
|
929
|
+
if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
|
|
930
|
+
const path = resolve(expanded);
|
|
931
|
+
try {
|
|
932
|
+
const stats = await stat(path);
|
|
933
|
+
if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
|
|
934
|
+
} catch (error2) {
|
|
935
|
+
const reason = invalidReasonForFsError(error2);
|
|
936
|
+
if (reason) return { valid: false, reason };
|
|
937
|
+
throw error2;
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
const insideWorkTree = (await runGit(["rev-parse", "--is-inside-work-tree"], path)).trim();
|
|
941
|
+
if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
|
|
942
|
+
} catch (error2) {
|
|
943
|
+
if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
|
|
944
|
+
throw gitFailure(error2, path);
|
|
945
|
+
}
|
|
946
|
+
return { valid: true, path };
|
|
947
|
+
}
|
|
948
|
+
|
|
753
949
|
// src/relay/relay-client.ts
|
|
754
950
|
import { sign } from "crypto";
|
|
755
951
|
import WebSocket from "ws";
|
|
756
952
|
|
|
953
|
+
// src/version.ts
|
|
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
|
+
}
|
|
962
|
+
|
|
757
963
|
// src/sessions/tmux/client.ts
|
|
758
|
-
import { execFile as
|
|
759
|
-
import { promisify } from "util";
|
|
760
|
-
var
|
|
964
|
+
import { execFile as execFileCb2 } from "child_process";
|
|
965
|
+
import { promisify as promisify2 } from "util";
|
|
966
|
+
var execFile2 = promisify2(execFileCb2);
|
|
761
967
|
var TmuxNoServerRunningError = class extends Error {
|
|
762
968
|
constructor() {
|
|
763
969
|
super("no tmux server running");
|
|
@@ -793,7 +999,7 @@ function filterSpawnEnv(env) {
|
|
|
793
999
|
}
|
|
794
1000
|
async function runTmux(args, options) {
|
|
795
1001
|
try {
|
|
796
|
-
const { stdout } = await
|
|
1002
|
+
const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
|
|
797
1003
|
return stdout;
|
|
798
1004
|
} catch (error2) {
|
|
799
1005
|
if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
|
|
@@ -824,9 +1030,18 @@ async function listTmuxSessions(formatString) {
|
|
|
824
1030
|
throw error2;
|
|
825
1031
|
}
|
|
826
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
|
+
];
|
|
827
1040
|
function isNoServerError(error2) {
|
|
828
|
-
if (typeof error2 !== "object" || error2 === null) return
|
|
829
|
-
|
|
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));
|
|
830
1045
|
}
|
|
831
1046
|
|
|
832
1047
|
// src/security/verify-command.ts
|
|
@@ -834,8 +1049,8 @@ import { verify as verify2 } from "crypto";
|
|
|
834
1049
|
|
|
835
1050
|
// src/security/trusted-controllers.ts
|
|
836
1051
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
837
|
-
import { homedir } from "os";
|
|
838
|
-
import { dirname, join } from "path";
|
|
1052
|
+
import { homedir as homedir2 } from "os";
|
|
1053
|
+
import { dirname, join as join2 } from "path";
|
|
839
1054
|
import { z as z9 } from "zod";
|
|
840
1055
|
var trustedControllerSchema = z9.object({
|
|
841
1056
|
id: z9.string(),
|
|
@@ -858,7 +1073,7 @@ var trustedControllersFileSchema = z9.object({
|
|
|
858
1073
|
controllers: z9.array(trustedControllerSchema)
|
|
859
1074
|
});
|
|
860
1075
|
function defaultTrustedControllersPath() {
|
|
861
|
-
return
|
|
1076
|
+
return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
|
|
862
1077
|
}
|
|
863
1078
|
async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
864
1079
|
let raw;
|
|
@@ -1007,6 +1222,76 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
|
|
|
1007
1222
|
return { ok: true };
|
|
1008
1223
|
}
|
|
1009
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
|
+
|
|
1010
1295
|
// src/ws/router.ts
|
|
1011
1296
|
var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
|
|
1012
1297
|
function isSignableAction(type) {
|
|
@@ -1033,18 +1318,40 @@ function verifyRejectionMessage(reason) {
|
|
|
1033
1318
|
var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
|
|
1034
1319
|
var BRACKETED_PASTE_START = "\x1B[200~";
|
|
1035
1320
|
var BRACKETED_PASTE_END = "\x1B[201~";
|
|
1036
|
-
function
|
|
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) {
|
|
1037
1328
|
const outputListeners = /* @__PURE__ */ new Map();
|
|
1038
1329
|
const attachLocks = /* @__PURE__ */ new Map();
|
|
1330
|
+
let closed = false;
|
|
1331
|
+
let admissionChain = Promise.resolve();
|
|
1039
1332
|
const conn = {
|
|
1040
1333
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
1041
1334
|
send,
|
|
1042
|
-
|
|
1043
|
-
|
|
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);
|
|
1044
1345
|
},
|
|
1045
|
-
getOutputListener: (sessionId) => outputListeners.get(sessionId),
|
|
1046
1346
|
connectionAuth,
|
|
1047
1347
|
markControllerId,
|
|
1348
|
+
admitInOrder: (fn) => {
|
|
1349
|
+
const next = admissionChain.catch(() => {
|
|
1350
|
+
}).then(fn);
|
|
1351
|
+
admissionChain = next.catch(() => {
|
|
1352
|
+
});
|
|
1353
|
+
return next;
|
|
1354
|
+
},
|
|
1048
1355
|
withAttachLock: (sessionId, fn) => {
|
|
1049
1356
|
const previous = attachLocks.get(sessionId) ?? Promise.resolve();
|
|
1050
1357
|
const next = previous.catch(() => {
|
|
@@ -1058,15 +1365,15 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
1058
1365
|
}
|
|
1059
1366
|
};
|
|
1060
1367
|
function releaseAllSubscriptions() {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1368
|
+
closed = true;
|
|
1369
|
+
for (const sessionId of [...conn.subscriptions]) {
|
|
1370
|
+
releaseSession(conn, ptyAttachments, sessionId);
|
|
1064
1371
|
}
|
|
1065
1372
|
}
|
|
1066
1373
|
return { conn, releaseAllSubscriptions };
|
|
1067
1374
|
}
|
|
1068
|
-
function error(message) {
|
|
1069
|
-
return { type: "error", message };
|
|
1375
|
+
function error(message, requestId) {
|
|
1376
|
+
return { type: "error", message, requestId };
|
|
1070
1377
|
}
|
|
1071
1378
|
function errorMessageOf(err, fallback) {
|
|
1072
1379
|
return err instanceof Error ? err.message : fallback;
|
|
@@ -1082,35 +1389,39 @@ async function handleBrowserMessage(conn, raw, deps) {
|
|
|
1082
1389
|
const { envelope } = signedCommand.data;
|
|
1083
1390
|
const innerMessage = browserMessageSchema.safeParse(envelope.payload);
|
|
1084
1391
|
if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
|
|
1085
|
-
conn.send(error(
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
const verdict = await verifyCommand(
|
|
1089
|
-
signedCommand.data,
|
|
1090
|
-
deps.daemonFingerprint,
|
|
1091
|
-
conn.connectionAuth,
|
|
1092
|
-
deps.trustedControllersPath
|
|
1093
|
-
);
|
|
1094
|
-
if (!verdict.ok) {
|
|
1095
|
-
conn.send(error(verifyRejectionMessage(verdict.reason)));
|
|
1096
|
-
return;
|
|
1097
|
-
}
|
|
1098
|
-
if (conn.connectionAuth.isRevoked()) {
|
|
1099
|
-
conn.send(error(REVOKED_REJECTION_MESSAGE));
|
|
1392
|
+
conn.send(error(INVALID_MESSAGE_ERROR));
|
|
1100
1393
|
return;
|
|
1101
1394
|
}
|
|
1395
|
+
const admitted = await conn.admitInOrder(async () => {
|
|
1396
|
+
const verdict = await verifyCommand(
|
|
1397
|
+
signedCommand.data,
|
|
1398
|
+
deps.daemonFingerprint,
|
|
1399
|
+
conn.connectionAuth,
|
|
1400
|
+
deps.trustedControllersPath
|
|
1401
|
+
);
|
|
1402
|
+
if (!verdict.ok) {
|
|
1403
|
+
conn.send(error(verifyRejectionMessage(verdict.reason)));
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
if (conn.connectionAuth.isRevoked()) {
|
|
1407
|
+
conn.send(error(REVOKED_REJECTION_MESSAGE));
|
|
1408
|
+
return false;
|
|
1409
|
+
}
|
|
1410
|
+
return true;
|
|
1411
|
+
});
|
|
1412
|
+
if (!admitted) return;
|
|
1102
1413
|
await dispatch(conn, innerMessage.data, deps);
|
|
1103
1414
|
return;
|
|
1104
1415
|
}
|
|
1105
1416
|
const parsed = browserMessageSchema.safeParse(raw);
|
|
1106
1417
|
if (!parsed.success) {
|
|
1107
|
-
conn.send(error(
|
|
1418
|
+
conn.send(error(INVALID_MESSAGE_ERROR));
|
|
1108
1419
|
return;
|
|
1109
1420
|
}
|
|
1110
1421
|
if (parsed.data.type === "connection.authenticate") {
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
deps.trustedControllersPath
|
|
1422
|
+
const { signed } = parsed.data;
|
|
1423
|
+
const result = await conn.admitInOrder(
|
|
1424
|
+
() => conn.connectionAuth.authenticate(signed, deps.trustedControllersPath)
|
|
1114
1425
|
);
|
|
1115
1426
|
if (!result.ok) {
|
|
1116
1427
|
conn.send({ type: "connection.authenticationFailed", reason: result.reason });
|
|
@@ -1132,6 +1443,7 @@ async function dispatch(conn, message, {
|
|
|
1132
1443
|
broadcast,
|
|
1133
1444
|
getWorkspaceStatus: getWorkspaceStatus2,
|
|
1134
1445
|
getWorkspaceDiff: getWorkspaceDiff2,
|
|
1446
|
+
validateProjectPath: validateProjectPath2,
|
|
1135
1447
|
hostname: hostname3,
|
|
1136
1448
|
daemonFingerprint,
|
|
1137
1449
|
pendingAuthorizations,
|
|
@@ -1143,26 +1455,39 @@ async function dispatch(conn, message, {
|
|
|
1143
1455
|
return;
|
|
1144
1456
|
}
|
|
1145
1457
|
case "session.create": {
|
|
1146
|
-
const { title, cwd, command, requestId } = message;
|
|
1458
|
+
const { title, cwd, command, worktreeBranch, requestId } = message;
|
|
1147
1459
|
try {
|
|
1148
|
-
const session = await sessionManager.create({ title, cwd, command });
|
|
1460
|
+
const session = await sessionManager.create({ title, cwd, command, worktreeBranch });
|
|
1149
1461
|
broadcast({ type: "session.created", session, requestId });
|
|
1150
1462
|
} catch (err) {
|
|
1151
|
-
conn.send(error(errorMessageOf(err, "failed to create session")));
|
|
1463
|
+
conn.send(error(errorMessageOf(err, "failed to create session"), requestId));
|
|
1152
1464
|
}
|
|
1153
1465
|
return;
|
|
1154
1466
|
}
|
|
1155
1467
|
case "session.attach": {
|
|
1468
|
+
const { requestId } = message;
|
|
1156
1469
|
if (!isValidSessionId(message.sessionId)) {
|
|
1157
|
-
conn.send(error("invalid session id"));
|
|
1470
|
+
conn.send(error("invalid session id", requestId));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const session = sessionManager.get(message.sessionId);
|
|
1474
|
+
if (!session) {
|
|
1475
|
+
conn.send(error("unknown session", requestId));
|
|
1158
1476
|
return;
|
|
1159
1477
|
}
|
|
1160
|
-
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1161
|
-
if (!session) return;
|
|
1162
1478
|
await conn.withAttachLock(message.sessionId, async () => {
|
|
1163
1479
|
const previousListener = conn.getOutputListener(message.sessionId);
|
|
1164
|
-
const
|
|
1165
|
-
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;
|
|
1166
1491
|
};
|
|
1167
1492
|
if (previousListener && await ptyAttachments.swapListener(
|
|
1168
1493
|
message.sessionId,
|
|
@@ -1170,26 +1495,38 @@ async function dispatch(conn, message, {
|
|
|
1170
1495
|
previousListener,
|
|
1171
1496
|
onData
|
|
1172
1497
|
)) {
|
|
1498
|
+
if (abandonedBecauseClosed()) return;
|
|
1173
1499
|
conn.subscriptions.add(message.sessionId);
|
|
1174
|
-
conn.registerOutputListener(message.sessionId, onData);
|
|
1175
|
-
conn.send({ type: "session.attached", sessionId: message.sessionId });
|
|
1500
|
+
conn.registerOutputListener(message.sessionId, onData, output.dispose);
|
|
1501
|
+
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1176
1502
|
return;
|
|
1177
1503
|
}
|
|
1178
1504
|
if (previousListener) {
|
|
1179
1505
|
ptyAttachments.release(message.sessionId, previousListener);
|
|
1506
|
+
conn.unregisterOutputListener(message.sessionId);
|
|
1180
1507
|
}
|
|
1181
1508
|
const attached = await ptyAttachments.ensureAttached(
|
|
1182
1509
|
message.sessionId,
|
|
1183
1510
|
session.tmuxSessionName,
|
|
1184
|
-
onData
|
|
1511
|
+
onData,
|
|
1512
|
+
message.size
|
|
1185
1513
|
);
|
|
1186
1514
|
if (!attached) {
|
|
1187
|
-
|
|
1515
|
+
output.dispose();
|
|
1516
|
+
conn.send(error("failed to attach to session", requestId));
|
|
1188
1517
|
return;
|
|
1189
1518
|
}
|
|
1519
|
+
if (abandonedBecauseClosed()) return;
|
|
1190
1520
|
conn.subscriptions.add(message.sessionId);
|
|
1191
|
-
conn.registerOutputListener(message.sessionId, onData);
|
|
1192
|
-
conn.send({ type: "session.attached", sessionId: message.sessionId });
|
|
1521
|
+
conn.registerOutputListener(message.sessionId, onData, output.dispose);
|
|
1522
|
+
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1523
|
+
});
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
case "session.detach": {
|
|
1527
|
+
if (!isValidSessionId(message.sessionId)) return;
|
|
1528
|
+
await conn.withAttachLock(message.sessionId, async () => {
|
|
1529
|
+
releaseSession(conn, ptyAttachments, message.sessionId);
|
|
1193
1530
|
});
|
|
1194
1531
|
return;
|
|
1195
1532
|
}
|
|
@@ -1265,6 +1602,15 @@ async function dispatch(conn, message, {
|
|
|
1265
1602
|
}
|
|
1266
1603
|
return;
|
|
1267
1604
|
}
|
|
1605
|
+
case "project.validatePath": {
|
|
1606
|
+
try {
|
|
1607
|
+
const result = await validateProjectPath2(message.path);
|
|
1608
|
+
conn.send({ type: "project.pathValidated", requestId: message.requestId, result });
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
conn.send(error(errorMessageOf(err, "failed to validate project path"), message.requestId));
|
|
1611
|
+
}
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1268
1614
|
case "daemon.getInfo": {
|
|
1269
1615
|
conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
|
|
1270
1616
|
return;
|
|
@@ -1587,7 +1933,9 @@ function startRelayClient(deps) {
|
|
|
1587
1933
|
},
|
|
1588
1934
|
() => {
|
|
1589
1935
|
},
|
|
1590
|
-
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
|
|
1591
1939
|
);
|
|
1592
1940
|
}
|
|
1593
1941
|
function handleSessionOpened(ws, relaySessionId) {
|
|
@@ -1615,7 +1963,9 @@ function startRelayClient(deps) {
|
|
|
1615
1963
|
challenge: challenge.challenge,
|
|
1616
1964
|
daemonFingerprint: deps.daemonFingerprint,
|
|
1617
1965
|
hostname: deps.hostname(),
|
|
1618
|
-
expiresAt: challenge.expiresAt
|
|
1966
|
+
expiresAt: challenge.expiresAt,
|
|
1967
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1968
|
+
daemonVersion: DAEMON_VERSION
|
|
1619
1969
|
});
|
|
1620
1970
|
}
|
|
1621
1971
|
function handleFrame(frame) {
|
|
@@ -1713,10 +2063,10 @@ function startRelayClient(deps) {
|
|
|
1713
2063
|
// src/security/daemon-identity.ts
|
|
1714
2064
|
import { generateKeyPairSync, sign as sign2, verify as verify4 } from "crypto";
|
|
1715
2065
|
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
1716
|
-
import { homedir as
|
|
1717
|
-
import { dirname as dirname2, join as
|
|
2066
|
+
import { homedir as homedir3 } from "os";
|
|
2067
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
1718
2068
|
function defaultCredentialsPath() {
|
|
1719
|
-
return
|
|
2069
|
+
return join3(homedir3(), ".bondsmith", "credentials");
|
|
1720
2070
|
}
|
|
1721
2071
|
async function loadOrCreateDaemonIdentity(credentialsPath = defaultCredentialsPath()) {
|
|
1722
2072
|
const existing = await tryReadIdentity(credentialsPath);
|
|
@@ -1778,10 +2128,10 @@ function extractPemBlock(text, label) {
|
|
|
1778
2128
|
// src/security/dev-tunnel-token-file.ts
|
|
1779
2129
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
1780
2130
|
import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
1781
|
-
import { homedir as
|
|
1782
|
-
import { dirname as dirname3, join as
|
|
2131
|
+
import { homedir as homedir4 } from "os";
|
|
2132
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
1783
2133
|
function defaultDevTunnelTokenPath() {
|
|
1784
|
-
return
|
|
2134
|
+
return join4(homedir4(), ".bondsmith", "dev-tunnel-token");
|
|
1785
2135
|
}
|
|
1786
2136
|
async function loadOrCreateDevTunnelToken(tokenPath = defaultDevTunnelTokenPath()) {
|
|
1787
2137
|
const existing = await tryReadToken(tokenPath);
|
|
@@ -1813,12 +2163,27 @@ async function persistToken(tokenPath, token) {
|
|
|
1813
2163
|
|
|
1814
2164
|
// src/security/pending-authorizations.ts
|
|
1815
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
|
|
1816
2179
|
function controllerIdFromPublicKeySync(publicKeyPem) {
|
|
1817
2180
|
return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
|
|
1818
2181
|
}
|
|
1819
2182
|
var REQUEST_EXPIRY_MS = 5 * 6e4;
|
|
1820
2183
|
var MAX_REQUESTS_PER_WINDOW = 5;
|
|
1821
2184
|
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
2185
|
+
var MAX_PENDING_REQUESTS = 5;
|
|
2186
|
+
var MAX_NEW_REQUESTS_PER_WINDOW = 10;
|
|
1822
2187
|
var PendingAuthorizationStore = class {
|
|
1823
2188
|
// Invariant: at most one PendingEntry per controllerId at any time. A
|
|
1824
2189
|
// conflicting requestEnrollment call for a controllerId that already has
|
|
@@ -1829,6 +2194,7 @@ var PendingAuthorizationStore = class {
|
|
|
1829
2194
|
// resolve a newer, unrelated one.
|
|
1830
2195
|
pending = /* @__PURE__ */ new Map();
|
|
1831
2196
|
requestTimestamps = /* @__PURE__ */ new Map();
|
|
2197
|
+
allRequestTimestamps = [];
|
|
1832
2198
|
isInteractive;
|
|
1833
2199
|
trustedControllersPath;
|
|
1834
2200
|
constructor(options = {}) {
|
|
@@ -1874,7 +2240,7 @@ var PendingAuthorizationStore = class {
|
|
|
1874
2240
|
result: existing.resultPromise
|
|
1875
2241
|
};
|
|
1876
2242
|
}
|
|
1877
|
-
if (this.isRateLimited(controllerId)) {
|
|
2243
|
+
if (this.isRateLimited(controllerId) || this.isOverGlobalLimits()) {
|
|
1878
2244
|
return { ok: false, reason: "rate_limited" };
|
|
1879
2245
|
}
|
|
1880
2246
|
this.recordRequestTimestamp(controllerId);
|
|
@@ -1882,15 +2248,15 @@ var PendingAuthorizationStore = class {
|
|
|
1882
2248
|
const authorization = {
|
|
1883
2249
|
controllerId,
|
|
1884
2250
|
publicKeyPem: params.publicKeyPem,
|
|
1885
|
-
label: params.label,
|
|
2251
|
+
label: sanitizeControllerLabel(params.label),
|
|
1886
2252
|
fingerprint: "",
|
|
1887
2253
|
requestedAt: now,
|
|
1888
2254
|
expiresAt: now + REQUEST_EXPIRY_MS,
|
|
1889
2255
|
requestToken: randomUUID()
|
|
1890
2256
|
};
|
|
1891
2257
|
let settleFn;
|
|
1892
|
-
const resultPromise = new Promise((
|
|
1893
|
-
settleFn =
|
|
2258
|
+
const resultPromise = new Promise((resolve2) => {
|
|
2259
|
+
settleFn = resolve2;
|
|
1894
2260
|
});
|
|
1895
2261
|
const entry = {
|
|
1896
2262
|
authorization,
|
|
@@ -1970,19 +2336,33 @@ var PendingAuthorizationStore = class {
|
|
|
1970
2336
|
entry.settle("cancelled");
|
|
1971
2337
|
}
|
|
1972
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
|
+
}
|
|
1973
2344
|
isRateLimited(controllerId) {
|
|
1974
2345
|
const timestamps = this.requestTimestamps.get(controllerId) ?? [];
|
|
1975
2346
|
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
1976
2347
|
const recent = timestamps.filter((t) => t > windowStart);
|
|
1977
2348
|
return recent.length >= MAX_REQUESTS_PER_WINDOW;
|
|
1978
2349
|
}
|
|
1979
|
-
|
|
2350
|
+
isOverGlobalLimits() {
|
|
1980
2351
|
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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);
|
|
1986
2366
|
}
|
|
1987
2367
|
};
|
|
1988
2368
|
|
|
@@ -2094,81 +2474,101 @@ function startRevocationSync(deps) {
|
|
|
2094
2474
|
// src/security/terminal-approval.ts
|
|
2095
2475
|
import { createInterface } from "readline";
|
|
2096
2476
|
function presentPendingAuthorizationOnTerminal(store) {
|
|
2097
|
-
|
|
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;
|
|
2098
2499
|
console.log("");
|
|
2099
2500
|
console.log(" Bondsmith controller authorization request");
|
|
2100
|
-
console.log(` Browser: ${pending.label}`);
|
|
2501
|
+
console.log(` Browser: "${pending.label}"`);
|
|
2101
2502
|
console.log(` Fingerprint: ${pending.fingerprint}`);
|
|
2503
|
+
if (waiting.length > 0) console.log(` (${waiting.length} more waiting after this one)`);
|
|
2102
2504
|
console.log("");
|
|
2103
2505
|
console.log(" Allow this browser to control local sessions? [y/N]");
|
|
2104
2506
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2105
|
-
|
|
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);
|
|
2106
2520
|
rl.close();
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
});
|
|
2111
|
-
console.log(approved ? " Approved." : " Denied.");
|
|
2521
|
+
record(pending, approved);
|
|
2522
|
+
if (expired) console.log(" Request expired.");
|
|
2523
|
+
else console.log(approved ? " Approved." : " Denied.");
|
|
2112
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);
|
|
2113
2533
|
});
|
|
2114
2534
|
};
|
|
2535
|
+
return (pending) => {
|
|
2536
|
+
waiting.push(pending);
|
|
2537
|
+
askNext();
|
|
2538
|
+
};
|
|
2115
2539
|
}
|
|
2116
2540
|
|
|
2117
|
-
// src/sessions/git/
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
var GitNotARepoError = class extends Error {
|
|
2122
|
-
constructor() {
|
|
2123
|
-
super("not a git repository");
|
|
2124
|
-
this.name = "GitNotARepoError";
|
|
2125
|
-
}
|
|
2126
|
-
};
|
|
2127
|
-
function isNotARepoError(error2) {
|
|
2128
|
-
if (typeof error2 !== "object" || error2 === null) return false;
|
|
2129
|
-
const stderr = error2.stderr;
|
|
2130
|
-
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
2131
|
-
}
|
|
2132
|
-
async function runGit(args, cwd) {
|
|
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) {
|
|
2133
2545
|
try {
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
2138
|
-
throw error2;
|
|
2546
|
+
return (await runGit(args, cwd)).trim() || null;
|
|
2547
|
+
} catch {
|
|
2548
|
+
return null;
|
|
2139
2549
|
}
|
|
2140
2550
|
}
|
|
2141
|
-
async function
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
return stdout;
|
|
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;
|
|
2151
2560
|
}
|
|
2152
|
-
throw error2;
|
|
2153
2561
|
}
|
|
2562
|
+
return null;
|
|
2154
2563
|
}
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
|
|
2159
|
-
const numstat = await runGit(numstatArgs, cwd);
|
|
2160
|
-
const binary = /^(-\t-\t)/.test(numstat.trim());
|
|
2161
|
-
if (binary) {
|
|
2162
|
-
return { path, staged, patch: null, binary: true };
|
|
2564
|
+
async function resolveComparisonBase(cwd) {
|
|
2565
|
+
if (!await tryGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)) {
|
|
2566
|
+
return { sha: EMPTY_TREE_SHA, ref: null };
|
|
2163
2567
|
}
|
|
2164
|
-
const
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
1
|
|
2169
|
-
]).catch(() => "");
|
|
2170
|
-
}
|
|
2171
|
-
return { path, staged, patch: patch || null, binary: false };
|
|
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 };
|
|
2172
2572
|
}
|
|
2173
2573
|
|
|
2174
2574
|
// src/sessions/git/status.ts
|
|
@@ -2178,7 +2578,11 @@ async function getCurrentBranch(cwd) {
|
|
|
2178
2578
|
return out === "" || out === "HEAD" ? null : out;
|
|
2179
2579
|
} catch (err) {
|
|
2180
2580
|
if (err instanceof GitNotARepoError) return null;
|
|
2181
|
-
|
|
2581
|
+
try {
|
|
2582
|
+
return (await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)).trim() || null;
|
|
2583
|
+
} catch {
|
|
2584
|
+
throw err;
|
|
2585
|
+
}
|
|
2182
2586
|
}
|
|
2183
2587
|
}
|
|
2184
2588
|
async function getRepositoryFullName(cwd) {
|
|
@@ -2228,88 +2632,73 @@ function parseNumstat(output) {
|
|
|
2228
2632
|
}
|
|
2229
2633
|
return map;
|
|
2230
2634
|
}
|
|
2231
|
-
var
|
|
2635
|
+
var STATUS_BY_LETTER = {
|
|
2232
2636
|
A: "added",
|
|
2233
2637
|
M: "modified",
|
|
2234
2638
|
D: "deleted",
|
|
2235
2639
|
R: "renamed",
|
|
2236
2640
|
C: "copied",
|
|
2641
|
+
T: "modified",
|
|
2642
|
+
// type change, such as a file becoming a symlink
|
|
2237
2643
|
U: "modified"
|
|
2238
|
-
// unmerged
|
|
2644
|
+
// unmerged: no conflict-resolution UI yet, so it reads as modified
|
|
2239
2645
|
};
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
const
|
|
2246
|
-
runGit(["status", "--porcelain=v1", "-z"], cwd),
|
|
2247
|
-
runGit(["diff", "--numstat", "-z"], cwd),
|
|
2248
|
-
runGit(["diff", "--cached", "--numstat", "-z"], cwd)
|
|
2249
|
-
]);
|
|
2250
|
-
const unstagedNumstat = parseNumstat(unstagedRaw);
|
|
2251
|
-
const stagedNumstat = parseNumstat(stagedRaw);
|
|
2252
|
-
const files = parsePorcelain(porcelain, unstagedNumstat, stagedNumstat);
|
|
2253
|
-
return { isGitRepo: true, branch, files };
|
|
2254
|
-
}
|
|
2255
|
-
function parsePorcelain(porcelain, unstagedNumstat, stagedNumstat) {
|
|
2256
|
-
const entries = [];
|
|
2257
|
-
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 = [];
|
|
2258
2652
|
let i = 0;
|
|
2259
2653
|
while (i < fields.length) {
|
|
2260
|
-
const
|
|
2261
|
-
const
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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 });
|
|
2271
2665
|
}
|
|
2272
|
-
pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
|
|
2273
2666
|
}
|
|
2274
|
-
return
|
|
2667
|
+
return changed;
|
|
2275
2668
|
}
|
|
2276
|
-
function
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
path,
|
|
2281
|
-
status: "untracked",
|
|
2282
|
-
staged: false,
|
|
2283
|
-
additions: numstat?.additions ?? null,
|
|
2284
|
-
deletions: numstat?.deletions ?? null,
|
|
2285
|
-
binary: numstat?.binary ?? false
|
|
2286
|
-
});
|
|
2287
|
-
return;
|
|
2288
|
-
}
|
|
2289
|
-
if (x !== " " && x !== "?") {
|
|
2290
|
-
const numstat = stagedNumstat.get(path);
|
|
2291
|
-
entries.push({
|
|
2292
|
-
path,
|
|
2293
|
-
previousPath,
|
|
2294
|
-
status: STATUS_CODE_MAP[x] ?? "modified",
|
|
2295
|
-
staged: true,
|
|
2296
|
-
additions: numstat?.additions ?? null,
|
|
2297
|
-
deletions: numstat?.deletions ?? null,
|
|
2298
|
-
binary: numstat?.binary ?? false
|
|
2299
|
-
});
|
|
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: [] };
|
|
2300
2673
|
}
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
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,
|
|
2307
2687
|
staged: false,
|
|
2308
|
-
additions:
|
|
2309
|
-
deletions:
|
|
2310
|
-
binary:
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
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] };
|
|
2313
2702
|
}
|
|
2314
2703
|
async function enrichSessionWithGitMetadata(session) {
|
|
2315
2704
|
const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
|
|
@@ -2328,6 +2717,33 @@ async function enrichSessionWithGitMetadata(session) {
|
|
|
2328
2717
|
};
|
|
2329
2718
|
}
|
|
2330
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
|
+
|
|
2331
2747
|
// src/ws/server.ts
|
|
2332
2748
|
import { hostname } from "os";
|
|
2333
2749
|
import { WebSocketServer } from "ws";
|
|
@@ -2353,23 +2769,40 @@ function isAllowedOrigin(origin, allowlist) {
|
|
|
2353
2769
|
|
|
2354
2770
|
// src/sessions/pty-attachment.ts
|
|
2355
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
|
+
}
|
|
2356
2788
|
var PtyAttachmentManager = class {
|
|
2357
2789
|
handles = /* @__PURE__ */ new Map();
|
|
2358
2790
|
/**
|
|
2359
2791
|
* Attempts to attach (spawning a pty if one isn't already running for this
|
|
2360
2792
|
* session). Returns false, without throwing, if the pty could not be
|
|
2361
2793
|
* spawned — a spawn failure here must never take down the whole daemon
|
|
2362
|
-
* 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.
|
|
2363
2796
|
*/
|
|
2364
|
-
async ensureAttached(sessionId, tmuxSessionName, onData) {
|
|
2797
|
+
async ensureAttached(sessionId, tmuxSessionName, onData, size = DEFAULT_PTY_SIZE) {
|
|
2365
2798
|
let handle = this.handles.get(sessionId);
|
|
2366
2799
|
if (!handle) {
|
|
2367
2800
|
let ptyProcess;
|
|
2368
2801
|
try {
|
|
2369
2802
|
ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
|
|
2370
2803
|
name: "xterm-256color",
|
|
2371
|
-
cols:
|
|
2372
|
-
rows:
|
|
2804
|
+
cols: size.cols,
|
|
2805
|
+
rows: size.rows,
|
|
2373
2806
|
cwd: process.env.HOME,
|
|
2374
2807
|
env: filterSpawnEnv(process.env)
|
|
2375
2808
|
});
|
|
@@ -2381,16 +2814,16 @@ ${new TmuxNotFoundError().message}
|
|
|
2381
2814
|
}
|
|
2382
2815
|
return false;
|
|
2383
2816
|
}
|
|
2384
|
-
|
|
2385
|
-
this.handles.set(sessionId,
|
|
2817
|
+
const created = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
|
|
2818
|
+
this.handles.set(sessionId, created);
|
|
2386
2819
|
ptyProcess.onData((data) => {
|
|
2387
|
-
for (const listener of
|
|
2820
|
+
for (const listener of created.dataListeners) listener(data);
|
|
2388
2821
|
});
|
|
2389
2822
|
ptyProcess.onExit(() => {
|
|
2390
|
-
this.handles.delete(sessionId);
|
|
2823
|
+
if (this.handles.get(sessionId) === created) this.handles.delete(sessionId);
|
|
2391
2824
|
});
|
|
2392
|
-
|
|
2393
|
-
|
|
2825
|
+
created.refCount += 1;
|
|
2826
|
+
created.dataListeners.add(onData);
|
|
2394
2827
|
return true;
|
|
2395
2828
|
}
|
|
2396
2829
|
await this.sendPaneSnapshot(tmuxSessionName, onData);
|
|
@@ -2407,17 +2840,20 @@ ${new TmuxNotFoundError().message}
|
|
|
2407
2840
|
handle.dataListeners.add(nextOnData);
|
|
2408
2841
|
return true;
|
|
2409
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
|
+
}
|
|
2410
2847
|
// Best-effort: a capture failure just leaves this listener blank until the next real write.
|
|
2411
2848
|
async sendPaneSnapshot(tmuxSessionName, onData) {
|
|
2412
2849
|
try {
|
|
2413
|
-
onData(await
|
|
2850
|
+
onData(await this.paneSnapshot(tmuxSessionName));
|
|
2414
2851
|
} catch {
|
|
2415
2852
|
}
|
|
2416
2853
|
}
|
|
2417
2854
|
release(sessionId, onData) {
|
|
2418
2855
|
const handle = this.handles.get(sessionId);
|
|
2419
|
-
if (!handle) return;
|
|
2420
|
-
handle.dataListeners.delete(onData);
|
|
2856
|
+
if (!handle?.dataListeners.delete(onData)) return;
|
|
2421
2857
|
handle.refCount -= 1;
|
|
2422
2858
|
if (handle.refCount <= 0) {
|
|
2423
2859
|
handle.process.kill();
|
|
@@ -2442,23 +2878,84 @@ ${new TmuxNotFoundError().message}
|
|
|
2442
2878
|
// src/sessions/session-manager.ts
|
|
2443
2879
|
import { v4 as uuidv4 } from "uuid";
|
|
2444
2880
|
|
|
2881
|
+
// src/sessions/git/worktree.ts
|
|
2882
|
+
import { execFile as execFileCb3 } from "child_process";
|
|
2883
|
+
import { promisify as promisify3 } from "util";
|
|
2884
|
+
var execFile3 = promisify3(execFileCb3);
|
|
2885
|
+
var WorktreeBranchNameTakenError = class extends Error {
|
|
2886
|
+
constructor(branch) {
|
|
2887
|
+
super(`a branch named "${branch}" already exists`);
|
|
2888
|
+
this.name = "WorktreeBranchNameTakenError";
|
|
2889
|
+
}
|
|
2890
|
+
};
|
|
2891
|
+
var WorktreePathExistsError = class extends Error {
|
|
2892
|
+
constructor(path) {
|
|
2893
|
+
super(`worktree path already exists: ${path}`);
|
|
2894
|
+
this.name = "WorktreePathExistsError";
|
|
2895
|
+
}
|
|
2896
|
+
};
|
|
2897
|
+
var WorktreeHasUncommittedChangesError = class extends Error {
|
|
2898
|
+
constructor(path) {
|
|
2899
|
+
super(
|
|
2900
|
+
`worktree at ${path} has uncommitted or untracked changes \u2014 not removing it. Commit or discard the changes, then remove it yourself (git worktree remove ${path}).`
|
|
2901
|
+
);
|
|
2902
|
+
this.name = "WorktreeHasUncommittedChangesError";
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
function stderrOf(error2) {
|
|
2906
|
+
if (typeof error2 !== "object" || error2 === null) return "";
|
|
2907
|
+
const stderr = error2.stderr;
|
|
2908
|
+
return typeof stderr === "string" ? stderr : "";
|
|
2909
|
+
}
|
|
2910
|
+
async function createWorktree(sourceCwd, worktreePath, branch) {
|
|
2911
|
+
try {
|
|
2912
|
+
await execFile3("git", ["worktree", "add", "-b", branch, worktreePath], { cwd: sourceCwd });
|
|
2913
|
+
} catch (error2) {
|
|
2914
|
+
const stderr = stderrOf(error2);
|
|
2915
|
+
if (stderr.includes("a branch named")) {
|
|
2916
|
+
throw new WorktreeBranchNameTakenError(branch);
|
|
2917
|
+
}
|
|
2918
|
+
if (stderr.includes("already exists")) {
|
|
2919
|
+
throw new WorktreePathExistsError(worktreePath);
|
|
2920
|
+
}
|
|
2921
|
+
throw error2;
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
async function removeWorktree(sourceCwd, worktreePath) {
|
|
2925
|
+
try {
|
|
2926
|
+
await execFile3("git", ["worktree", "remove", worktreePath], { cwd: sourceCwd });
|
|
2927
|
+
} catch (error2) {
|
|
2928
|
+
const stderr = stderrOf(error2);
|
|
2929
|
+
if (stderr.includes("contains modified or untracked files")) {
|
|
2930
|
+
throw new WorktreeHasUncommittedChangesError(worktreePath);
|
|
2931
|
+
}
|
|
2932
|
+
throw error2;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2445
2936
|
// src/sessions/tmux/create.ts
|
|
2446
2937
|
import { access } from "fs/promises";
|
|
2447
|
-
import { homedir as
|
|
2448
|
-
import { join as
|
|
2938
|
+
import { homedir as homedir5 } from "os";
|
|
2939
|
+
import { join as join5 } from "path";
|
|
2449
2940
|
var InvalidCwdError = class extends Error {
|
|
2450
2941
|
constructor(cwd) {
|
|
2451
2942
|
super(`working directory does not exist: ${cwd}`);
|
|
2452
2943
|
this.name = "InvalidCwdError";
|
|
2453
2944
|
}
|
|
2454
2945
|
};
|
|
2455
|
-
function
|
|
2456
|
-
|
|
2457
|
-
if (cwd.startsWith("~/")) return join4(homedir4(), cwd.slice(2));
|
|
2458
|
-
return cwd;
|
|
2946
|
+
function worktreePathFor(sessionId) {
|
|
2947
|
+
return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
|
|
2459
2948
|
}
|
|
2460
2949
|
async function createTmuxSession(input) {
|
|
2461
|
-
const
|
|
2950
|
+
const sourceCwd = expandHome(input.cwd);
|
|
2951
|
+
let cwd = sourceCwd;
|
|
2952
|
+
let worktreeOf = null;
|
|
2953
|
+
if (input.worktreeBranch) {
|
|
2954
|
+
const worktreePath = worktreePathFor(input.id);
|
|
2955
|
+
await createWorktree(sourceCwd, worktreePath, input.worktreeBranch);
|
|
2956
|
+
cwd = worktreePath;
|
|
2957
|
+
worktreeOf = sourceCwd;
|
|
2958
|
+
}
|
|
2462
2959
|
try {
|
|
2463
2960
|
await access(cwd);
|
|
2464
2961
|
} catch {
|
|
@@ -2474,6 +2971,9 @@ async function createTmuxSession(input) {
|
|
|
2474
2971
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
|
|
2475
2972
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-cwd", cwd]);
|
|
2476
2973
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-started-at", startedAt]);
|
|
2974
|
+
if (worktreeOf) {
|
|
2975
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
|
|
2976
|
+
}
|
|
2477
2977
|
const session = {
|
|
2478
2978
|
id: input.id,
|
|
2479
2979
|
title: input.title,
|
|
@@ -2485,7 +2985,8 @@ async function createTmuxSession(input) {
|
|
|
2485
2985
|
branch: null,
|
|
2486
2986
|
repositoryFullName: null,
|
|
2487
2987
|
lastActivityAt: null,
|
|
2488
|
-
changedFileCount: null
|
|
2988
|
+
changedFileCount: null,
|
|
2989
|
+
worktreeOf
|
|
2489
2990
|
};
|
|
2490
2991
|
return enrichSessionWithGitMetadata(session);
|
|
2491
2992
|
}
|
|
@@ -2494,14 +2995,14 @@ async function stopTmuxSession(tmuxSessionName) {
|
|
|
2494
2995
|
}
|
|
2495
2996
|
|
|
2496
2997
|
// src/sessions/tmux/parse.ts
|
|
2497
|
-
var FIELD_COUNT =
|
|
2998
|
+
var FIELD_COUNT = 7;
|
|
2498
2999
|
function parseTmuxSessions(output) {
|
|
2499
3000
|
const sessions = [];
|
|
2500
3001
|
for (const line of output.split("\n")) {
|
|
2501
3002
|
if (!line.trim()) continue;
|
|
2502
3003
|
const fields = line.split(" ");
|
|
2503
3004
|
if (fields.length < FIELD_COUNT) continue;
|
|
2504
|
-
const [sessionName, id, title, command, cwd, startedAt] = fields;
|
|
3005
|
+
const [sessionName, id, title, command, cwd, startedAt, worktreeOf] = fields;
|
|
2505
3006
|
if (!isBondsmithTmuxSessionName(sessionName)) continue;
|
|
2506
3007
|
if (!id || !title || !command || !cwd || !startedAt) continue;
|
|
2507
3008
|
sessions.push({
|
|
@@ -2515,7 +3016,11 @@ function parseTmuxSessions(output) {
|
|
|
2515
3016
|
branch: null,
|
|
2516
3017
|
repositoryFullName: null,
|
|
2517
3018
|
lastActivityAt: null,
|
|
2518
|
-
changedFileCount: null
|
|
3019
|
+
changedFileCount: null,
|
|
3020
|
+
// tmux reports an unset user-option as an empty string via
|
|
3021
|
+
// #{...}, never literally absent — a non-worktree session (the
|
|
3022
|
+
// common case) has no @bondsmith-worktree-of option set at all.
|
|
3023
|
+
worktreeOf: worktreeOf ? worktreeOf : null
|
|
2519
3024
|
});
|
|
2520
3025
|
}
|
|
2521
3026
|
return sessions;
|
|
@@ -2528,7 +3033,8 @@ var LIST_FORMAT = [
|
|
|
2528
3033
|
"#{@bondsmith-title}",
|
|
2529
3034
|
"#{@bondsmith-command}",
|
|
2530
3035
|
"#{@bondsmith-cwd}",
|
|
2531
|
-
"#{@bondsmith-started-at}"
|
|
3036
|
+
"#{@bondsmith-started-at}",
|
|
3037
|
+
"#{@bondsmith-worktree-of}"
|
|
2532
3038
|
].join(" ");
|
|
2533
3039
|
async function discoverSessions() {
|
|
2534
3040
|
try {
|
|
@@ -2584,6 +3090,17 @@ var SessionManager = class {
|
|
|
2584
3090
|
await stopTmuxSession(session.tmuxSessionName);
|
|
2585
3091
|
} catch {
|
|
2586
3092
|
}
|
|
3093
|
+
if (session.worktreeOf) {
|
|
3094
|
+
try {
|
|
3095
|
+
await removeWorktree(session.worktreeOf, session.cwd);
|
|
3096
|
+
} catch (error2) {
|
|
3097
|
+
console.error("Failed to remove session worktree", {
|
|
3098
|
+
sessionId: session.id,
|
|
3099
|
+
worktreePath: session.cwd,
|
|
3100
|
+
error: error2
|
|
3101
|
+
});
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
2587
3104
|
return this.removeAsExited(session);
|
|
2588
3105
|
}
|
|
2589
3106
|
/**
|
|
@@ -2609,7 +3126,12 @@ var SessionManager = class {
|
|
|
2609
3126
|
if (error2 instanceof TmuxNotFoundError) console.error(`
|
|
2610
3127
|
${error2.message}
|
|
2611
3128
|
`);
|
|
2612
|
-
if (!(error2 instanceof TmuxNoServerRunningError))
|
|
3129
|
+
if (!(error2 instanceof TmuxNoServerRunningError)) {
|
|
3130
|
+
if (!(error2 instanceof TmuxNotFoundError)) {
|
|
3131
|
+
console.error("tmux list-sessions failed; skipping reconcile", error2);
|
|
3132
|
+
}
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
2613
3135
|
}
|
|
2614
3136
|
await Promise.all(
|
|
2615
3137
|
Array.from(this.sessions.values()).map(async (session) => {
|
|
@@ -2683,6 +3205,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2683
3205
|
const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
|
|
2684
3206
|
const wss = new WebSocketServer({
|
|
2685
3207
|
server: httpServer,
|
|
3208
|
+
// ws defaults to 100 MiB per frame, parsed before any signature is checked.
|
|
3209
|
+
maxPayload: MAX_WIRE_MESSAGE_BYTES,
|
|
2686
3210
|
// Client always sends the dev-tunnel token as the sole entry (see
|
|
2687
3211
|
// isValidDevTunnelToken); echoing it back is required for a spec-
|
|
2688
3212
|
// compliant handshake once a Sec-WebSocket-Protocol is offered.
|
|
@@ -2706,6 +3230,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2706
3230
|
broadcast({ type: "session.updated", session });
|
|
2707
3231
|
});
|
|
2708
3232
|
wss.on("connection", (socket, request) => {
|
|
3233
|
+
socket.on("error", (error2) => {
|
|
3234
|
+
console.warn("bondsmithd: browser socket error", error2.message);
|
|
3235
|
+
});
|
|
2709
3236
|
const rejectionReason = rejectionReasonByRequest.get(request);
|
|
2710
3237
|
if (rejectionReason) {
|
|
2711
3238
|
rejectionReasonByRequest.delete(request);
|
|
@@ -2720,7 +3247,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2720
3247
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
2721
3248
|
},
|
|
2722
3249
|
(controllerId) => registerControllerConnection(controllerId, socket),
|
|
2723
|
-
ptyAttachments
|
|
3250
|
+
ptyAttachments,
|
|
3251
|
+
() => socket.bufferedAmount
|
|
2724
3252
|
);
|
|
2725
3253
|
cleanupBySocket.set(socket, releaseAllSubscriptions);
|
|
2726
3254
|
connectionAuthBySocket.set(socket, connectionAuth);
|
|
@@ -2731,7 +3259,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2731
3259
|
challenge: challenge.challenge,
|
|
2732
3260
|
daemonFingerprint,
|
|
2733
3261
|
hostname: hostname(),
|
|
2734
|
-
expiresAt: challenge.expiresAt
|
|
3262
|
+
expiresAt: challenge.expiresAt,
|
|
3263
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3264
|
+
daemonVersion: DAEMON_VERSION
|
|
2735
3265
|
});
|
|
2736
3266
|
socket.on("message", (raw) => {
|
|
2737
3267
|
let parsed;
|
|
@@ -2747,6 +3277,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2747
3277
|
broadcast,
|
|
2748
3278
|
getWorkspaceStatus,
|
|
2749
3279
|
getWorkspaceDiff,
|
|
3280
|
+
validateProjectPath,
|
|
2750
3281
|
hostname: () => hostname(),
|
|
2751
3282
|
daemonFingerprint,
|
|
2752
3283
|
pendingAuthorizations,
|
|
@@ -2781,15 +3312,20 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2781
3312
|
}
|
|
2782
3313
|
|
|
2783
3314
|
// src/index.ts
|
|
3315
|
+
if (isVersionRequest(process.argv.slice(2))) {
|
|
3316
|
+
console.log(versionLine());
|
|
3317
|
+
process.exit(0);
|
|
3318
|
+
}
|
|
2784
3319
|
function loadRepoRootEnv() {
|
|
2785
|
-
const repoRoot =
|
|
2786
|
-
if (!existsSync(
|
|
2787
|
-
const envLocal =
|
|
2788
|
-
const envDefault =
|
|
3320
|
+
const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
3321
|
+
if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
3322
|
+
const envLocal = join6(repoRoot, ".env.local");
|
|
3323
|
+
const envDefault = join6(repoRoot, ".env");
|
|
2789
3324
|
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
2790
3325
|
}
|
|
2791
3326
|
loadRepoRootEnv();
|
|
2792
3327
|
async function start() {
|
|
3328
|
+
console.log(versionLine());
|
|
2793
3329
|
let config = getDaemonConfig();
|
|
2794
3330
|
if (!config.devTunnelToken) {
|
|
2795
3331
|
const generatedToken = await loadOrCreateDevTunnelToken();
|
|
@@ -2838,6 +3374,7 @@ async function start() {
|
|
|
2838
3374
|
ptyAttachments,
|
|
2839
3375
|
getWorkspaceStatus,
|
|
2840
3376
|
getWorkspaceDiff,
|
|
3377
|
+
validateProjectPath,
|
|
2841
3378
|
hostname: hostname2,
|
|
2842
3379
|
pendingAuthorizations,
|
|
2843
3380
|
presentPendingAuthorization
|