baychat 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,11 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.socketPath = socketPath;
37
+ exports.unconfineRuntimeDir = unconfineRuntimeDir;
37
38
  exports.isNamedPipe = isNamedPipe;
38
39
  exports.pidFilePath = pidFilePath;
39
40
  exports.createFrameReader = createFrameReader;
40
41
  exports.writeFrame = writeFrame;
42
+ exports.probeSocketDetailed = probeSocketDetailed;
41
43
  exports.probeSocket = probeSocket;
44
+ exports.describeProbeFailure = describeProbeFailure;
42
45
  exports.unlinkStaleSocket = unlinkStaleSocket;
43
46
  const fs = __importStar(require("fs"));
44
47
  const net = __importStar(require("net"));
@@ -76,9 +79,34 @@ function socketPath() {
76
79
  }
77
80
  const runtimeDir = process.env.XDG_RUNTIME_DIR;
78
81
  if (runtimeDir)
79
- return path.join(runtimeDir, "baychat-relay.sock");
82
+ return path.join(unconfineRuntimeDir(runtimeDir), "baychat-relay.sock");
80
83
  return path.join((0, config_1.configDir)(), "relay.sock");
81
84
  }
85
+ /**
86
+ * The runtime dir the RELAY uses, as seen from inside a snap.
87
+ *
88
+ * A confined snap does not get the user's `XDG_RUNTIME_DIR`: snapd rewrites it
89
+ * to a private subdirectory, `/run/user/1000/snap.codex`. A snap-installed Codex
90
+ * therefore computes a socket path no relay has ever listened on, finds nothing,
91
+ * and reports "no relay is running" — while the real socket sits one level up,
92
+ * readable and connectable by the same uid. Verified on 2026-08-30: from inside
93
+ * `snap run --shell codex`, `/run/user/1000/baychat-relay.sock` connects fine;
94
+ * only the path was wrong.
95
+ *
96
+ * This is RECURRING_MISTAKES §24 again — "where it keeps its state" is not
97
+ * "where this copy reads" — and the same shape as `codexRoots`, which already
98
+ * has to know that a snap Codex keeps its rollouts somewhere else.
99
+ *
100
+ * Guarded on `SNAP` so an ordinary process is never second-guessed, and it only
101
+ * strips a segment that actually looks like snapd's, so a user whose runtime dir
102
+ * legitimately ends in something else is left alone.
103
+ */
104
+ function unconfineRuntimeDir(runtimeDir, env = process.env) {
105
+ if (!env.SNAP)
106
+ return runtimeDir;
107
+ const parent = path.dirname(runtimeDir);
108
+ return /^snap\.[^/\\]+$/.test(path.basename(runtimeDir)) ? parent : runtimeDir;
109
+ }
82
110
  /** A pipe name may not contain a backslash — it would read as another level of
83
111
  * the pipe namespace — and Windows usernames legally can (`DOMAIN\user` reaches
84
112
  * `os.userInfo()` that way on some setups). Everything outside the safe set is
@@ -129,31 +157,87 @@ function writeFrame(sock, frame) {
129
157
  sock.write(JSON.stringify(frame) + "\n");
130
158
  }
131
159
  /**
132
- * Is a daemon already listening?
160
+ * Probe the attach endpoint, KEEPING THE REASON IT FAILED.
161
+ *
162
+ * The boolean version of this threw the errno away, and every distinct failure
163
+ * arrived at the user as one sentence: "no relay is running". That sentence is
164
+ * true for exactly one of them. A sandboxed or differently-elevated shell that
165
+ * is REFUSED the pipe (EACCES/EPERM) is told the daemon does not exist, so the
166
+ * obvious next move is `relay start` — which spawns a SECOND daemon, and two
167
+ * daemons on one credential is a materially worse state than the one we were
168
+ * diagnosing. A wedged daemon that never accepts is likewise reported as absent.
133
169
  *
134
- * A socket file on disk proves nothing — a killed daemon leaves one behind. The
135
- * only honest test is to connect: ECONNREFUSED means the file is stale and safe
136
- * to unlink, which is what lets `relay start` recover from a hard kill without
137
- * a human deleting files.
170
+ * Measured on 2026-08-30: a Codex session on Windows reported "no relay is
171
+ * running" while a healthy relay (pid 16388) held four of its messages, and the
172
+ * CLI could not say which of these it had hit. Hence the reason code.
138
173
  */
139
- function probeSocket(sockPath, timeoutMs = 1_000) {
174
+ function probeSocketDetailed(sockPath, timeoutMs = 1_000) {
140
175
  return new Promise((resolve) => {
141
176
  // The existence check is a cheap way to skip a connect that cannot succeed —
142
177
  // but a named pipe has no directory entry, so on Windows it answers "false"
143
178
  // for a perfectly healthy daemon. Skipped there; the connect below is the
144
179
  // honest test on every platform anyway.
145
- if (!isNamedPipe(sockPath) && !fs.existsSync(sockPath))
146
- return resolve(false);
180
+ if (!isNamedPipe(sockPath)) {
181
+ // `fs.existsSync` cannot be used here: it answers false for BOTH "there is
182
+ // nothing at this path" and "you may not look at this path", which is the
183
+ // very conflation this function exists to end. `statSync` throws an errno
184
+ // that separates them.
185
+ try {
186
+ fs.statSync(sockPath);
187
+ }
188
+ catch (err) {
189
+ const code = err.code;
190
+ if (code === "ENOENT")
191
+ return resolve({ alive: false, reason: "absent" });
192
+ const reason = code === "EACCES" || code === "EPERM" ? "denied" : "error";
193
+ return resolve({ alive: false, reason, code, detail: err.message });
194
+ }
195
+ }
147
196
  const sock = net.createConnection(sockPath);
148
- const done = (alive) => {
197
+ const done = (result) => {
149
198
  sock.destroy();
150
- resolve(alive);
199
+ resolve(result);
151
200
  };
152
- sock.setTimeout(timeoutMs, () => done(false));
153
- sock.on("connect", () => done(true));
154
- sock.on("error", () => done(false));
201
+ sock.setTimeout(timeoutMs, () => done({ alive: false, reason: "timeout" }));
202
+ sock.on("connect", () => done({ alive: true }));
203
+ sock.on("error", (err) => {
204
+ const code = err.code;
205
+ // ENOENT/ECONNREFUSED genuinely mean nothing is listening. EACCES/EPERM
206
+ // mean something IS there and we were refused — the opposite diagnosis.
207
+ const reason = code === "ENOENT" || code === "ECONNREFUSED" ? "absent" : code === "EACCES" || code === "EPERM" ? "denied" : "error";
208
+ done({ alive: false, reason, code, detail: err.message });
209
+ });
155
210
  });
156
211
  }
212
+ function probeSocket(sockPath, timeoutMs = 1_000) {
213
+ return probeSocketDetailed(sockPath, timeoutMs).then((r) => r.alive);
214
+ }
215
+ /**
216
+ * Turn a failed probe into a sentence that names the ACTUAL obstacle.
217
+ *
218
+ * Only `absent` may suggest `relay start`; the others must not, because
219
+ * starting a second daemon is the wrong move for all of them.
220
+ */
221
+ function describeProbeFailure(sockPath, result) {
222
+ if (result.alive)
223
+ return "";
224
+ switch (result.reason) {
225
+ case "absent":
226
+ return "no relay is running — start one with `baychat relay start`";
227
+ case "denied":
228
+ return (`a relay endpoint exists at ${sockPath} but this process was refused it (${result.code}). ` +
229
+ `That is a permission boundary, not a missing daemon — do NOT run \`relay start\`, which would ` +
230
+ `create a second one. It usually means this shell runs under a different token than the relay ` +
231
+ `(an elevated or sandboxed shell), so run from a normal shell as the same user, or restart the ` +
232
+ `relay from this shell.`);
233
+ case "timeout":
234
+ return (`a relay is listening at ${sockPath} but did not accept a connection within ${1_000}ms — it is ` +
235
+ `probably wedged rather than absent. Check \`relay status\` from the shell that started it, and ` +
236
+ `stop that daemon before starting another.`);
237
+ default:
238
+ return `could not reach the relay at ${sockPath}: ${result.code ?? "unknown error"}${result.detail ? ` (${result.detail})` : ""}`;
239
+ }
240
+ }
157
241
  /** Remove a socket file we have already proven dead.
158
242
  *
159
243
  * A no-op for a named pipe, which has nothing on disk to remove: Windows tears
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.headlessSpawnEnv = headlessSpawnEnv;
37
+ const path = __importStar(require("path"));
38
+ /**
39
+ * The environment a headless turn is spawned with.
40
+ *
41
+ * WHY THIS EXISTS. A runtime installed by npm is a JavaScript file with a
42
+ * `#!/usr/bin/env node` shebang, so running it asks the SPAWNING process's PATH
43
+ * to find `node`. The relay daemon runs under systemd, whose PATH is a minimal
44
+ * system one — it does not contain nvm's node, or any node at all on a machine
45
+ * where node was never installed system-wide.
46
+ *
47
+ * Measured 2026-08-31 00:06: the headless rung became reachable for the first
48
+ * time and every wake died instantly with
49
+ * `/usr/bin/env: 'node': No such file or directory`, exit 127. The binary was
50
+ * correct and present; nothing could run it.
51
+ *
52
+ * This is `SessionTarget.runtimeBin` one layer down. That rule says the session
53
+ * identifies its own binary because only it can; this says the daemon must also
54
+ * hand that binary an environment it can actually start in — and the daemon is
55
+ * the only process that knows where its own node lives (`process.execPath`,
56
+ * which is exactly the interpreter a shebang is looking for).
57
+ *
58
+ * PREPENDED, not replaced: a runtime may legitimately need the rest of the
59
+ * inherited PATH to find its own helpers, and clobbering it would trade this
60
+ * failure for a subtler one.
61
+ */
62
+ function headlessSpawnEnv(env = process.env, execPath = process.execPath) {
63
+ const nodeDir = path.dirname(execPath);
64
+ const current = env.PATH ?? "";
65
+ const parts = current.split(path.delimiter).filter(Boolean);
66
+ if (parts.includes(nodeDir))
67
+ return env;
68
+ return { ...env, PATH: [nodeDir, ...parts].join(path.delimiter) };
69
+ }
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveRuntimeBinary = resolveRuntimeBinary;
37
+ exports.describeResolution = describeResolution;
38
+ exports.spawnPlanFor = spawnPlanFor;
39
+ exports.summarizeProbe = summarizeProbe;
40
+ exports.currentBinaryEnv = currentBinaryEnv;
41
+ exports.summarizeResolutionFailure = summarizeResolutionFailure;
42
+ const child_process_1 = require("child_process");
43
+ const fs = __importStar(require("fs"));
44
+ /**
45
+ * Resolve a runtime's executable, proving each candidate before accepting it.
46
+ *
47
+ * Precedence: explicit override (exclusively), then native PATH entries in
48
+ * order, then foreign-filesystem PATH entries. Foreign entries are demoted
49
+ * rather than excluded — a user genuinely running the Windows build through WSL
50
+ * interop must still be able to work — but they never beat a native binary that
51
+ * runs, because spawning across the interop boundary means a different
52
+ * filesystem and a different home than the session it is answering for.
53
+ */
54
+ function resolveRuntimeBinary(name, env) {
55
+ if (env.override !== undefined && env.override.trim() !== "") {
56
+ return resolveOverride(env.override, env);
57
+ }
58
+ const candidates = pathCandidates(name, env);
59
+ if (candidates.length === 0) {
60
+ return {
61
+ reason: `${name} not found on PATH (${env.pathEntries.length} entries searched)`,
62
+ rejected: [],
63
+ ok: false,
64
+ };
65
+ }
66
+ const rejected = [];
67
+ for (const candidate of candidates) {
68
+ const probed = env.probe(candidate.path);
69
+ if (probed.ok) {
70
+ return { ok: true, path: candidate.path, version: probed.version, source: candidate.source };
71
+ }
72
+ rejected.push({ path: candidate.path, reason: probed.detail });
73
+ }
74
+ return { ok: false, reason: `no working ${name} binary on this machine`, rejected };
75
+ }
76
+ /** Render a resolution for a human — one line on success, a full account on failure. */
77
+ function describeResolution(name, resolution) {
78
+ if (resolution.ok)
79
+ return [`${resolution.path} (${resolution.version})`];
80
+ const lines = [resolution.reason];
81
+ for (const candidate of resolution.rejected) {
82
+ lines.push(` tried ${candidate.path}`);
83
+ lines.push(` → ${candidate.reason}`);
84
+ }
85
+ return lines;
86
+ }
87
+ /**
88
+ * An override is considered alone, and its failure is the answer.
89
+ *
90
+ * Falling through to PATH would mean the CLI used a binary the user did not name
91
+ * while their explicit setting sat broken and unreported.
92
+ */
93
+ function resolveOverride(override, env) {
94
+ if (!env.isExecutable(override)) {
95
+ return {
96
+ ok: false,
97
+ reason: `configured override ${override} is not an executable file`,
98
+ rejected: [],
99
+ };
100
+ }
101
+ const probed = env.probe(override);
102
+ if (probed.ok)
103
+ return { ok: true, path: override, version: probed.version, source: "override" };
104
+ return {
105
+ ok: false,
106
+ reason: `configured override ${override} does not run`,
107
+ rejected: [{ path: override, reason: probed.detail }],
108
+ };
109
+ }
110
+ /**
111
+ * Every executable candidate on PATH, native entries first.
112
+ *
113
+ * Deduplicated by path: WSL routinely lists the same Windows npm directory
114
+ * twice, and probing spawns a process, so a duplicate entry would double the
115
+ * cost of resolution for no new information.
116
+ */
117
+ function pathCandidates(name, env) {
118
+ const native = [];
119
+ const foreign = [];
120
+ const seen = new Set();
121
+ for (const entry of env.pathEntries) {
122
+ if (entry.trim() === "")
123
+ continue;
124
+ const source = isForeignMount(entry, env.platform) ? "foreign-path" : "path";
125
+ for (const fileName of executableNames(name, env.platform)) {
126
+ const candidate = joinPath(entry, fileName, env.platform);
127
+ if (seen.has(candidate))
128
+ continue;
129
+ seen.add(candidate);
130
+ if (!env.isExecutable(candidate))
131
+ continue;
132
+ (source === "path" ? native : foreign).push({ path: candidate, source });
133
+ }
134
+ }
135
+ return [...native, ...foreign];
136
+ }
137
+ /**
138
+ * Whether a PATH entry lives on a Windows drive mounted into Linux.
139
+ *
140
+ * Deliberately narrow: only `/mnt/<single letter>/…` counts. `/mnt/data` is an
141
+ * ordinary Linux mount and demoting it would be wrong.
142
+ */
143
+ function isForeignMount(entry, platform) {
144
+ if (platform !== "linux")
145
+ return false;
146
+ return /^\/mnt\/[a-z]\//i.test(entry);
147
+ }
148
+ /**
149
+ * The file names a runtime may have on this platform.
150
+ *
151
+ * Windows resolves an unqualified command against PATHEXT; an npm-installed CLI
152
+ * is typically a `.cmd` shim, so omitting the extensions would find nothing at
153
+ * all there.
154
+ */
155
+ function executableNames(name, platform) {
156
+ if (platform !== "win32")
157
+ return [name];
158
+ return [`${name}.exe`, `${name}.cmd`, `${name}.bat`, name];
159
+ }
160
+ /**
161
+ * Join a directory and a file name for the TARGET platform.
162
+ *
163
+ * Node's `path.join` follows the host, not the injected platform, so using it
164
+ * would make every Windows case untestable from Linux CI.
165
+ */
166
+ function joinPath(dir, file, platform) {
167
+ const separator = platform === "win32" ? "\\" : "/";
168
+ const trimmed = dir.endsWith(separator) ? dir.slice(0, -separator.length) : dir;
169
+ return `${trimmed}${separator}${file}`;
170
+ }
171
+ /**
172
+ * @param candidate an executable path already resolved by `resolveRuntimeBinary`
173
+ * @returns file + prefix args to spawn it with, always `shell: false`
174
+ */
175
+ function spawnPlanFor(candidate, platform) {
176
+ const needsInterpreter = platform === "win32" && /\.(cmd|bat)$/i.test(candidate);
177
+ if (!needsInterpreter)
178
+ return { file: candidate, prefixArgs: [] };
179
+ return { file: "cmd.exe", prefixArgs: ["/d", "/s", "/c", candidate] };
180
+ }
181
+ /** How long a `--version` probe may run before it is treated as broken. */
182
+ const PROBE_TIMEOUT_MS = 5_000;
183
+ /** The most stderr worth quoting back to a user in a rejection reason. */
184
+ const PROBE_DETAIL_LIMIT = 200;
185
+ /**
186
+ * Turn a finished `--version` run into a probe result.
187
+ *
188
+ * Split out from the spawn so the interesting half — deciding what counts as
189
+ * working, and what to quote when it does not — is testable without a process.
190
+ */
191
+ function summarizeProbe(outcome) {
192
+ if (outcome.error)
193
+ return { ok: false, detail: outcome.error.message };
194
+ if (outcome.status !== 0) {
195
+ // Prefer stderr: a failing CLI puts its diagnosis there, and it is what
196
+ // names the actual fault (e.g. the missing optional dependency).
197
+ const said = firstMeaningfulLine(outcome.stderr) || firstMeaningfulLine(outcome.stdout);
198
+ const exited = `exits ${outcome.status ?? "on a signal"}`;
199
+ return { ok: false, detail: said ? `${exited}: ${said}` : exited };
200
+ }
201
+ const version = firstMeaningfulLine(outcome.stdout) || firstMeaningfulLine(outcome.stderr);
202
+ // Exit 0 is the contract. A binary that runs but prints nothing recognisable
203
+ // still runs, so it is accepted with an honest placeholder rather than
204
+ // rejected for a cosmetic reason.
205
+ return { ok: true, version: version || "version not reported" };
206
+ }
207
+ function firstMeaningfulLine(text) {
208
+ for (const line of text.split("\n")) {
209
+ const trimmed = line.trim();
210
+ if (trimmed !== "")
211
+ return trimmed.slice(0, PROBE_DETAIL_LIMIT);
212
+ }
213
+ return "";
214
+ }
215
+ /**
216
+ * Read the binary environment from this process.
217
+ *
218
+ * `shell: false` throughout: nothing here is ever concatenated into a command
219
+ * line, and a PATH entry is attacker-adjacent data on a shared machine.
220
+ */
221
+ function currentBinaryEnv(override) {
222
+ const delimiter = process.platform === "win32" ? ";" : ":";
223
+ return {
224
+ platform: process.platform,
225
+ pathEntries: (process.env.PATH ?? "").split(delimiter),
226
+ override,
227
+ isExecutable(candidate) {
228
+ try {
229
+ return fs.statSync(candidate).isFile();
230
+ }
231
+ catch {
232
+ // Absent, unreadable, or a dangling symlink — all mean "not a candidate".
233
+ // There is nothing to log: most PATH entries do not contain most binaries.
234
+ return false;
235
+ }
236
+ },
237
+ probe(candidate) {
238
+ const plan = spawnPlanFor(candidate, process.platform);
239
+ const run = (0, child_process_1.spawnSync)(plan.file, [...plan.prefixArgs, "--version"], {
240
+ timeout: PROBE_TIMEOUT_MS,
241
+ encoding: "utf8",
242
+ shell: false,
243
+ });
244
+ return summarizeProbe({
245
+ status: run.status,
246
+ stdout: run.stdout ?? "",
247
+ stderr: run.stderr ?? "",
248
+ error: run.error,
249
+ });
250
+ },
251
+ };
252
+ }
253
+ /**
254
+ * One line naming the failure and the best evidence for it.
255
+ *
256
+ * `describeResolution` is shaped for a report a human is reading deliberately;
257
+ * this is for a place that has room for a sentence — a `DELIVERY PENDING`
258
+ * reason in `relay status`, a log line — where a multi-line block would wrap
259
+ * into noise. It names the first rejected candidate because that is the one
260
+ * PATH would have chosen, and therefore the one the user believes is in use.
261
+ */
262
+ function summarizeResolutionFailure(resolution) {
263
+ if (resolution.ok)
264
+ return "";
265
+ const first = resolution.rejected[0];
266
+ if (!first)
267
+ return resolution.reason;
268
+ return `${resolution.reason} (tried ${first.path}: ${first.reason})`;
269
+ }