baychat 0.22.0 → 0.23.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.
- package/README.md +22 -0
- package/dist/connect-acp.js +165 -0
- package/dist/connect-cursor.js +65 -0
- package/dist/connect-dsh.js +17 -137
- package/dist/connect.js +10 -0
- package/dist/doctor.js +13 -14
- package/dist/index.js +5 -0
- package/dist/relay/acp/agents.js +76 -31
- package/dist/relay/acp/client.js +13 -1
- package/dist/relay/acp/commands.js +3 -3
- package/dist/relay/acp/cursor.js +271 -0
- package/dist/relay/acp/daemon-glue.js +12 -15
- package/dist/relay/acp/modes.js +5 -5
- package/dist/relay/acp/permissions.js +65 -3
- package/dist/relay/acp/policy.js +7 -6
- package/dist/relay/acp/prompt.js +2 -3
- package/dist/relay/acp/runner.js +65 -12
- package/dist/relay/acp/types.js +3 -0
- package/dist/relay/daemon.js +1 -1
- package/dist/runtime-binary.js +17 -0
- package/package.json +4 -2
package/dist/relay/acp/agents.js
CHANGED
|
@@ -35,15 +35,22 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.ACP_AGENTS = exports.DSH_LOCAL_TOOLS = void 0;
|
|
37
37
|
exports.acpAgent = acpAgent;
|
|
38
|
+
exports.describeAgentMode = describeAgentMode;
|
|
39
|
+
exports.otherAgentRefusal = otherAgentRefusal;
|
|
40
|
+
exports.binaryIdentified = binaryIdentified;
|
|
41
|
+
exports.unidentifiedBinary = unidentifiedBinary;
|
|
42
|
+
exports.connectCommandFor = connectCommandFor;
|
|
38
43
|
exports.matchesTestedVersion = matchesTestedVersion;
|
|
39
44
|
exports.modeAllowedHere = modeAllowedHere;
|
|
40
45
|
exports.acpPatchDir = acpPatchDir;
|
|
41
46
|
exports.patchPath = patchPath;
|
|
42
|
-
exports.
|
|
47
|
+
exports.writeModePatch = writeModePatch;
|
|
43
48
|
const fs = __importStar(require("fs"));
|
|
44
49
|
const path = __importStar(require("path"));
|
|
45
50
|
const config_1 = require("../../config");
|
|
46
51
|
const modes_1 = require("./modes");
|
|
52
|
+
const cursor_1 = require("./cursor");
|
|
53
|
+
const permissions_1 = require("./permissions");
|
|
47
54
|
// Every tool plugin in dsh's `acp` profile (`--profile acp --dump-config`, 0.1.5-rc.2) [run].
|
|
48
55
|
// MCP clients are mounted per ACP session by dsh-acp itself and are untouched by these.
|
|
49
56
|
// Confirmed against docs/planning/2026-09-19-acp-research/dump-acp-default.yml — the tool-* ids
|
|
@@ -62,10 +69,11 @@ const dsh = {
|
|
|
62
69
|
chat: disable(exports.DSH_LOCAL_TOOLS, "chat mode: no local tools at all."),
|
|
63
70
|
read: disable(exports.DSH_LOCAL_TOOLS.filter((id) => id !== "tool-fs" && id !== "tool-fs-search"), "read mode: file read and search only — no shell, no subagents, no web."),
|
|
64
71
|
},
|
|
65
|
-
|
|
72
|
+
patchFile: (mode) => `dsh-${mode}.yml`, // shared by every dsh session: its patches name no session
|
|
73
|
+
launch(mode, patchDir, session) {
|
|
66
74
|
const args = ["--profile", "acp"];
|
|
67
75
|
if (this.patches[mode])
|
|
68
|
-
args.push("--patch", patchPath(this, mode, patchDir));
|
|
76
|
+
args.push("--patch", patchPath(this, mode, session, patchDir));
|
|
69
77
|
// `danger-full-access` is deliberately unreachable from here: no mode maps to it.
|
|
70
78
|
return { args, env: { DSH_PERMISSION_MODE: mode === "full" ? "workspace-write" : "read-only" } };
|
|
71
79
|
},
|
|
@@ -84,16 +92,48 @@ const dsh = {
|
|
|
84
92
|
return "session";
|
|
85
93
|
return undefined;
|
|
86
94
|
},
|
|
95
|
+
decidePermission: permissions_1.decideDshPermission,
|
|
87
96
|
keyHint: "DeepSeek has no API key on that computer. There, run `dsh web` and add the key on the Models page.",
|
|
88
97
|
installHint: "Install it with `npm install -g @deepseek-ai/dsh`, then run `baychat connect dsh` again.",
|
|
98
|
+
connectCommand: "baychat connect dsh",
|
|
99
|
+
credentialSeen: (home) => Boolean(process.env.DEEPSEEK_API_KEY) ||
|
|
100
|
+
fs.existsSync(path.join(process.env.DSH_HOME ?? path.join(home, ".dsh"), ".credentials.yaml")),
|
|
101
|
+
// Experimental and not security-audited (R11/R17).
|
|
102
|
+
safetyNote: "DeepSeek recommends a disposable VM or container",
|
|
89
103
|
confidence: "run",
|
|
90
104
|
checked: "2026-09-19",
|
|
91
105
|
testedVersion: "0.1.5-rc.2",
|
|
92
106
|
};
|
|
93
|
-
exports.ACP_AGENTS = [dsh];
|
|
107
|
+
exports.ACP_AGENTS = [dsh, cursor_1.cursor];
|
|
94
108
|
function acpAgent(id) {
|
|
95
109
|
return exports.ACP_AGENTS.find((row) => row.id === id);
|
|
96
110
|
}
|
|
111
|
+
/** How `mode` is described for a session of this agent: its own words if it has them, else the shared ones. */
|
|
112
|
+
function describeAgentMode(row, mode) {
|
|
113
|
+
return row?.modeDescriptions?.[mode] ?? (0, modes_1.describeMode)(mode);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The refusal for giving `row` a session name another agent's relay-run session already holds,
|
|
117
|
+
* or undefined when the name is free or already this agent's. A session id, a binary and a mode
|
|
118
|
+
* belong to the agent that made them; handing them to a different agent is never a re-register.
|
|
119
|
+
*/
|
|
120
|
+
function otherAgentRefusal(row, session, heldBy) {
|
|
121
|
+
if (heldBy === undefined || heldBy === row.id)
|
|
122
|
+
return undefined;
|
|
123
|
+
return `"${session}" is already run by the relay for ${acpAgent(heldBy)?.label ?? heldBy}. Choose another name (${row.connectCommand} --session <name>).`;
|
|
124
|
+
}
|
|
125
|
+
/** Is `version` (a binary's `--version` first line) this agent's? True for a row with no pattern. */
|
|
126
|
+
function binaryIdentified(row, version) {
|
|
127
|
+
return row.versionPattern?.test(version) ?? true;
|
|
128
|
+
}
|
|
129
|
+
/** Said when the binary found under the agent's name is not the agent. Names the fix. */
|
|
130
|
+
function unidentifiedBinary(row, binaryPath, version) {
|
|
131
|
+
return `${binaryPath} does not look like ${row.label}'s CLI (\`--version\` said "${version}"). ${row.installHint}`;
|
|
132
|
+
}
|
|
133
|
+
/** The setup command to name for `agent` — a neutral one when this relay does not know it. */
|
|
134
|
+
function connectCommandFor(agent) {
|
|
135
|
+
return (agent !== undefined ? acpAgent(agent)?.connectCommand : undefined) ?? "baychat connect <agent>";
|
|
136
|
+
}
|
|
97
137
|
/** A version-string character: digits, letters, dot, plus, hyphen — the semver/rc alphabet. */
|
|
98
138
|
const VERSION_TOKEN_CHAR = /[0-9A-Za-z.+-]/;
|
|
99
139
|
/**
|
|
@@ -132,7 +172,7 @@ function matchesTestedVersion(version, tested) {
|
|
|
132
172
|
*/
|
|
133
173
|
function modeAllowedHere(row, mode, maxMode, platform) {
|
|
134
174
|
if ((0, modes_1.modeRank)(mode) > (0, modes_1.modeRank)(maxMode)) {
|
|
135
|
-
return { ok: false, reason: `${mode} is above the highest mode allowed for that folder (${maxMode}). That ceiling is set on the computer, never from a chat: run
|
|
175
|
+
return { ok: false, reason: `${mode} is above the highest mode allowed for that folder (${maxMode}). That ceiling is set on the computer, never from a chat: run \`${row.connectCommand}\` there to change it.` };
|
|
136
176
|
}
|
|
137
177
|
if (!row.modesFor(platform).includes(mode)) {
|
|
138
178
|
return { ok: false, reason: `${mode} is not offered on that computer's operating system, because ${row.label}'s sandbox cannot fully enforce it there.` };
|
|
@@ -143,36 +183,41 @@ function modeAllowedHere(row, mode, maxMode, platform) {
|
|
|
143
183
|
function acpPatchDir() {
|
|
144
184
|
return path.join((0, config_1.configDir)(), "acp");
|
|
145
185
|
}
|
|
146
|
-
function patchPath(row, mode, dir) {
|
|
147
|
-
return path.join(dir,
|
|
186
|
+
function patchPath(row, mode, session, dir) {
|
|
187
|
+
return path.join(dir, row.patchFile(mode, session));
|
|
148
188
|
}
|
|
149
189
|
/**
|
|
150
|
-
* (Re)write
|
|
151
|
-
* before every turn
|
|
190
|
+
* (Re)write the overlay a turn of `session` in `mode` reads, if the agent needs one there.
|
|
191
|
+
* Idempotent; called by `connect` and before every turn, so an upgrade that changes an overlay,
|
|
192
|
+
* or a `/mode-*` that changes which one applies, takes effect at the next turn.
|
|
152
193
|
*
|
|
153
|
-
* NEVER TRUNCATE IN PLACE.
|
|
154
|
-
* while another's freshly spawned agent is reading the same
|
|
155
|
-
* an agent with every local tool.
|
|
156
|
-
* written beside it and renamed over it, so a reader sees
|
|
194
|
+
* NEVER TRUNCATE IN PLACE. dsh's overlays are shared by its sessions, which drain concurrently,
|
|
195
|
+
* so one session's rewrite can land while another's freshly spawned agent is reading the same
|
|
196
|
+
* file — and an empty `chat` patch is an agent with every local tool. An overlay that is already
|
|
197
|
+
* right is left alone; one that is not is written beside it and renamed over it, so a reader sees
|
|
198
|
+
* the old file or the new, never half.
|
|
157
199
|
*/
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
200
|
+
function writeModePatch(row, mode, session, dir) {
|
|
201
|
+
const content = row.patches[mode];
|
|
202
|
+
if (content === undefined)
|
|
203
|
+
return;
|
|
204
|
+
const file = patchPath(row, mode, session, dir);
|
|
205
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
206
|
+
const onDisk = readOrUndefined(file);
|
|
207
|
+
const isCurrent = row.patchIsCurrent ?? ((current, wanted) => current === wanted);
|
|
208
|
+
if (onDisk !== undefined && isCurrent(onDisk, content)) {
|
|
209
|
+
fs.chmodSync(file, 0o600); // permissions only — the content and the inode stay as they are
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
213
|
+
try {
|
|
214
|
+
fs.writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
|
|
215
|
+
fs.chmodSync(tmp, 0o600); // `mode` is filtered by the umask
|
|
216
|
+
fs.renameSync(tmp, file);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
fs.rmSync(tmp, { force: true });
|
|
220
|
+
throw err;
|
|
176
221
|
}
|
|
177
222
|
}
|
|
178
223
|
/** A read error (absent, unreadable) means "write it". */
|
package/dist/relay/acp/client.js
CHANGED
|
@@ -143,7 +143,9 @@ async function runAcpTurn(req, deps) {
|
|
|
143
143
|
rawInput: isRecord(update.rawInput) ? update.rawInput : (known?.rawInput ?? {}),
|
|
144
144
|
};
|
|
145
145
|
calls.set(id, call);
|
|
146
|
-
|
|
146
|
+
// Cursor announces a call bare ("MCP: tool", no input) and names it in a later update, so
|
|
147
|
+
// an update that carries a title or an input is news; a status-only update is not.
|
|
148
|
+
if (known && typeof update.title !== "string" && !isRecord(update.rawInput))
|
|
147
149
|
return;
|
|
148
150
|
try {
|
|
149
151
|
req.onToolCall?.(call);
|
|
@@ -237,6 +239,16 @@ async function runAcpTurn(req, deps) {
|
|
|
237
239
|
sessionId = created.sessionId;
|
|
238
240
|
fresh = true;
|
|
239
241
|
}
|
|
242
|
+
if (req.sessionModeId) {
|
|
243
|
+
// Never prompt in a mode we did not choose: an agent left in another mode (Cursor's
|
|
244
|
+
// `ask` refuses BayChat's own write tools) would fail the room in a way nobody sees.
|
|
245
|
+
try {
|
|
246
|
+
await conn.setSessionMode({ sessionId, modeId: req.sessionModeId });
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
throw new TurnFailure("session", `the agent would not switch to its "${req.sessionModeId}" mode: ${errorText(err)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
240
252
|
phase = "turn";
|
|
241
253
|
arm(turnTimeout, "timeout", `the turn did not finish within ${Math.round(turnTimeout / 1000)}s`);
|
|
242
254
|
const text = fresh && req.freshPreamble ? `${req.freshPreamble}\n\n${req.prompt}` : req.prompt;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runAcpSessionCommand = runAcpSessionCommand;
|
|
4
4
|
const agents_1 = require("./agents");
|
|
5
|
-
const modes_1 = require("./modes");
|
|
6
5
|
/**
|
|
7
6
|
* The session commands that mean something different — or only mean something — for a session
|
|
8
7
|
* the relay runs over ACP. Kept out of `daemon.ts`, which only calls in.
|
|
@@ -26,7 +25,8 @@ async function runAcpSessionCommand(acp, session, target, message, command, say)
|
|
|
26
25
|
}
|
|
27
26
|
// `setMode` holds the one rule (rule-file ceiling, then platform) — not re-implemented here.
|
|
28
27
|
const result = acp.setMode(session, command.mode);
|
|
29
|
-
|
|
28
|
+
const row = target.acp ? (0, agents_1.acpAgent)(target.acp.agent) : undefined;
|
|
29
|
+
await say(result.ok ? `"${session}" is now in ${(0, agents_1.describeAgentMode)(row, result.mode)}` : result.reason);
|
|
30
30
|
return true;
|
|
31
31
|
}
|
|
32
32
|
if (command.id === "stop") {
|
|
@@ -46,7 +46,7 @@ async function runAcpSessionCommand(acp, session, target, message, command, say)
|
|
|
46
46
|
await say([
|
|
47
47
|
`Session "${session}" — run by the relay, no terminal`,
|
|
48
48
|
` agent: ${row?.label ?? target.acp.agent}${row ? ` (tested with ${row.testedVersion})` : ""}`,
|
|
49
|
-
` mode: ${(0,
|
|
49
|
+
` mode: ${(0, agents_1.describeAgentMode)(row, target.acp.mode)}`,
|
|
50
50
|
` folder: ${target.acp.cwd}`,
|
|
51
51
|
` now: ${s.running ? "working on a turn" : "idle"}, ${s.queued} message(s) waiting`,
|
|
52
52
|
` memory: ${target.acp.sessionId ? "kept between messages" : "fresh on the next message"}`,
|
|
@@ -0,0 +1,271 @@
|
|
|
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.cursor = void 0;
|
|
37
|
+
exports.cursorSessionDir = cursorSessionDir;
|
|
38
|
+
exports.cursorOverrideRefusal = cursorOverrideRefusal;
|
|
39
|
+
exports.watchCursorOverrides = watchCursorOverrides;
|
|
40
|
+
const crypto_1 = require("crypto");
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const modes_1 = require("./modes");
|
|
44
|
+
const permissions_1 = require("./permissions");
|
|
45
|
+
const types_1 = require("./types");
|
|
46
|
+
// Cursor's CLI (`agent acp`) [run, 19 Sep 2026, CLI 2026.09.18-9a7762b — the spikes and Karmen's
|
|
47
|
+
// decision are in docs/superpowers/plans/2026-09-19-acp-cursor.md]. Its ACP modes are not ours:
|
|
48
|
+
// its own `ask` still READ files inside and outside the folder, so every mode runs Cursor as its
|
|
49
|
+
// `agent` and takes tools away with a config deny list instead. Deny beats allow within that one
|
|
50
|
+
// file, and BayChat's allowlist is empty, so every shell command raises a permission request.
|
|
51
|
+
//
|
|
52
|
+
// Cursor reads that config from `CURSOR_CONFIG_DIR` (undocumented; found in the CLI bundle and
|
|
53
|
+
// RUN). The same folder holds Cursor's ACP chat store (`acp-sessions/`) [run, Spike 3], so it is
|
|
54
|
+
// one folder per SESSION — never per mode (a `/mode-*` would lose the conversation) and never
|
|
55
|
+
// per turn (every turn would). Its `cli-config.json` is rewritten for the turn's mode before each
|
|
56
|
+
// turn. Nothing is written into the person's project, and the "Allow always" rules of their own
|
|
57
|
+
// Cursor never reach a relay session. The sandbox is off because, run on Linux, it confined
|
|
58
|
+
// nothing — the deny list and the permission rule are the whole boundary.
|
|
59
|
+
const CURSOR_DENY = {
|
|
60
|
+
chat: ["Read(**)", "Read(/**)", "Write(**)", "Write(/**)", "Shell(*)", "WebFetch(*)"],
|
|
61
|
+
read: ["Write(**)", "Write(/**)", "Shell(*)", "WebFetch(*)"],
|
|
62
|
+
// `.cursor/**`: its edit tool must not plant an override file (below) [run, Spike 3: refused
|
|
63
|
+
// with "Write permission denied"; the shell fallback asked, and the permission rule refuses it].
|
|
64
|
+
ask: ["Write(.cursor/**)", "Write(**/.cursor/**)", "WebFetch(*)"],
|
|
65
|
+
};
|
|
66
|
+
const cursorConfig = (deny) => `${JSON.stringify({ version: 1, approvalMode: "allowlist", sandbox: { mode: "disabled" }, permissions: { allow: [], deny } }, null, 2)}\n`;
|
|
67
|
+
/** Characters a session name keeps as they are in its folder name. Lower case only: see below. */
|
|
68
|
+
const KEPT = /^[a-z0-9_-]$/;
|
|
69
|
+
/**
|
|
70
|
+
* The folder name for a session's Cursor config. INJECTIVE and path-safe on every filesystem:
|
|
71
|
+
* every UTF-8 byte outside `[a-z0-9_-]` — upper case included, because macOS and Windows fold
|
|
72
|
+
* case, and `%` itself — becomes `%xx`, so two names can never share a folder and no name can
|
|
73
|
+
* spell `..`, a separator or a reserved device name (the `s-` prefix sees to `con`, `nul`, …).
|
|
74
|
+
*/
|
|
75
|
+
function cursorSessionDir(session) {
|
|
76
|
+
let encoded = "s-";
|
|
77
|
+
for (const byte of Buffer.from(session, "utf8")) {
|
|
78
|
+
const char = String.fromCharCode(byte);
|
|
79
|
+
encoded += KEPT.test(char) ? char : `%${byte.toString(16).padStart(2, "0")}`;
|
|
80
|
+
}
|
|
81
|
+
// A folder name longer than the filesystems allow would fail late and obscurely; say why now.
|
|
82
|
+
if (encoded.length > 200)
|
|
83
|
+
throw new Error(`The session name "${session.slice(0, 40)}…" is too long for its Cursor config folder. Choose a shorter one.`);
|
|
84
|
+
return encoded;
|
|
85
|
+
}
|
|
86
|
+
exports.cursor = {
|
|
87
|
+
id: "cursor",
|
|
88
|
+
label: "Cursor",
|
|
89
|
+
bin: "agent",
|
|
90
|
+
patches: Object.fromEntries(Object.entries(CURSOR_DENY).map(([mode, deny]) => [mode, cursorConfig(deny)])),
|
|
91
|
+
patchFile: (_mode, session) => path.join("cursor", cursorSessionDir(session), "cli-config.json"),
|
|
92
|
+
// Cursor rewrites this file itself (it adds `model`, `privacyCache`, …) and keeps `permissions`
|
|
93
|
+
// exactly [run]. Comparing bytes would rewrite it before every turn; comparing nothing would
|
|
94
|
+
// trust whatever is there. So: the fields BayChat's safety rests on, and nothing else.
|
|
95
|
+
patchIsCurrent: (onDisk, wanted) => {
|
|
96
|
+
const current = cursorSecurityFields(onDisk);
|
|
97
|
+
return current !== undefined && current === cursorSecurityFields(wanted);
|
|
98
|
+
},
|
|
99
|
+
launch(mode, patchDir, session) {
|
|
100
|
+
// No mode maps to `full`: `modesFor` never offers it, and a launch that reached here anyway
|
|
101
|
+
// must not quietly run with some other mode's config.
|
|
102
|
+
if (!this.patches[mode])
|
|
103
|
+
throw new Error(`Cursor has no ${mode} mode in BayChat`);
|
|
104
|
+
return { args: ["acp"], env: { CURSOR_CONFIG_DIR: path.join(patchDir, path.dirname(this.patchFile(mode, session))) } };
|
|
105
|
+
},
|
|
106
|
+
// Its `ask` mode refuses BayChat's own write tools ("I'm in Ask mode") [run], so an agent that
|
|
107
|
+
// must answer the room runs as `agent`, with the deny list doing the limiting.
|
|
108
|
+
acpModeId: "agent",
|
|
109
|
+
// A deny list does not rest on an OS sandbox, so `chat` and `read` hold everywhere. `ask` was
|
|
110
|
+
// only RUN on Linux, and a row ships only what has been run.
|
|
111
|
+
modesFor: (platform) => (platform === "linux" ? ["chat", "read", "ask"] : ["chat", "read"]),
|
|
112
|
+
// Only these two were named in the spike; Cursor's texts for no plan/quota, a busy session and
|
|
113
|
+
// a bad folder were not collected, so they stay unclassified (a generic line in the room).
|
|
114
|
+
classifyError(message) {
|
|
115
|
+
if (/not logged in|authenticat|login required/i.test(message))
|
|
116
|
+
return "missing-key";
|
|
117
|
+
if (/mcp.*baychat|baychat.*(unreachable|failed)/i.test(message))
|
|
118
|
+
return "baychat-unreachable";
|
|
119
|
+
return undefined;
|
|
120
|
+
},
|
|
121
|
+
// Cursor names the server of every MCP call it asks about, and the rule approves by that name.
|
|
122
|
+
// A project's own `.cursor/mcp.json` could define a server called "baychat" (in Spike 3 it was
|
|
123
|
+
// not started — but that is not a guarantee), so the name is random and new every turn.
|
|
124
|
+
mcpServerName: () => `${types_1.BAYCHAT_MCP_SERVER}-${(0, crypto_1.randomBytes)(4).toString("hex")}`,
|
|
125
|
+
toolNote: (server) => `Your BayChat tools this turn are on the MCP server "${server}" (shown as "${server}: send_message" and so on). Wherever your instructions say mcp__baychat__<tool>, use that server's <tool>. A BayChat server under any other name is not this turn's, and its calls are refused.`,
|
|
126
|
+
turnRefusal: cursorOverrideRefusal,
|
|
127
|
+
guardTurn: (cwd, onBreach) => watchCursorOverrides(cwd, onBreach),
|
|
128
|
+
decidePermission: permissions_1.decideCursorPermission,
|
|
129
|
+
keyHint: "Cursor is not logged in on that computer. There, run `agent login`.",
|
|
130
|
+
installHint: "Install it with `curl https://cursor.com/install -fsS | bash`, then run `baychat connect cursor-cli` again.",
|
|
131
|
+
// `agent` is a name any program could have. Cursor's CLI answers `--version` with a dated build
|
|
132
|
+
// id alone ("2026.09.18-9a7762b" [run]), so anything else is not started.
|
|
133
|
+
versionPattern: /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]{7,}$/,
|
|
134
|
+
// Not `connect cursor`: that word already configures the Cursor EDITOR's MCP file, and the
|
|
135
|
+
// relay-run CLI must not take it over (it would silently stop doing what it does today).
|
|
136
|
+
connectCommand: "baychat connect cursor-cli",
|
|
137
|
+
// Edits cannot be ASKED in Cursor, only allowed or denied [run], so its `ask` is not dsh's.
|
|
138
|
+
modeDescriptions: {
|
|
139
|
+
ask: `ask — it edits files inside its folder without asking; edits outside it are refused; every command asks you on your phone first. ${modes_1.READS_ANYTHING}`,
|
|
140
|
+
},
|
|
141
|
+
confidence: "run",
|
|
142
|
+
checked: "2026-09-19",
|
|
143
|
+
testedVersion: "2026.09.18-9a7762b",
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Project files under `.cursor/` that Cursor reads beside BayChat's config, and why each is refused.
|
|
147
|
+
*
|
|
148
|
+
* `cli.json` [run, Spike 3]: a project `.cursor/cli.json` allowing `Shell(*)` OVERRODE the relay's
|
|
149
|
+
* config — with BayChat's chat config (deny read, write, shell, fetch) Cursor ran a command with
|
|
150
|
+
* no permission request. "Deny beats allow" holds only inside one file. Cursor looks in the
|
|
151
|
+
* folder and its parents, so every ancestor is checked. `hooks.json`: hooks run commands on
|
|
152
|
+
* Cursor's events; not run against the relay, refused to be safe.
|
|
153
|
+
*/
|
|
154
|
+
const CURSOR_OVERRIDES = [
|
|
155
|
+
{ name: "cli.json", why: "Cursor lets that project file override BayChat's limits for this session" },
|
|
156
|
+
{
|
|
157
|
+
name: "hooks.json",
|
|
158
|
+
why: "Cursor hooks run commands on its events, which could override BayChat's limits (this has not been tested with the relay, so it is refused to be safe)",
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
/**
|
|
162
|
+
* Why Cursor must not start in `cwd`: one of its override files exists there or in any folder
|
|
163
|
+
* above it, up to the filesystem root — or could not be checked. Undefined when none is there.
|
|
164
|
+
*/
|
|
165
|
+
function cursorOverrideRefusal(cwd) {
|
|
166
|
+
const found = findCursorOverride(cwd, presence);
|
|
167
|
+
return found && `Cursor was not started: ${found}`;
|
|
168
|
+
}
|
|
169
|
+
/** What was found, as a sentence that names the file and the way out — or undefined. */
|
|
170
|
+
function findCursorOverride(cwd, look) {
|
|
171
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
172
|
+
for (const { name, why } of CURSOR_OVERRIDES) {
|
|
173
|
+
const file = path.join(dir, ".cursor", name);
|
|
174
|
+
const seen = look(file);
|
|
175
|
+
if (seen === "present")
|
|
176
|
+
return `${file} exists, and ${why}. Remove or rename it to use this session.`;
|
|
177
|
+
if (seen !== "absent")
|
|
178
|
+
return `could not check whether ${file} exists (${seen}). A file there could override BayChat's limits, so Cursor is not run until it can be checked.`;
|
|
179
|
+
}
|
|
180
|
+
if (path.dirname(dir) === dir)
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** How often a turn's guard looks, when no filesystem event has told it to look sooner. */
|
|
185
|
+
const GUARD_POLL_MS = 250;
|
|
186
|
+
/**
|
|
187
|
+
* For the length of ONE turn, look for what `cursorOverrideRefusal` refuses — the refusal only
|
|
188
|
+
* sees the disk as it was before the turn, and Cursor could write the file during it. Calls
|
|
189
|
+
* `onBreach` once with what it found, then stops looking. Returns how to stop.
|
|
190
|
+
*
|
|
191
|
+
* A poll is the guarantee; filesystem events on the session folder and its `.cursor` (when
|
|
192
|
+
* there) only make it sooner. Nothing is created to be watched: the folder is the person's.
|
|
193
|
+
*/
|
|
194
|
+
function watchCursorOverrides(cwd, onBreach, deps = REAL_WATCH) {
|
|
195
|
+
let done = false;
|
|
196
|
+
const unwatch = [];
|
|
197
|
+
let watchingDotCursor = false;
|
|
198
|
+
const stop = () => {
|
|
199
|
+
done = true;
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
for (const close of unwatch.splice(0))
|
|
202
|
+
close();
|
|
203
|
+
};
|
|
204
|
+
const check = () => {
|
|
205
|
+
if (done)
|
|
206
|
+
return;
|
|
207
|
+
if (!watchingDotCursor && deps.look(path.join(cwd, ".cursor")) === "present") {
|
|
208
|
+
watchingDotCursor = true;
|
|
209
|
+
unwatch.push(deps.watch(path.join(cwd, ".cursor"), check));
|
|
210
|
+
}
|
|
211
|
+
const found = findCursorOverride(cwd, deps.look);
|
|
212
|
+
if (!found)
|
|
213
|
+
return;
|
|
214
|
+
stop();
|
|
215
|
+
onBreach(found);
|
|
216
|
+
};
|
|
217
|
+
const timer = setInterval(check, deps.pollMs);
|
|
218
|
+
timer.unref?.();
|
|
219
|
+
unwatch.push(deps.watch(cwd, check));
|
|
220
|
+
check();
|
|
221
|
+
return stop;
|
|
222
|
+
}
|
|
223
|
+
const REAL_WATCH = {
|
|
224
|
+
look: presence,
|
|
225
|
+
watch(dir, onEvent) {
|
|
226
|
+
try {
|
|
227
|
+
const watcher = fs.watch(dir, { persistent: false }, onEvent);
|
|
228
|
+
watcher.on("error", () => watcher.close()); // the poll carries on without it
|
|
229
|
+
return () => watcher.close();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return () => undefined; // unwatchable here (some filesystems): the poll is the guarantee
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
pollMs: GUARD_POLL_MS,
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* Is there anything at `file`? `lstat`, so a symlink counts even when it leads nowhere. Only
|
|
239
|
+
* "no such file" is absence: a folder we may not look into could hold the file (RECURRING_MISTAKES
|
|
240
|
+
* §26), so any other error is returned as its code.
|
|
241
|
+
*/
|
|
242
|
+
function presence(file) {
|
|
243
|
+
try {
|
|
244
|
+
fs.lstatSync(file);
|
|
245
|
+
return "present";
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const code = err.code;
|
|
249
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "absent" : (code ?? "unknown error");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** The security fields of a Cursor config, in one canonical text — or undefined if unparseable. */
|
|
253
|
+
function cursorSecurityFields(text) {
|
|
254
|
+
let config;
|
|
255
|
+
try {
|
|
256
|
+
config = JSON.parse(text);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return undefined; // unparseable is "not current": it is rewritten
|
|
260
|
+
}
|
|
261
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
262
|
+
return undefined;
|
|
263
|
+
const { approvalMode, sandbox, permissions } = config;
|
|
264
|
+
return canonicalJson({ approvalMode, sandbox, permissions });
|
|
265
|
+
}
|
|
266
|
+
/** JSON with every object's keys sorted, so key order never reads as a difference. */
|
|
267
|
+
function canonicalJson(value) {
|
|
268
|
+
return JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v)
|
|
269
|
+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
270
|
+
: v);
|
|
271
|
+
}
|
|
@@ -49,7 +49,7 @@ const presence_1 = require("./presence");
|
|
|
49
49
|
/**
|
|
50
50
|
* The daemon's ACP upkeep, kept out of `daemon.ts` so that file only calls in.
|
|
51
51
|
*
|
|
52
|
-
* Everything here is reached from a thin call site in the daemon: start-up (
|
|
52
|
+
* Everything here is reached from a thin call site in the daemon: start-up (the
|
|
53
53
|
* keep-alive), the live-session list (a lapsed relay-owned session), and the `acp-register`
|
|
54
54
|
* socket frame. None of it runs for a session without `acp`.
|
|
55
55
|
*/
|
|
@@ -75,20 +75,12 @@ async function keepAcpSessionsAlive(deps) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* Daemon start:
|
|
79
|
-
*
|
|
80
|
-
* the timer for `stop()` to clear; it is
|
|
78
|
+
* Daemon start: refresh relay-owned sessions now, and again every 12 h — a quiet relay-owned
|
|
79
|
+
* session would otherwise go deaf overnight. (Mode overlays are written by the runner before
|
|
80
|
+
* every turn, for that turn's session and mode.) Returns the timer for `stop()` to clear; it is
|
|
81
|
+
* unref'd so it never holds the process open.
|
|
81
82
|
*/
|
|
82
|
-
function startAcpUpkeep(keepAlive
|
|
83
|
-
for (const row of agents_1.ACP_AGENTS) {
|
|
84
|
-
try {
|
|
85
|
-
(0, agents_1.writeModePatches)(row, (0, agents_1.acpPatchDir)());
|
|
86
|
-
}
|
|
87
|
-
catch (err) {
|
|
88
|
-
// Not fatal: the runner rewrites them before every turn and reports its own failure.
|
|
89
|
-
log(`acp: could not write ${row.id}'s mode patches — ${errText(err)}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
83
|
+
function startAcpUpkeep(keepAlive) {
|
|
92
84
|
void keepAlive();
|
|
93
85
|
const timer = setInterval(() => void keepAlive(), exports.ACP_KEEPALIVE_MS);
|
|
94
86
|
timer.unref();
|
|
@@ -157,8 +149,11 @@ function registerAcpFrame(frame, deps) {
|
|
|
157
149
|
// resume id, add `acp`, and hand its name to this agent from then on. Refused instead.
|
|
158
150
|
const current = deps.registry.get(frame.session);
|
|
159
151
|
if (current && !current.acp) {
|
|
160
|
-
return { ok: false, message: `A session named "${frame.session}" already exists on this computer and is not run by the relay. Choose another name (
|
|
152
|
+
return { ok: false, message: `A session named "${frame.session}" already exists on this computer and is not run by the relay. Choose another name (${row.connectCommand} --session <name>).` };
|
|
161
153
|
}
|
|
154
|
+
const otherAgent = (0, agents_1.otherAgentRefusal)(row, frame.session, current?.acp?.agent);
|
|
155
|
+
if (otherAgent)
|
|
156
|
+
return { ok: false, message: otherAgent };
|
|
162
157
|
const existing = current?.acp;
|
|
163
158
|
if (existing && (0, modes_1.modeRank)(acp.mode) > (0, modes_1.modeRank)(existing.mode)) {
|
|
164
159
|
return { ok: false, message: `A running session's mode can only be raised from the chat, with /mode-${acp.mode} (you, or anyone you gave command access).` };
|
|
@@ -175,6 +170,8 @@ function registerAcpFrame(frame, deps) {
|
|
|
175
170
|
if (!found.ok || !path.isAbsolute(found.path)) {
|
|
176
171
|
return { ok: false, message: `${row.label} is not installed where the relay can find it. ${row.installHint}` };
|
|
177
172
|
}
|
|
173
|
+
if (!(0, agents_1.binaryIdentified)(row, found.version))
|
|
174
|
+
return { ok: false, message: (0, agents_1.unidentifiedBinary)(row, found.path, found.version) };
|
|
178
175
|
runtimeBin = found.path;
|
|
179
176
|
}
|
|
180
177
|
// The agent's own session id survives a re-register in the same folder; a moved folder
|
package/dist/relay/acp/modes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ACP_MODES = void 0;
|
|
3
|
+
exports.READS_ANYTHING = exports.ACP_MODES = void 0;
|
|
4
4
|
exports.isAcpMode = isAcpMode;
|
|
5
5
|
exports.modeRank = modeRank;
|
|
6
6
|
exports.clampMode = clampMode;
|
|
@@ -24,12 +24,12 @@ function usesLocalTools(mode) {
|
|
|
24
24
|
// Said in the room whenever a mode is set, and printed by `connect`. The limit is stated every
|
|
25
25
|
// time because the agent's sandbox confines WRITES and never reads — a person choosing `read`
|
|
26
26
|
// must not believe it means "only this folder".
|
|
27
|
-
|
|
27
|
+
exports.READS_ANYTHING = "It can read any file your account can, not only this folder — only `chat` protects private files.";
|
|
28
28
|
const DESCRIPTIONS = {
|
|
29
29
|
chat: "chat — BayChat tools only. It cannot read, write or run anything on this computer.",
|
|
30
|
-
read: `read — it can read and search files, and cannot run commands or write. ${READS_ANYTHING}`,
|
|
31
|
-
ask: `ask — it can run commands; every write, and anything needing more rights, asks you on your phone first. Commands that only read run without asking. ${READS_ANYTHING}`,
|
|
32
|
-
full: `full — it can write and run commands inside its folder without asking. ${READS_ANYTHING}`,
|
|
30
|
+
read: `read — it can read and search files, and cannot run commands or write. ${exports.READS_ANYTHING}`,
|
|
31
|
+
ask: `ask — it can run commands; every write, and anything needing more rights, asks you on your phone first. Commands that only read run without asking. ${exports.READS_ANYTHING}`,
|
|
32
|
+
full: `full — it can write and run commands inside its folder without asking. ${exports.READS_ANYTHING}`,
|
|
33
33
|
};
|
|
34
34
|
function describeMode(mode) {
|
|
35
35
|
return DESCRIPTIONS[mode];
|