@bondsmith/bondsmithd 0.1.1 → 0.1.3
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 +20 -5
- package/dist/index.js +396 -123
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,12 +37,26 @@ Environment variables (all optional):
|
|
|
37
37
|
```
|
|
38
38
|
PORT=4577 # defaults to 4577
|
|
39
39
|
ALLOWED_ORIGINS=https://app.bondsmith.dev
|
|
40
|
-
BONDSMITH_RELAY_URL=
|
|
40
|
+
BONDSMITH_RELAY_URL=ws://127.0.0.1:8787 # defaults to production; set this when developing bondsmithd against a local apps/relay
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
`BONDSMITH_DEV_TUNNEL_TOKEN` also exists but only applies to the legacy direct-connection path
|
|
44
44
|
(`BONDSMITH_RELAY_ENABLED=false`) — should not be set by hand for a normal install.
|
|
45
45
|
|
|
46
|
+
## Updating
|
|
47
|
+
|
|
48
|
+
Stop the running daemon, then update it and start it again:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install -g @bondsmith/bondsmithd@latest
|
|
52
|
+
bondsmithd
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If you run it with `npx`, use `npx @bondsmith/bondsmithd@latest` so a cached older copy isn't reused.
|
|
56
|
+
|
|
57
|
+
When the web app and an older daemon disagree, Bondsmith shows "couldn't read a message from your
|
|
58
|
+
daemon". Reload the page first; if that doesn't fix it, update the daemon as above.
|
|
59
|
+
|
|
46
60
|
## What it does
|
|
47
61
|
|
|
48
62
|
- Creates, lists, attaches to, and stops tmux sessions prefixed `bondsmith-*`.
|
|
@@ -54,10 +68,11 @@ BONDSMITH_RELAY_URL=wss://relay.bondsmith.dev # defaults to a local-only address
|
|
|
54
68
|
|
|
55
69
|
## Production connectivity (current status)
|
|
56
70
|
|
|
57
|
-
`bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true
|
|
58
|
-
which is what a production account actually
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
`bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
|
|
72
|
+
`BONDSMITH_RELAY_URL=wss://relay.bondsmith.dev`), which is what a production account actually
|
|
73
|
+
uses — no dev-tunnel token involved, no config needed for a real install. Override
|
|
74
|
+
`BONDSMITH_RELAY_URL` to a local apps/relay instance (see "Environment variables" above) only when
|
|
75
|
+
developing bondsmithd itself.
|
|
61
76
|
|
|
62
77
|
The dev-tunnel-token requirement only applies to the legacy direct-connection path
|
|
63
78
|
(`BONDSMITH_RELAY_ENABLED=false`), which still 404s the token-handoff route in production by
|
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 = 1;
|
|
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";
|
|
@@ -55,7 +68,8 @@ var localSessionSchema = z2.object({
|
|
|
55
68
|
branch: z2.string().nullable(),
|
|
56
69
|
repositoryFullName: z2.string().nullable(),
|
|
57
70
|
lastActivityAt: z2.string().nullable(),
|
|
58
|
-
changedFileCount: z2.number().int().nonnegative().nullable()
|
|
71
|
+
changedFileCount: z2.number().int().nonnegative().nullable(),
|
|
72
|
+
worktreeOf: z2.string().nullable()
|
|
59
73
|
});
|
|
60
74
|
var workspaceFileEntrySchema = z2.object({
|
|
61
75
|
path: z2.string(),
|
|
@@ -77,6 +91,10 @@ var workspaceFileDiffSchema = z2.object({
|
|
|
77
91
|
patch: z2.string().nullable(),
|
|
78
92
|
binary: z2.boolean()
|
|
79
93
|
});
|
|
94
|
+
var projectPathValidationSchema = z2.discriminatedUnion("valid", [
|
|
95
|
+
z2.object({ valid: z2.literal(true), path: z2.string() }),
|
|
96
|
+
z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
|
|
97
|
+
]);
|
|
80
98
|
var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
81
99
|
z2.object({ type: z2.literal("session.list") }),
|
|
82
100
|
z2.object({
|
|
@@ -84,9 +102,34 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
84
102
|
title: z2.string().min(1).max(200),
|
|
85
103
|
cwd: z2.string().min(1),
|
|
86
104
|
command: z2.string().min(1).max(2e3),
|
|
105
|
+
// Opt-in worktree creation — see messages.ts's own field comment.
|
|
106
|
+
// Most branch-name legality is left to git itself in createWorktree()
|
|
107
|
+
// (whose named error classes surface a rejection back to the
|
|
108
|
+
// browser), EXCEPT a leading "-": git's own argv parsing for
|
|
109
|
+
// `worktree add -b <branch>` treats a leading "-" as a flag, not a
|
|
110
|
+
// branch name, producing a raw multi-line `git branch` usage dump as
|
|
111
|
+
// the error instead of a clean, classified one (confirmed live
|
|
112
|
+
// during adversarial review) — rejected here instead, before it ever
|
|
113
|
+
// reaches git.
|
|
114
|
+
worktreeBranch: z2.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
|
|
115
|
+
message: "branch name can't start with -"
|
|
116
|
+
}).optional(),
|
|
117
|
+
requestId: z2.string().min(1).max(200).optional()
|
|
118
|
+
}),
|
|
119
|
+
z2.object({
|
|
120
|
+
type: z2.literal("session.attach"),
|
|
121
|
+
sessionId: z2.string(),
|
|
122
|
+
// Optional (not required) so an older daemon build, or a stale queued
|
|
123
|
+
// envelope signed before this field existed, still parses — see
|
|
124
|
+
// session.attached/error's own requestId fields for why this matters:
|
|
125
|
+
// without a requestId round-tripped on both the ack and the error
|
|
126
|
+
// path, the browser can only guess which pending attachSession() call
|
|
127
|
+
// an incoming error/ack belongs to (oldest-pending heuristic), which
|
|
128
|
+
// misattributes a daemon error for ANY other in-flight command
|
|
129
|
+
// (observed: a session.resize racing ahead of its own session.attach)
|
|
130
|
+
// to the wrong promise — a confirmed bug, not a hypothetical.
|
|
87
131
|
requestId: z2.string().min(1).max(200).optional()
|
|
88
132
|
}),
|
|
89
|
-
z2.object({ type: z2.literal("session.attach"), sessionId: z2.string() }),
|
|
90
133
|
z2.object({
|
|
91
134
|
type: z2.literal("session.input"),
|
|
92
135
|
sessionId: z2.string(),
|
|
@@ -116,6 +159,11 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
116
159
|
staged: z2.boolean(),
|
|
117
160
|
requestId: z2.string().min(1).max(200)
|
|
118
161
|
}),
|
|
162
|
+
z2.object({
|
|
163
|
+
type: z2.literal("project.validatePath"),
|
|
164
|
+
path: z2.string().min(1).max(4096),
|
|
165
|
+
requestId: z2.string().min(1).max(200)
|
|
166
|
+
}),
|
|
119
167
|
z2.object({ type: z2.literal("daemon.getInfo") }),
|
|
120
168
|
// Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
|
|
121
169
|
// unsigned: the requesting browser has no trusted key yet to sign with.
|
|
@@ -166,8 +214,22 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
166
214
|
// session" (observed during a reconnect: attachSession() was
|
|
167
215
|
// fire-and-forget, so terminal.onData could fire before attach
|
|
168
216
|
// completed).
|
|
169
|
-
z2.object({
|
|
170
|
-
|
|
217
|
+
z2.object({
|
|
218
|
+
type: z2.literal("session.attached"),
|
|
219
|
+
sessionId: z2.string(),
|
|
220
|
+
requestId: z2.string().optional()
|
|
221
|
+
}),
|
|
222
|
+
z2.object({
|
|
223
|
+
type: z2.literal("error"),
|
|
224
|
+
message: z2.string(),
|
|
225
|
+
// Echoes the triggering message's requestId when it had one, so the
|
|
226
|
+
// browser can match this error to the exact pending request instead
|
|
227
|
+
// of guessing "oldest pending create, then oldest pending attach" —
|
|
228
|
+
// undefined for error paths with no requestId to echo (most session.*
|
|
229
|
+
// commands still don't carry one; see session.attach above for the
|
|
230
|
+
// one this was added to fix).
|
|
231
|
+
requestId: z2.string().optional()
|
|
232
|
+
}),
|
|
171
233
|
z2.object({
|
|
172
234
|
type: z2.literal("workspace.status"),
|
|
173
235
|
sessionId: z2.string(),
|
|
@@ -180,6 +242,11 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
180
242
|
requestId: z2.string(),
|
|
181
243
|
diff: workspaceFileDiffSchema
|
|
182
244
|
}),
|
|
245
|
+
z2.object({
|
|
246
|
+
type: z2.literal("project.pathValidated"),
|
|
247
|
+
requestId: z2.string(),
|
|
248
|
+
result: projectPathValidationSchema
|
|
249
|
+
}),
|
|
183
250
|
z2.object({
|
|
184
251
|
type: z2.literal("daemon.info"),
|
|
185
252
|
hostname: z2.string(),
|
|
@@ -199,7 +266,10 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
199
266
|
challenge: z2.string().min(1),
|
|
200
267
|
daemonFingerprint: z2.string().min(1),
|
|
201
268
|
hostname: z2.string(),
|
|
202
|
-
expiresAt: z2.number().int().positive()
|
|
269
|
+
expiresAt: z2.number().int().positive(),
|
|
270
|
+
// .catch(undefined): a malformed informational field must never fail the whole handshake message.
|
|
271
|
+
protocolVersion: z2.number().int().positive().optional().catch(void 0),
|
|
272
|
+
daemonVersion: z2.string().optional().catch(void 0)
|
|
203
273
|
}),
|
|
204
274
|
z2.object({
|
|
205
275
|
type: z2.literal("connection.authenticated"),
|
|
@@ -284,7 +354,8 @@ var SIGNABLE_ACTIONS = [
|
|
|
284
354
|
"session.stop",
|
|
285
355
|
"session.list",
|
|
286
356
|
"workspace.getStatus",
|
|
287
|
-
"workspace.getDiff"
|
|
357
|
+
"workspace.getDiff",
|
|
358
|
+
"project.validatePath"
|
|
288
359
|
];
|
|
289
360
|
var envelopeSchema = z3.object({
|
|
290
361
|
version: z3.literal(1),
|
|
@@ -508,18 +579,19 @@ var environmentSchema = z8.object({
|
|
|
508
579
|
// install needs to touch.
|
|
509
580
|
BONDSMITH_BIND_HOST: z8.string().min(1).default("127.0.0.1"),
|
|
510
581
|
// BD-450/BD-483/BD-484: connect outbound to the Bondsmith relay instead of
|
|
511
|
-
// (or alongside) listening inbound. ON by default
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
// BONDSMITH_RELAY_URL defaults to that same local apps/relay instance for
|
|
517
|
-
// the same reason. The existing inbound listener is completely
|
|
518
|
-
// unaffected either way — it keeps running regardless, so direct-
|
|
519
|
-
// connection debugging (bypassing the relay entirely) is still possible
|
|
520
|
-
// by setting BONDSMITH_RELAY_ENABLED=false explicitly.
|
|
582
|
+
// (or alongside) listening inbound. ON by default. The existing inbound
|
|
583
|
+
// listener is completely unaffected either way — it keeps running
|
|
584
|
+
// regardless, so direct-connection debugging (bypassing the relay
|
|
585
|
+
// entirely) is still possible by setting BONDSMITH_RELAY_ENABLED=false
|
|
586
|
+
// explicitly.
|
|
521
587
|
BONDSMITH_RELAY_ENABLED: z8.string().default("true").transform((value) => value === "true" || value === "1"),
|
|
522
|
-
|
|
588
|
+
// Defaults to production, mirroring BONDSMITH_API_URL above, so a real
|
|
589
|
+
// install works with zero config — a bare `bondsmithd` used to silently
|
|
590
|
+
// connect outbound to loopback (nothing listening there) with no error,
|
|
591
|
+
// looking healthy while never reaching relay at all. Override to a local
|
|
592
|
+
// apps/relay instance (its own `pnpm dev` default port) when developing
|
|
593
|
+
// bondsmithd itself against a local relay.
|
|
594
|
+
BONDSMITH_RELAY_URL: z8.string().url().default("wss://relay.bondsmith.dev")
|
|
523
595
|
});
|
|
524
596
|
function getDaemonConfig(env = process.env) {
|
|
525
597
|
const parsed = environmentSchema.parse(env);
|
|
@@ -612,7 +684,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
|
|
|
612
684
|
// src/pairing/pairing.ts
|
|
613
685
|
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
614
686
|
function sleep(ms) {
|
|
615
|
-
return new Promise((
|
|
687
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
616
688
|
}
|
|
617
689
|
async function ensurePaired(options) {
|
|
618
690
|
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -749,20 +821,128 @@ async function bindFirstController(params, response) {
|
|
|
749
821
|
}
|
|
750
822
|
}
|
|
751
823
|
|
|
824
|
+
// src/projects/validate-path.ts
|
|
825
|
+
import { stat } from "fs/promises";
|
|
826
|
+
import { isAbsolute, resolve } from "path";
|
|
827
|
+
|
|
828
|
+
// src/sessions/expand-home.ts
|
|
829
|
+
import { homedir } from "os";
|
|
830
|
+
import { join } from "path";
|
|
831
|
+
function expandHome(cwd) {
|
|
832
|
+
if (cwd === "~") return homedir();
|
|
833
|
+
if (cwd.startsWith("~/")) return join(homedir(), cwd.slice(2));
|
|
834
|
+
return cwd;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/sessions/git/client.ts
|
|
838
|
+
import { execFile as execFileCb } from "child_process";
|
|
839
|
+
import { promisify } from "util";
|
|
840
|
+
var execFile = promisify(execFileCb);
|
|
841
|
+
var GitNotARepoError = class extends Error {
|
|
842
|
+
constructor() {
|
|
843
|
+
super("not a git repository");
|
|
844
|
+
this.name = "GitNotARepoError";
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
function isNotARepoError(error2) {
|
|
848
|
+
if (typeof error2 !== "object" || error2 === null) return false;
|
|
849
|
+
const stderr = error2.stderr;
|
|
850
|
+
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
851
|
+
}
|
|
852
|
+
async function runGit(args, cwd) {
|
|
853
|
+
try {
|
|
854
|
+
const { stdout } = await execFile("git", args, { cwd });
|
|
855
|
+
return stdout;
|
|
856
|
+
} catch (error2) {
|
|
857
|
+
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
858
|
+
throw error2;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
862
|
+
try {
|
|
863
|
+
const { stdout } = await execFile("git", args, { cwd });
|
|
864
|
+
return stdout;
|
|
865
|
+
} catch (error2) {
|
|
866
|
+
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
867
|
+
const code = error2.code;
|
|
868
|
+
const stdout = error2.stdout;
|
|
869
|
+
if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
|
|
870
|
+
return stdout;
|
|
871
|
+
}
|
|
872
|
+
throw error2;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// src/projects/validate-path.ts
|
|
877
|
+
var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
|
|
878
|
+
var PERMISSION_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
|
|
879
|
+
function invalidReasonForFsError(error2) {
|
|
880
|
+
const code = error2?.code;
|
|
881
|
+
if (typeof code !== "string") return null;
|
|
882
|
+
if (NOT_FOUND_CODES.has(code)) return "not_found";
|
|
883
|
+
if (PERMISSION_CODES.has(code)) return "permission_denied";
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
async function validateProjectPath(input) {
|
|
887
|
+
const expanded = expandHome(input.trim());
|
|
888
|
+
if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
|
|
889
|
+
const path = resolve(expanded);
|
|
890
|
+
try {
|
|
891
|
+
const stats = await stat(path);
|
|
892
|
+
if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
|
|
893
|
+
} catch (error2) {
|
|
894
|
+
const reason = invalidReasonForFsError(error2);
|
|
895
|
+
if (reason) return { valid: false, reason };
|
|
896
|
+
throw error2;
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
const insideWorkTree = (await runGit(["rev-parse", "--is-inside-work-tree"], path)).trim();
|
|
900
|
+
if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
|
|
901
|
+
} catch (error2) {
|
|
902
|
+
if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
|
|
903
|
+
throw error2;
|
|
904
|
+
}
|
|
905
|
+
return { valid: true, path };
|
|
906
|
+
}
|
|
907
|
+
|
|
752
908
|
// src/relay/relay-client.ts
|
|
753
909
|
import { sign } from "crypto";
|
|
754
910
|
import WebSocket from "ws";
|
|
755
911
|
|
|
912
|
+
// src/version.ts
|
|
913
|
+
var DAEMON_VERSION = true ? "0.1.3" : "dev";
|
|
914
|
+
|
|
756
915
|
// src/sessions/tmux/client.ts
|
|
757
|
-
import { execFile as
|
|
758
|
-
import { promisify } from "util";
|
|
759
|
-
var
|
|
916
|
+
import { execFile as execFileCb2 } from "child_process";
|
|
917
|
+
import { promisify as promisify2 } from "util";
|
|
918
|
+
var execFile2 = promisify2(execFileCb2);
|
|
760
919
|
var TmuxNoServerRunningError = class extends Error {
|
|
761
920
|
constructor() {
|
|
762
921
|
super("no tmux server running");
|
|
763
922
|
this.name = "TmuxNoServerRunningError";
|
|
764
923
|
}
|
|
765
924
|
};
|
|
925
|
+
var TmuxNotFoundError = class extends Error {
|
|
926
|
+
constructor() {
|
|
927
|
+
super(
|
|
928
|
+
[
|
|
929
|
+
"bondsmithd requires tmux, and it was not found on PATH.",
|
|
930
|
+
"",
|
|
931
|
+
"Install it:",
|
|
932
|
+
" macOS (Homebrew): brew install tmux",
|
|
933
|
+
" Debian/Ubuntu: sudo apt-get install tmux",
|
|
934
|
+
" Fedora/RHEL: sudo dnf install tmux",
|
|
935
|
+
" Arch: sudo pacman -S tmux",
|
|
936
|
+
"",
|
|
937
|
+
"If tmux is already installed, start bondsmithd from a normal interactive shell \u2014",
|
|
938
|
+
"not a LaunchAgent, Docker container, or other minimal-PATH context. Those often",
|
|
939
|
+
"don't inherit the PATH tmux was installed into (e.g. Homebrew's /opt/homebrew/bin",
|
|
940
|
+
"on Apple Silicon)."
|
|
941
|
+
].join("\n")
|
|
942
|
+
);
|
|
943
|
+
this.name = "TmuxNotFoundError";
|
|
944
|
+
}
|
|
945
|
+
};
|
|
766
946
|
var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
|
|
767
947
|
function filterSpawnEnv(env) {
|
|
768
948
|
const filtered = { ...env };
|
|
@@ -770,8 +950,13 @@ function filterSpawnEnv(env) {
|
|
|
770
950
|
return filtered;
|
|
771
951
|
}
|
|
772
952
|
async function runTmux(args, options) {
|
|
773
|
-
|
|
774
|
-
|
|
953
|
+
try {
|
|
954
|
+
const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
|
|
955
|
+
return stdout;
|
|
956
|
+
} catch (error2) {
|
|
957
|
+
if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
|
|
958
|
+
throw error2;
|
|
959
|
+
}
|
|
775
960
|
}
|
|
776
961
|
async function sendKeysLiteral(tmuxSessionName, text) {
|
|
777
962
|
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
|
|
@@ -807,8 +992,8 @@ import { verify as verify2 } from "crypto";
|
|
|
807
992
|
|
|
808
993
|
// src/security/trusted-controllers.ts
|
|
809
994
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
810
|
-
import { homedir } from "os";
|
|
811
|
-
import { dirname, join } from "path";
|
|
995
|
+
import { homedir as homedir2 } from "os";
|
|
996
|
+
import { dirname, join as join2 } from "path";
|
|
812
997
|
import { z as z9 } from "zod";
|
|
813
998
|
var trustedControllerSchema = z9.object({
|
|
814
999
|
id: z9.string(),
|
|
@@ -831,7 +1016,7 @@ var trustedControllersFileSchema = z9.object({
|
|
|
831
1016
|
controllers: z9.array(trustedControllerSchema)
|
|
832
1017
|
});
|
|
833
1018
|
function defaultTrustedControllersPath() {
|
|
834
|
-
return
|
|
1019
|
+
return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
|
|
835
1020
|
}
|
|
836
1021
|
async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
837
1022
|
let raw;
|
|
@@ -1009,6 +1194,7 @@ var BRACKETED_PASTE_END = "\x1B[201~";
|
|
|
1009
1194
|
function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments) {
|
|
1010
1195
|
const outputListeners = /* @__PURE__ */ new Map();
|
|
1011
1196
|
const attachLocks = /* @__PURE__ */ new Map();
|
|
1197
|
+
let admissionChain = Promise.resolve();
|
|
1012
1198
|
const conn = {
|
|
1013
1199
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
1014
1200
|
send,
|
|
@@ -1018,6 +1204,13 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
1018
1204
|
getOutputListener: (sessionId) => outputListeners.get(sessionId),
|
|
1019
1205
|
connectionAuth,
|
|
1020
1206
|
markControllerId,
|
|
1207
|
+
admitInOrder: (fn) => {
|
|
1208
|
+
const next = admissionChain.catch(() => {
|
|
1209
|
+
}).then(fn);
|
|
1210
|
+
admissionChain = next.catch(() => {
|
|
1211
|
+
});
|
|
1212
|
+
return next;
|
|
1213
|
+
},
|
|
1021
1214
|
withAttachLock: (sessionId, fn) => {
|
|
1022
1215
|
const previous = attachLocks.get(sessionId) ?? Promise.resolve();
|
|
1023
1216
|
const next = previous.catch(() => {
|
|
@@ -1038,8 +1231,8 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
1038
1231
|
}
|
|
1039
1232
|
return { conn, releaseAllSubscriptions };
|
|
1040
1233
|
}
|
|
1041
|
-
function error(message) {
|
|
1042
|
-
return { type: "error", message };
|
|
1234
|
+
function error(message, requestId) {
|
|
1235
|
+
return { type: "error", message, requestId };
|
|
1043
1236
|
}
|
|
1044
1237
|
function errorMessageOf(err, fallback) {
|
|
1045
1238
|
return err instanceof Error ? err.message : fallback;
|
|
@@ -1055,35 +1248,39 @@ async function handleBrowserMessage(conn, raw, deps) {
|
|
|
1055
1248
|
const { envelope } = signedCommand.data;
|
|
1056
1249
|
const innerMessage = browserMessageSchema.safeParse(envelope.payload);
|
|
1057
1250
|
if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
|
|
1058
|
-
conn.send(error(
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
const verdict = await verifyCommand(
|
|
1062
|
-
signedCommand.data,
|
|
1063
|
-
deps.daemonFingerprint,
|
|
1064
|
-
conn.connectionAuth,
|
|
1065
|
-
deps.trustedControllersPath
|
|
1066
|
-
);
|
|
1067
|
-
if (!verdict.ok) {
|
|
1068
|
-
conn.send(error(verifyRejectionMessage(verdict.reason)));
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
if (conn.connectionAuth.isRevoked()) {
|
|
1072
|
-
conn.send(error(REVOKED_REJECTION_MESSAGE));
|
|
1251
|
+
conn.send(error(INVALID_MESSAGE_ERROR));
|
|
1073
1252
|
return;
|
|
1074
1253
|
}
|
|
1254
|
+
const admitted = await conn.admitInOrder(async () => {
|
|
1255
|
+
const verdict = await verifyCommand(
|
|
1256
|
+
signedCommand.data,
|
|
1257
|
+
deps.daemonFingerprint,
|
|
1258
|
+
conn.connectionAuth,
|
|
1259
|
+
deps.trustedControllersPath
|
|
1260
|
+
);
|
|
1261
|
+
if (!verdict.ok) {
|
|
1262
|
+
conn.send(error(verifyRejectionMessage(verdict.reason)));
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
if (conn.connectionAuth.isRevoked()) {
|
|
1266
|
+
conn.send(error(REVOKED_REJECTION_MESSAGE));
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
return true;
|
|
1270
|
+
});
|
|
1271
|
+
if (!admitted) return;
|
|
1075
1272
|
await dispatch(conn, innerMessage.data, deps);
|
|
1076
1273
|
return;
|
|
1077
1274
|
}
|
|
1078
1275
|
const parsed = browserMessageSchema.safeParse(raw);
|
|
1079
1276
|
if (!parsed.success) {
|
|
1080
|
-
conn.send(error(
|
|
1277
|
+
conn.send(error(INVALID_MESSAGE_ERROR));
|
|
1081
1278
|
return;
|
|
1082
1279
|
}
|
|
1083
1280
|
if (parsed.data.type === "connection.authenticate") {
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
deps.trustedControllersPath
|
|
1281
|
+
const { signed } = parsed.data;
|
|
1282
|
+
const result = await conn.admitInOrder(
|
|
1283
|
+
() => conn.connectionAuth.authenticate(signed, deps.trustedControllersPath)
|
|
1087
1284
|
);
|
|
1088
1285
|
if (!result.ok) {
|
|
1089
1286
|
conn.send({ type: "connection.authenticationFailed", reason: result.reason });
|
|
@@ -1105,6 +1302,7 @@ async function dispatch(conn, message, {
|
|
|
1105
1302
|
broadcast,
|
|
1106
1303
|
getWorkspaceStatus: getWorkspaceStatus2,
|
|
1107
1304
|
getWorkspaceDiff: getWorkspaceDiff2,
|
|
1305
|
+
validateProjectPath: validateProjectPath2,
|
|
1108
1306
|
hostname: hostname3,
|
|
1109
1307
|
daemonFingerprint,
|
|
1110
1308
|
pendingAuthorizations,
|
|
@@ -1116,22 +1314,26 @@ async function dispatch(conn, message, {
|
|
|
1116
1314
|
return;
|
|
1117
1315
|
}
|
|
1118
1316
|
case "session.create": {
|
|
1119
|
-
const { title, cwd, command, requestId } = message;
|
|
1317
|
+
const { title, cwd, command, worktreeBranch, requestId } = message;
|
|
1120
1318
|
try {
|
|
1121
|
-
const session = await sessionManager.create({ title, cwd, command });
|
|
1319
|
+
const session = await sessionManager.create({ title, cwd, command, worktreeBranch });
|
|
1122
1320
|
broadcast({ type: "session.created", session, requestId });
|
|
1123
1321
|
} catch (err) {
|
|
1124
|
-
conn.send(error(errorMessageOf(err, "failed to create session")));
|
|
1322
|
+
conn.send(error(errorMessageOf(err, "failed to create session"), requestId));
|
|
1125
1323
|
}
|
|
1126
1324
|
return;
|
|
1127
1325
|
}
|
|
1128
1326
|
case "session.attach": {
|
|
1327
|
+
const { requestId } = message;
|
|
1129
1328
|
if (!isValidSessionId(message.sessionId)) {
|
|
1130
|
-
conn.send(error("invalid session id"));
|
|
1329
|
+
conn.send(error("invalid session id", requestId));
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const session = sessionManager.get(message.sessionId);
|
|
1333
|
+
if (!session) {
|
|
1334
|
+
conn.send(error("unknown session", requestId));
|
|
1131
1335
|
return;
|
|
1132
1336
|
}
|
|
1133
|
-
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1134
|
-
if (!session) return;
|
|
1135
1337
|
await conn.withAttachLock(message.sessionId, async () => {
|
|
1136
1338
|
const previousListener = conn.getOutputListener(message.sessionId);
|
|
1137
1339
|
const onData = (data) => {
|
|
@@ -1145,7 +1347,7 @@ async function dispatch(conn, message, {
|
|
|
1145
1347
|
)) {
|
|
1146
1348
|
conn.subscriptions.add(message.sessionId);
|
|
1147
1349
|
conn.registerOutputListener(message.sessionId, onData);
|
|
1148
|
-
conn.send({ type: "session.attached", sessionId: message.sessionId });
|
|
1350
|
+
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1149
1351
|
return;
|
|
1150
1352
|
}
|
|
1151
1353
|
if (previousListener) {
|
|
@@ -1157,12 +1359,12 @@ async function dispatch(conn, message, {
|
|
|
1157
1359
|
onData
|
|
1158
1360
|
);
|
|
1159
1361
|
if (!attached) {
|
|
1160
|
-
conn.send(error("failed to attach to session"));
|
|
1362
|
+
conn.send(error("failed to attach to session", requestId));
|
|
1161
1363
|
return;
|
|
1162
1364
|
}
|
|
1163
1365
|
conn.subscriptions.add(message.sessionId);
|
|
1164
1366
|
conn.registerOutputListener(message.sessionId, onData);
|
|
1165
|
-
conn.send({ type: "session.attached", sessionId: message.sessionId });
|
|
1367
|
+
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1166
1368
|
});
|
|
1167
1369
|
return;
|
|
1168
1370
|
}
|
|
@@ -1238,6 +1440,15 @@ async function dispatch(conn, message, {
|
|
|
1238
1440
|
}
|
|
1239
1441
|
return;
|
|
1240
1442
|
}
|
|
1443
|
+
case "project.validatePath": {
|
|
1444
|
+
try {
|
|
1445
|
+
const result = await validateProjectPath2(message.path);
|
|
1446
|
+
conn.send({ type: "project.pathValidated", requestId: message.requestId, result });
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
conn.send(error(errorMessageOf(err, "failed to validate project path"), message.requestId));
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1241
1452
|
case "daemon.getInfo": {
|
|
1242
1453
|
conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
|
|
1243
1454
|
return;
|
|
@@ -1588,7 +1799,9 @@ function startRelayClient(deps) {
|
|
|
1588
1799
|
challenge: challenge.challenge,
|
|
1589
1800
|
daemonFingerprint: deps.daemonFingerprint,
|
|
1590
1801
|
hostname: deps.hostname(),
|
|
1591
|
-
expiresAt: challenge.expiresAt
|
|
1802
|
+
expiresAt: challenge.expiresAt,
|
|
1803
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1804
|
+
daemonVersion: DAEMON_VERSION
|
|
1592
1805
|
});
|
|
1593
1806
|
}
|
|
1594
1807
|
function handleFrame(frame) {
|
|
@@ -1686,10 +1899,10 @@ function startRelayClient(deps) {
|
|
|
1686
1899
|
// src/security/daemon-identity.ts
|
|
1687
1900
|
import { generateKeyPairSync, sign as sign2, verify as verify4 } from "crypto";
|
|
1688
1901
|
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
1689
|
-
import { homedir as
|
|
1690
|
-
import { dirname as dirname2, join as
|
|
1902
|
+
import { homedir as homedir3 } from "os";
|
|
1903
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
1691
1904
|
function defaultCredentialsPath() {
|
|
1692
|
-
return
|
|
1905
|
+
return join3(homedir3(), ".bondsmith", "credentials");
|
|
1693
1906
|
}
|
|
1694
1907
|
async function loadOrCreateDaemonIdentity(credentialsPath = defaultCredentialsPath()) {
|
|
1695
1908
|
const existing = await tryReadIdentity(credentialsPath);
|
|
@@ -1751,10 +1964,10 @@ function extractPemBlock(text, label) {
|
|
|
1751
1964
|
// src/security/dev-tunnel-token-file.ts
|
|
1752
1965
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
1753
1966
|
import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
1754
|
-
import { homedir as
|
|
1755
|
-
import { dirname as dirname3, join as
|
|
1967
|
+
import { homedir as homedir4 } from "os";
|
|
1968
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
1756
1969
|
function defaultDevTunnelTokenPath() {
|
|
1757
|
-
return
|
|
1970
|
+
return join4(homedir4(), ".bondsmith", "dev-tunnel-token");
|
|
1758
1971
|
}
|
|
1759
1972
|
async function loadOrCreateDevTunnelToken(tokenPath = defaultDevTunnelTokenPath()) {
|
|
1760
1973
|
const existing = await tryReadToken(tokenPath);
|
|
@@ -1862,8 +2075,8 @@ var PendingAuthorizationStore = class {
|
|
|
1862
2075
|
requestToken: randomUUID()
|
|
1863
2076
|
};
|
|
1864
2077
|
let settleFn;
|
|
1865
|
-
const resultPromise = new Promise((
|
|
1866
|
-
settleFn =
|
|
2078
|
+
const resultPromise = new Promise((resolve2) => {
|
|
2079
|
+
settleFn = resolve2;
|
|
1867
2080
|
});
|
|
1868
2081
|
const entry = {
|
|
1869
2082
|
authorization,
|
|
@@ -2087,45 +2300,6 @@ function presentPendingAuthorizationOnTerminal(store) {
|
|
|
2087
2300
|
};
|
|
2088
2301
|
}
|
|
2089
2302
|
|
|
2090
|
-
// src/sessions/git/client.ts
|
|
2091
|
-
import { execFile as execFileCb2 } from "child_process";
|
|
2092
|
-
import { promisify as promisify2 } from "util";
|
|
2093
|
-
var execFile2 = promisify2(execFileCb2);
|
|
2094
|
-
var GitNotARepoError = class extends Error {
|
|
2095
|
-
constructor() {
|
|
2096
|
-
super("not a git repository");
|
|
2097
|
-
this.name = "GitNotARepoError";
|
|
2098
|
-
}
|
|
2099
|
-
};
|
|
2100
|
-
function isNotARepoError(error2) {
|
|
2101
|
-
if (typeof error2 !== "object" || error2 === null) return false;
|
|
2102
|
-
const stderr = error2.stderr;
|
|
2103
|
-
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
2104
|
-
}
|
|
2105
|
-
async function runGit(args, cwd) {
|
|
2106
|
-
try {
|
|
2107
|
-
const { stdout } = await execFile2("git", args, { cwd });
|
|
2108
|
-
return stdout;
|
|
2109
|
-
} catch (error2) {
|
|
2110
|
-
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
2111
|
-
throw error2;
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
2115
|
-
try {
|
|
2116
|
-
const { stdout } = await execFile2("git", args, { cwd });
|
|
2117
|
-
return stdout;
|
|
2118
|
-
} catch (error2) {
|
|
2119
|
-
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
2120
|
-
const code = error2.code;
|
|
2121
|
-
const stdout = error2.stdout;
|
|
2122
|
-
if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
|
|
2123
|
-
return stdout;
|
|
2124
|
-
}
|
|
2125
|
-
throw error2;
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
2303
|
// src/sessions/git/diff.ts
|
|
2130
2304
|
async function getWorkspaceDiff(cwd, path, staged) {
|
|
2131
2305
|
const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
|
|
@@ -2346,7 +2520,12 @@ var PtyAttachmentManager = class {
|
|
|
2346
2520
|
cwd: process.env.HOME,
|
|
2347
2521
|
env: filterSpawnEnv(process.env)
|
|
2348
2522
|
});
|
|
2349
|
-
} catch {
|
|
2523
|
+
} catch (error2) {
|
|
2524
|
+
if (error2?.code === "ENOENT") {
|
|
2525
|
+
console.error(`
|
|
2526
|
+
${new TmuxNotFoundError().message}
|
|
2527
|
+
`);
|
|
2528
|
+
}
|
|
2350
2529
|
return false;
|
|
2351
2530
|
}
|
|
2352
2531
|
handle = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
|
|
@@ -2410,23 +2589,84 @@ var PtyAttachmentManager = class {
|
|
|
2410
2589
|
// src/sessions/session-manager.ts
|
|
2411
2590
|
import { v4 as uuidv4 } from "uuid";
|
|
2412
2591
|
|
|
2592
|
+
// src/sessions/git/worktree.ts
|
|
2593
|
+
import { execFile as execFileCb3 } from "child_process";
|
|
2594
|
+
import { promisify as promisify3 } from "util";
|
|
2595
|
+
var execFile3 = promisify3(execFileCb3);
|
|
2596
|
+
var WorktreeBranchNameTakenError = class extends Error {
|
|
2597
|
+
constructor(branch) {
|
|
2598
|
+
super(`a branch named "${branch}" already exists`);
|
|
2599
|
+
this.name = "WorktreeBranchNameTakenError";
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
var WorktreePathExistsError = class extends Error {
|
|
2603
|
+
constructor(path) {
|
|
2604
|
+
super(`worktree path already exists: ${path}`);
|
|
2605
|
+
this.name = "WorktreePathExistsError";
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
var WorktreeHasUncommittedChangesError = class extends Error {
|
|
2609
|
+
constructor(path) {
|
|
2610
|
+
super(
|
|
2611
|
+
`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}).`
|
|
2612
|
+
);
|
|
2613
|
+
this.name = "WorktreeHasUncommittedChangesError";
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
function stderrOf(error2) {
|
|
2617
|
+
if (typeof error2 !== "object" || error2 === null) return "";
|
|
2618
|
+
const stderr = error2.stderr;
|
|
2619
|
+
return typeof stderr === "string" ? stderr : "";
|
|
2620
|
+
}
|
|
2621
|
+
async function createWorktree(sourceCwd, worktreePath, branch) {
|
|
2622
|
+
try {
|
|
2623
|
+
await execFile3("git", ["worktree", "add", "-b", branch, worktreePath], { cwd: sourceCwd });
|
|
2624
|
+
} catch (error2) {
|
|
2625
|
+
const stderr = stderrOf(error2);
|
|
2626
|
+
if (stderr.includes("a branch named")) {
|
|
2627
|
+
throw new WorktreeBranchNameTakenError(branch);
|
|
2628
|
+
}
|
|
2629
|
+
if (stderr.includes("already exists")) {
|
|
2630
|
+
throw new WorktreePathExistsError(worktreePath);
|
|
2631
|
+
}
|
|
2632
|
+
throw error2;
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
async function removeWorktree(sourceCwd, worktreePath) {
|
|
2636
|
+
try {
|
|
2637
|
+
await execFile3("git", ["worktree", "remove", worktreePath], { cwd: sourceCwd });
|
|
2638
|
+
} catch (error2) {
|
|
2639
|
+
const stderr = stderrOf(error2);
|
|
2640
|
+
if (stderr.includes("contains modified or untracked files")) {
|
|
2641
|
+
throw new WorktreeHasUncommittedChangesError(worktreePath);
|
|
2642
|
+
}
|
|
2643
|
+
throw error2;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2413
2647
|
// src/sessions/tmux/create.ts
|
|
2414
2648
|
import { access } from "fs/promises";
|
|
2415
|
-
import { homedir as
|
|
2416
|
-
import { join as
|
|
2649
|
+
import { homedir as homedir5 } from "os";
|
|
2650
|
+
import { join as join5 } from "path";
|
|
2417
2651
|
var InvalidCwdError = class extends Error {
|
|
2418
2652
|
constructor(cwd) {
|
|
2419
2653
|
super(`working directory does not exist: ${cwd}`);
|
|
2420
2654
|
this.name = "InvalidCwdError";
|
|
2421
2655
|
}
|
|
2422
2656
|
};
|
|
2423
|
-
function
|
|
2424
|
-
|
|
2425
|
-
if (cwd.startsWith("~/")) return join4(homedir4(), cwd.slice(2));
|
|
2426
|
-
return cwd;
|
|
2657
|
+
function worktreePathFor(sessionId) {
|
|
2658
|
+
return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
|
|
2427
2659
|
}
|
|
2428
2660
|
async function createTmuxSession(input) {
|
|
2429
|
-
const
|
|
2661
|
+
const sourceCwd = expandHome(input.cwd);
|
|
2662
|
+
let cwd = sourceCwd;
|
|
2663
|
+
let worktreeOf = null;
|
|
2664
|
+
if (input.worktreeBranch) {
|
|
2665
|
+
const worktreePath = worktreePathFor(input.id);
|
|
2666
|
+
await createWorktree(sourceCwd, worktreePath, input.worktreeBranch);
|
|
2667
|
+
cwd = worktreePath;
|
|
2668
|
+
worktreeOf = sourceCwd;
|
|
2669
|
+
}
|
|
2430
2670
|
try {
|
|
2431
2671
|
await access(cwd);
|
|
2432
2672
|
} catch {
|
|
@@ -2442,6 +2682,9 @@ async function createTmuxSession(input) {
|
|
|
2442
2682
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
|
|
2443
2683
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-cwd", cwd]);
|
|
2444
2684
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-started-at", startedAt]);
|
|
2685
|
+
if (worktreeOf) {
|
|
2686
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
|
|
2687
|
+
}
|
|
2445
2688
|
const session = {
|
|
2446
2689
|
id: input.id,
|
|
2447
2690
|
title: input.title,
|
|
@@ -2453,7 +2696,8 @@ async function createTmuxSession(input) {
|
|
|
2453
2696
|
branch: null,
|
|
2454
2697
|
repositoryFullName: null,
|
|
2455
2698
|
lastActivityAt: null,
|
|
2456
|
-
changedFileCount: null
|
|
2699
|
+
changedFileCount: null,
|
|
2700
|
+
worktreeOf
|
|
2457
2701
|
};
|
|
2458
2702
|
return enrichSessionWithGitMetadata(session);
|
|
2459
2703
|
}
|
|
@@ -2462,14 +2706,14 @@ async function stopTmuxSession(tmuxSessionName) {
|
|
|
2462
2706
|
}
|
|
2463
2707
|
|
|
2464
2708
|
// src/sessions/tmux/parse.ts
|
|
2465
|
-
var FIELD_COUNT =
|
|
2709
|
+
var FIELD_COUNT = 7;
|
|
2466
2710
|
function parseTmuxSessions(output) {
|
|
2467
2711
|
const sessions = [];
|
|
2468
2712
|
for (const line of output.split("\n")) {
|
|
2469
2713
|
if (!line.trim()) continue;
|
|
2470
2714
|
const fields = line.split(" ");
|
|
2471
2715
|
if (fields.length < FIELD_COUNT) continue;
|
|
2472
|
-
const [sessionName, id, title, command, cwd, startedAt] = fields;
|
|
2716
|
+
const [sessionName, id, title, command, cwd, startedAt, worktreeOf] = fields;
|
|
2473
2717
|
if (!isBondsmithTmuxSessionName(sessionName)) continue;
|
|
2474
2718
|
if (!id || !title || !command || !cwd || !startedAt) continue;
|
|
2475
2719
|
sessions.push({
|
|
@@ -2483,7 +2727,11 @@ function parseTmuxSessions(output) {
|
|
|
2483
2727
|
branch: null,
|
|
2484
2728
|
repositoryFullName: null,
|
|
2485
2729
|
lastActivityAt: null,
|
|
2486
|
-
changedFileCount: null
|
|
2730
|
+
changedFileCount: null,
|
|
2731
|
+
// tmux reports an unset user-option as an empty string via
|
|
2732
|
+
// #{...}, never literally absent — a non-worktree session (the
|
|
2733
|
+
// common case) has no @bondsmith-worktree-of option set at all.
|
|
2734
|
+
worktreeOf: worktreeOf ? worktreeOf : null
|
|
2487
2735
|
});
|
|
2488
2736
|
}
|
|
2489
2737
|
return sessions;
|
|
@@ -2496,7 +2744,8 @@ var LIST_FORMAT = [
|
|
|
2496
2744
|
"#{@bondsmith-title}",
|
|
2497
2745
|
"#{@bondsmith-command}",
|
|
2498
2746
|
"#{@bondsmith-cwd}",
|
|
2499
|
-
"#{@bondsmith-started-at}"
|
|
2747
|
+
"#{@bondsmith-started-at}",
|
|
2748
|
+
"#{@bondsmith-worktree-of}"
|
|
2500
2749
|
].join(" ");
|
|
2501
2750
|
async function discoverSessions() {
|
|
2502
2751
|
try {
|
|
@@ -2505,6 +2754,12 @@ async function discoverSessions() {
|
|
|
2505
2754
|
return Promise.all(sessions.map((session) => enrichSessionWithGitMetadata(session)));
|
|
2506
2755
|
} catch (error2) {
|
|
2507
2756
|
if (error2 instanceof TmuxNoServerRunningError) return [];
|
|
2757
|
+
if (error2 instanceof TmuxNotFoundError) {
|
|
2758
|
+
console.error(`
|
|
2759
|
+
${error2.message}
|
|
2760
|
+
`);
|
|
2761
|
+
process.exit(1);
|
|
2762
|
+
}
|
|
2508
2763
|
throw error2;
|
|
2509
2764
|
}
|
|
2510
2765
|
}
|
|
@@ -2546,6 +2801,17 @@ var SessionManager = class {
|
|
|
2546
2801
|
await stopTmuxSession(session.tmuxSessionName);
|
|
2547
2802
|
} catch {
|
|
2548
2803
|
}
|
|
2804
|
+
if (session.worktreeOf) {
|
|
2805
|
+
try {
|
|
2806
|
+
await removeWorktree(session.worktreeOf, session.cwd);
|
|
2807
|
+
} catch (error2) {
|
|
2808
|
+
console.error("Failed to remove session worktree", {
|
|
2809
|
+
sessionId: session.id,
|
|
2810
|
+
worktreePath: session.cwd,
|
|
2811
|
+
error: error2
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2549
2815
|
return this.removeAsExited(session);
|
|
2550
2816
|
}
|
|
2551
2817
|
/**
|
|
@@ -2568,6 +2834,9 @@ var SessionManager = class {
|
|
|
2568
2834
|
const output = await listTmuxSessions("#{session_name}");
|
|
2569
2835
|
liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
|
|
2570
2836
|
} catch (error2) {
|
|
2837
|
+
if (error2 instanceof TmuxNotFoundError) console.error(`
|
|
2838
|
+
${error2.message}
|
|
2839
|
+
`);
|
|
2571
2840
|
if (!(error2 instanceof TmuxNoServerRunningError)) return;
|
|
2572
2841
|
}
|
|
2573
2842
|
await Promise.all(
|
|
@@ -2690,7 +2959,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2690
2959
|
challenge: challenge.challenge,
|
|
2691
2960
|
daemonFingerprint,
|
|
2692
2961
|
hostname: hostname(),
|
|
2693
|
-
expiresAt: challenge.expiresAt
|
|
2962
|
+
expiresAt: challenge.expiresAt,
|
|
2963
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2964
|
+
daemonVersion: DAEMON_VERSION
|
|
2694
2965
|
});
|
|
2695
2966
|
socket.on("message", (raw) => {
|
|
2696
2967
|
let parsed;
|
|
@@ -2706,6 +2977,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2706
2977
|
broadcast,
|
|
2707
2978
|
getWorkspaceStatus,
|
|
2708
2979
|
getWorkspaceDiff,
|
|
2980
|
+
validateProjectPath,
|
|
2709
2981
|
hostname: () => hostname(),
|
|
2710
2982
|
daemonFingerprint,
|
|
2711
2983
|
pendingAuthorizations,
|
|
@@ -2741,10 +3013,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2741
3013
|
|
|
2742
3014
|
// src/index.ts
|
|
2743
3015
|
function loadRepoRootEnv() {
|
|
2744
|
-
const repoRoot =
|
|
2745
|
-
if (!existsSync(
|
|
2746
|
-
const envLocal =
|
|
2747
|
-
const envDefault =
|
|
3016
|
+
const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
3017
|
+
if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
3018
|
+
const envLocal = join6(repoRoot, ".env.local");
|
|
3019
|
+
const envDefault = join6(repoRoot, ".env");
|
|
2748
3020
|
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
2749
3021
|
}
|
|
2750
3022
|
loadRepoRootEnv();
|
|
@@ -2797,6 +3069,7 @@ async function start() {
|
|
|
2797
3069
|
ptyAttachments,
|
|
2798
3070
|
getWorkspaceStatus,
|
|
2799
3071
|
getWorkspaceDiff,
|
|
3072
|
+
validateProjectPath,
|
|
2800
3073
|
hostname: hostname2,
|
|
2801
3074
|
pendingAuthorizations,
|
|
2802
3075
|
presentPendingAuthorization
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|