baychat 0.34.1 → 0.35.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/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -2
- package/dist/api.js +30 -0
- package/dist/bays-command.js +6 -0
- package/dist/channel/feed.js +48 -4
- package/dist/channel/log-file.js +37 -0
- package/dist/channel/server.js +34 -4
- package/dist/claude-context-flush.js +31 -8
- package/dist/claude-context.js +19 -5
- package/dist/claude-onboarding.js +21 -8
- package/dist/cli-help.js +3 -0
- package/dist/client-paths.js +6 -1
- package/dist/client-scope.js +34 -0
- package/dist/commands.js +16 -8
- package/dist/connect-link.js +205 -0
- package/dist/connect-plan.js +2 -2
- package/dist/connect.js +8 -0
- package/dist/device-login.js +19 -0
- package/dist/launch/command.js +6 -3
- package/dist/main.js +18 -2
- package/dist/mcp-files.js +2 -0
- package/dist/mcp-register.js +2 -0
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +7 -0
- package/dist/relay/acp/approval.js +99 -30
- package/dist/relay/acp/daemon-glue.js +2 -1
- package/dist/relay/adapters.js +5 -1
- package/dist/relay/bay-relays.js +18 -5
- package/dist/relay/codex-allowances.js +113 -0
- package/dist/relay/codex-app-server.js +637 -157
- package/dist/relay/codex-approvals.js +390 -0
- package/dist/relay/codex-commands.js +61 -0
- package/dist/relay/codex-log.js +260 -0
- package/dist/relay/codex-runner.js +505 -0
- package/dist/relay/codex-version.js +50 -0
- package/dist/relay/daemon.js +190 -6
- package/dist/relay/fresh-start.js +2 -1
- package/dist/relay/launcher/capabilities.js +24 -9
- package/dist/relay/launcher/codex-launch.js +9 -2
- package/dist/relay/launcher/codex-relay-launch.js +80 -0
- package/dist/relay/launcher/handle.js +101 -30
- package/dist/relay/launcher/loop.js +5 -1
- package/dist/relay/launcher/stop-registered.js +124 -9
- package/dist/relay/registry.js +30 -0
- package/dist/relay/runtime-limits.js +167 -0
- package/dist/relay/spawn-env.js +40 -3
- package/dist/relay/terminal-pane.js +1 -0
- package/dist/sea-names.js +34 -0
- package/dist/session-command.js +3 -1
- package/dist/session-name.js +25 -2
- package/dist/skill-bootstrap.js +2 -0
- package/dist/tool-defs.js +30 -0
- package/package.json +1 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Codex's approval requests, as BayChat decision cards (2026-10-23).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS. A relay-run Codex turn (`codex app-server`, codex-app-server.ts) runs with
|
|
5
|
+
// `approvalPolicy: "on-request"` when everyone who woke it may command the session. Codex then
|
|
6
|
+
// stops and ASKS before it runs a command outside its sandbox, writes a file, widens its own
|
|
7
|
+
// permissions or calls an MCP tool — as JSON-RPC requests from the server to us. This module is
|
|
8
|
+
// the pure half of answering them: what the card says, which buttons it has, and what each tap
|
|
9
|
+
// (or no tap) means in the protocol's own words. No I/O here; the runner (codex-runner.ts) asks
|
|
10
|
+
// the phone and remembers what was allowed for the session.
|
|
11
|
+
//
|
|
12
|
+
// THE BUTTONS. "Allow once", "Allow for this session", "Deny". The middle one is GUARDED — the
|
|
13
|
+
// app confirms it on the device (Face ID / a confirm step) before sending, because it answers
|
|
14
|
+
// every later identical request without asking (the card API's `guardedOptions`). No answer
|
|
15
|
+
// within the card's time is a Deny, and the card is withdrawn.
|
|
16
|
+
//
|
|
17
|
+
// NEVER AN AMENDMENT. Codex offers "allow commands like this forever" (execpolicy amendments) and
|
|
18
|
+
// "allow this host forever" (network policy amendments). Both write rules that outlive the
|
|
19
|
+
// session and the person who tapped, so this relay never sends them: the strongest answer it
|
|
20
|
+
// gives is `acceptForSession`.
|
|
21
|
+
//
|
|
22
|
+
// Protocol shapes are the official 0.160 schema's (`openai/codex@rust-v0.160.0`,
|
|
23
|
+
// `codex-rs/app-server-protocol/schema/json/`), not guesses.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.CODEX_CARD_WAIT_SECONDS = exports.CODEX_GUARDED_OPTIONS = exports.CODEX_APPROVE_OPTIONS = void 0;
|
|
26
|
+
exports.isCodexApprovalMethod = isCodexApprovalMethod;
|
|
27
|
+
exports.cachedItemFrom = cachedItemFrom;
|
|
28
|
+
exports.fileChangesFrom = fileChangesFrom;
|
|
29
|
+
exports.choiceAt = choiceAt;
|
|
30
|
+
exports.planCodexCard = planCodexCard;
|
|
31
|
+
exports.composeCodexQuestion = composeCodexQuestion;
|
|
32
|
+
exports.looksSecret = looksSecret;
|
|
33
|
+
exports.describePermissions = describePermissions;
|
|
34
|
+
exports.decisionFor = decisionFor;
|
|
35
|
+
exports.isBayChatMcpApproval = isBayChatMcpApproval;
|
|
36
|
+
exports.sessionAllowKey = sessionAllowKey;
|
|
37
|
+
exports.commandKeyCwd = commandKeyCwd;
|
|
38
|
+
exports.describeForLog = describeForLog;
|
|
39
|
+
exports.clean = clean;
|
|
40
|
+
/** The card's buttons when everything can be shown: index 1 is guarded. */
|
|
41
|
+
exports.CODEX_APPROVE_OPTIONS = ["Allow once", "Allow for this session", "Deny"];
|
|
42
|
+
/** Confirmed on the device before it is sent: it answers later requests without asking. */
|
|
43
|
+
exports.CODEX_GUARDED_OPTIONS = [1];
|
|
44
|
+
/** How long a card is waited on before it counts as Deny and is withdrawn (the owner's call: 10 min). */
|
|
45
|
+
exports.CODEX_CARD_WAIT_SECONDS = 600;
|
|
46
|
+
/** The card API's limit on a question (apps/api/src/lib/cards.ts). */
|
|
47
|
+
const QUESTION_MAX = 2_000;
|
|
48
|
+
/** How much of a command the card shows. */
|
|
49
|
+
const COMMAND_MAX = 600;
|
|
50
|
+
/** How much of one path the card shows (the middle is elided). */
|
|
51
|
+
const PATH_MAX = 160;
|
|
52
|
+
/** How many files, or permission entries of one kind, a card lists. More than that and it says so. */
|
|
53
|
+
const LIST_MAX = 12;
|
|
54
|
+
/** How much of a NEW file's contents the card shows. */
|
|
55
|
+
const ADD_EXCERPT_MAX = 300;
|
|
56
|
+
function isCodexApprovalMethod(method) {
|
|
57
|
+
return (method === "item/commandExecution/requestApproval" ||
|
|
58
|
+
method === "item/fileChange/requestApproval" ||
|
|
59
|
+
method === "item/permissions/requestApproval" ||
|
|
60
|
+
method === "mcpServer/elicitation/request");
|
|
61
|
+
}
|
|
62
|
+
/** Read one item from `item/started` / `item/fileChange/patchUpdated`, defensively. */
|
|
63
|
+
function cachedItemFrom(raw) {
|
|
64
|
+
const item = (raw ?? {});
|
|
65
|
+
const id = typeof item.id === "string" ? item.id : "";
|
|
66
|
+
if (!id)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (item.type === "fileChange")
|
|
69
|
+
return { type: "fileChange", id, changes: fileChangesFrom(item.changes) };
|
|
70
|
+
if (item.type === "commandExecution")
|
|
71
|
+
return { type: "commandExecution", id, command: str(item.command), cwd: str(item.cwd) };
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
function fileChangesFrom(raw) {
|
|
75
|
+
if (!Array.isArray(raw))
|
|
76
|
+
return [];
|
|
77
|
+
return raw.flatMap((c) => {
|
|
78
|
+
const change = (c ?? {});
|
|
79
|
+
const kindObj = (change.kind ?? {});
|
|
80
|
+
const kind = kindObj.type === "add" || kindObj.type === "delete" ? kindObj.type : "update";
|
|
81
|
+
if (typeof change.path !== "string")
|
|
82
|
+
return [];
|
|
83
|
+
const movePath = typeof kindObj.move_path === "string" ? kindObj.move_path : undefined;
|
|
84
|
+
return [{ path: change.path, kind, ...(movePath ? { movePath } : {}), diff: str(change.diff) }];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** The button that was tapped, as a choice; null (no answer, or an index the card never had) is a refusal. */
|
|
88
|
+
function choiceAt(card, index) {
|
|
89
|
+
return index === null ? null : (card.choices[index] ?? null);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The card for one approval request, or a refusal when no card could honestly show it. At most
|
|
93
|
+
* 2,000 characters, every control and invisible character a renderer could act on removed — this
|
|
94
|
+
* is text Codex (and so, indirectly, anyone in the room) chose.
|
|
95
|
+
*/
|
|
96
|
+
function planCodexCard(req, ctx) {
|
|
97
|
+
const p = req.params;
|
|
98
|
+
const who = `Codex on ${clean(ctx.host)} (session "${clean(ctx.session)}")`;
|
|
99
|
+
/** Lines that must always be on the card, in order. */
|
|
100
|
+
const head = [];
|
|
101
|
+
/** The variable-length part, clipped to whatever room is left. */
|
|
102
|
+
let body = "";
|
|
103
|
+
let complete = true;
|
|
104
|
+
let sessionNote = "";
|
|
105
|
+
const reason = str(p.reason);
|
|
106
|
+
switch (req.method) {
|
|
107
|
+
case "item/commandExecution/requestApproval": {
|
|
108
|
+
const command = str(p.command) || (req.item?.type === "commandExecution" ? req.item.command : "");
|
|
109
|
+
const cwd = str(p.cwd) || (req.item?.type === "commandExecution" ? req.item.cwd : "");
|
|
110
|
+
head.push(p.kind === "writeStdin" ? `${who} wants to send input to a running command.` : `${who} wants to run a command.`);
|
|
111
|
+
if (cwd)
|
|
112
|
+
head.push(`In: ${shortPath(cwd)}`);
|
|
113
|
+
const net = (p.networkApprovalContext ?? null);
|
|
114
|
+
if (net && typeof net.host === "string")
|
|
115
|
+
head.push(`Network: it wants to reach ${clean(net.host)}${typeof net.protocol === "string" ? ` (${clean(net.protocol)})` : ""}`);
|
|
116
|
+
const shown = clipWithCount(collapse(clean(command, true)) || "(no command given)", COMMAND_MAX);
|
|
117
|
+
if (shown.clipped)
|
|
118
|
+
complete = false;
|
|
119
|
+
body = shown.text;
|
|
120
|
+
sessionNote = `"Allow for this session" lets this exact command run again here without asking — and that covers whatever the scripts it runs contain when they run.`;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "item/fileChange/requestApproval": {
|
|
124
|
+
const changes = req.item?.type === "fileChange" ? req.item.changes : [];
|
|
125
|
+
head.push(`${who} wants to change ${changes.length === 1 ? "1 file" : changes.length > 1 ? `${changes.length} files` : "files (Codex did not say which)"}.`);
|
|
126
|
+
if (changes.length === 0)
|
|
127
|
+
complete = false;
|
|
128
|
+
const root = str(p.grantRoot);
|
|
129
|
+
if (root) {
|
|
130
|
+
head.push(`It also asks to write ANYWHERE under ${shortPath(root)} for the rest of the session.`);
|
|
131
|
+
complete = false; // a whole folder is never remembered
|
|
132
|
+
}
|
|
133
|
+
for (const c of changes.slice(0, LIST_MAX))
|
|
134
|
+
head.push(`${c.kind} ${shortPath(c.path)}${c.movePath ? ` → ${shortPath(c.movePath)}` : ""}`);
|
|
135
|
+
if (changes.length > LIST_MAX) {
|
|
136
|
+
head.push(`…and ${changes.length - LIST_MAX} more file(s) not listed`);
|
|
137
|
+
complete = false;
|
|
138
|
+
}
|
|
139
|
+
body = changes.map(contentsFor).filter(Boolean).join("\n");
|
|
140
|
+
sessionNote = `"Allow for this session" lets changes to exactly these files through without asking.`;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "item/permissions/requestApproval": {
|
|
144
|
+
const described = describePermissions(p.permissions);
|
|
145
|
+
if (described.refuse)
|
|
146
|
+
return { kind: "refuse", reason: described.refuse };
|
|
147
|
+
head.push(`${who} wants more access:`, ...described.lines);
|
|
148
|
+
if (str(p.cwd))
|
|
149
|
+
head.push(`In: ${shortPath(str(p.cwd))}`);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "mcpServer/elicitation/request": {
|
|
153
|
+
head.push(`${who}: the tool server "${clean(str(p.serverName))}" asks:`);
|
|
154
|
+
const shown = clipWithCount(collapse(clean(str(p.message), true)), COMMAND_MAX);
|
|
155
|
+
body = shown.text;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (reason)
|
|
160
|
+
head.push(`Why: ${clipWithCount(clean(reason), 300).text}`);
|
|
161
|
+
const offerSession = complete && sessionNote !== "";
|
|
162
|
+
const tail = ["", ...(offerSession ? [sessionNote] : []), "Allowing does it on that computer, now. If you did not expect this, deny."];
|
|
163
|
+
const fixed = [...head, "", "", ...tail].join("\n");
|
|
164
|
+
// The lines a decision turns on must all fit; a request too big to show is not asked about.
|
|
165
|
+
if (fixed.length > QUESTION_MAX)
|
|
166
|
+
return { kind: "refuse", reason: "it is too big to show on one card" };
|
|
167
|
+
const room = Math.max(0, QUESTION_MAX - fixed.length - 40);
|
|
168
|
+
let question;
|
|
169
|
+
if (body) {
|
|
170
|
+
const shown = clipWithCount(body, room);
|
|
171
|
+
question = [...head, "", shown.text, ...tail].join("\n");
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
question = [...head, ...tail].join("\n");
|
|
175
|
+
}
|
|
176
|
+
question = question.replace(/\n{3,}/g, "\n\n").trim().slice(0, QUESTION_MAX);
|
|
177
|
+
const labels = offerSession ? [...exports.CODEX_APPROVE_OPTIONS] : ["Allow once", "Deny"];
|
|
178
|
+
const choices = offerSession ? ["once", "session", "deny"] : ["once", "deny"];
|
|
179
|
+
return { kind: "card", card: { question, labels, choices, guardedOptions: offerSession ? [...exports.CODEX_GUARDED_OPTIONS] : [], rememberable: offerSession } };
|
|
180
|
+
}
|
|
181
|
+
/** The card's question alone (tests and logs). A refused request has none. */
|
|
182
|
+
function composeCodexQuestion(req, ctx) {
|
|
183
|
+
const plan = planCodexCard(req, ctx);
|
|
184
|
+
return plan.kind === "card" ? plan.card.question : `(refused without asking: ${plan.reason})`;
|
|
185
|
+
}
|
|
186
|
+
/** Paths whose contents never go on a card: they very likely hold a secret. */
|
|
187
|
+
const SECRET_PATH = [
|
|
188
|
+
/(^|[\\/])\.env(\.[^\\/]*)?$/i,
|
|
189
|
+
/credential/i,
|
|
190
|
+
/secret/i,
|
|
191
|
+
/password|passwd/i,
|
|
192
|
+
/token/i,
|
|
193
|
+
/\.(pem|key|p12|pfx|jks|keystore|kdbx|gpg|asc)$/i,
|
|
194
|
+
/(^|[\\/])id_[a-z0-9]+(\.pub)?$/i,
|
|
195
|
+
/(^|[\\/])\.(ssh|aws|gnupg|kube|docker)[\\/]/i,
|
|
196
|
+
/(^|[\\/])\.(npmrc|netrc|pypirc|pgpass|git-credentials)$/i,
|
|
197
|
+
];
|
|
198
|
+
function looksSecret(path) {
|
|
199
|
+
return SECRET_PATH.some((re) => re.test(path));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* What a card shows of one file's contents. In 0.160 an add's or a delete's `diff` is the WHOLE
|
|
203
|
+
* file, so: a deletion shows nothing, a new file a short excerpt, a path that looks like it holds
|
|
204
|
+
* a secret nothing at all.
|
|
205
|
+
*/
|
|
206
|
+
function contentsFor(change) {
|
|
207
|
+
if (!change.diff)
|
|
208
|
+
return "";
|
|
209
|
+
const where = shortPath(change.path);
|
|
210
|
+
if (change.kind === "delete")
|
|
211
|
+
return `(${where} is deleted — its contents are not shown)`;
|
|
212
|
+
if (looksSecret(change.path) || (change.movePath && looksSecret(change.movePath)))
|
|
213
|
+
return `(${where} — contents not shown)`;
|
|
214
|
+
if (change.kind === "add") {
|
|
215
|
+
const shown = clipWithCount(clean(change.diff, true), ADD_EXCERPT_MAX);
|
|
216
|
+
return `New file ${where}:\n${shown.text}`;
|
|
217
|
+
}
|
|
218
|
+
return clean(change.diff, true);
|
|
219
|
+
}
|
|
220
|
+
const SPECIAL_KIND = {
|
|
221
|
+
root: "the WHOLE disk",
|
|
222
|
+
minimal: "the minimal system paths",
|
|
223
|
+
tmpdir: "the temp folder ($TMPDIR)",
|
|
224
|
+
slash_tmp: "/tmp",
|
|
225
|
+
};
|
|
226
|
+
/** One file-system target, named so a person can see exactly what it is. */
|
|
227
|
+
function describeTarget(raw) {
|
|
228
|
+
const where = (raw ?? {});
|
|
229
|
+
if (where.type === "path")
|
|
230
|
+
return { text: shortPath(str(where.path)), root: str(where.path) === "/" };
|
|
231
|
+
if (where.type === "glob_pattern")
|
|
232
|
+
return { text: `files matching ${clean(str(where.pattern))}`, root: false };
|
|
233
|
+
if (where.type === "special") {
|
|
234
|
+
const value = (where.value ?? {});
|
|
235
|
+
const kind = str(value.kind);
|
|
236
|
+
const sub = str(value.subpath);
|
|
237
|
+
if (kind === "project_roots")
|
|
238
|
+
return { text: `the project folders${sub ? ` (${shortPath(sub)})` : ""}`, root: false };
|
|
239
|
+
if (kind === "unknown")
|
|
240
|
+
return { text: `"${shortPath(str(value.path))}"${sub ? ` (${shortPath(sub)})` : ""} — a special path this relay does not know`, root: false };
|
|
241
|
+
const known = SPECIAL_KIND[kind];
|
|
242
|
+
return { text: known ?? `an unrecognised special path "${clean(kind)}"`, root: kind === "root" };
|
|
243
|
+
}
|
|
244
|
+
return { text: `an unrecognised path ${clean(JSON.stringify(where).slice(0, 80))}`, root: false };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Every grant a permissions request asks for, as card lines — or a refusal when the card could
|
|
248
|
+
* not list them all, or one of them is write access to the whole disk. Never fewer lines than
|
|
249
|
+
* grants: `decisionFor` grants the request as a whole.
|
|
250
|
+
*/
|
|
251
|
+
function describePermissions(raw) {
|
|
252
|
+
const perms = (raw ?? {});
|
|
253
|
+
const lines = [];
|
|
254
|
+
const fs = (perms.fileSystem ?? null);
|
|
255
|
+
if (fs) {
|
|
256
|
+
const write = Array.isArray(fs.write) ? fs.write : [];
|
|
257
|
+
const read = Array.isArray(fs.read) ? fs.read : [];
|
|
258
|
+
const entries = Array.isArray(fs.entries) ? fs.entries : [];
|
|
259
|
+
if (write.length > LIST_MAX || read.length > LIST_MAX || entries.length > LIST_MAX) {
|
|
260
|
+
return { lines, refuse: `it asked for more file-system access than a card can list (${write.length} write, ${read.length} read, ${entries.length} entries)` };
|
|
261
|
+
}
|
|
262
|
+
for (const w of write) {
|
|
263
|
+
if (str(w) === "/")
|
|
264
|
+
return { lines, refuse: "it asked to write to the whole disk" };
|
|
265
|
+
lines.push(`Write: ${shortPath(str(w))}`);
|
|
266
|
+
}
|
|
267
|
+
for (const r of read)
|
|
268
|
+
lines.push(`Read: ${str(r) === "/" ? "the WHOLE disk" : shortPath(str(r))}`);
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
const entry = (e ?? {});
|
|
271
|
+
const access = str(entry.access);
|
|
272
|
+
const target = describeTarget(entry.path);
|
|
273
|
+
if (target.root && access === "write")
|
|
274
|
+
return { lines, refuse: "it asked to write to the whole disk" };
|
|
275
|
+
if (access !== "read" && access !== "write" && access !== "deny")
|
|
276
|
+
return { lines, refuse: `it asked for an access mode this relay does not know ("${clean(access)}")` };
|
|
277
|
+
lines.push(`${access === "write" ? "Write" : access === "read" ? "Read" : "Keep closed"}: ${target.text}`);
|
|
278
|
+
}
|
|
279
|
+
if (typeof fs.globScanMaxDepth === "number")
|
|
280
|
+
lines.push(`(patterns are searched up to ${fs.globScanMaxDepth} folder(s) deep)`);
|
|
281
|
+
}
|
|
282
|
+
const net = (perms.network ?? null);
|
|
283
|
+
if (net && net.enabled === true)
|
|
284
|
+
lines.push("Network: ON, to any host");
|
|
285
|
+
return { lines: lines.length > 0 ? lines : ["(Codex did not say what access.)"] };
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* What to send back for a choice — or for `null`, which is no answer at all (deadline, withdrawn,
|
|
289
|
+
* unreachable, an index the card never had) and is ALWAYS the refusal.
|
|
290
|
+
*/
|
|
291
|
+
function decisionFor(req, choice) {
|
|
292
|
+
const allow = choice === "once" || choice === "session";
|
|
293
|
+
const forSession = choice === "session";
|
|
294
|
+
switch (req.method) {
|
|
295
|
+
case "item/commandExecution/requestApproval":
|
|
296
|
+
case "item/fileChange/requestApproval":
|
|
297
|
+
return { decision: allow ? (forSession ? "acceptForSession" : "accept") : "decline" };
|
|
298
|
+
case "item/permissions/requestApproval":
|
|
299
|
+
// Exactly what was requested — and the card listed all of it — or nothing.
|
|
300
|
+
return allow ? { permissions: req.params.permissions ?? {}, scope: forSession ? "session" : "turn" } : { permissions: {}, scope: "turn" };
|
|
301
|
+
case "mcpServer/elicitation/request":
|
|
302
|
+
return allow ? { action: "accept", content: {} } : { action: "decline" };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Is this elicitation Codex asking whether it may call one of BAYCHAT'S OWN tools (`send_message`
|
|
307
|
+
* and the rest)? Those are the session's voice in the room — asking the owner's phone before every
|
|
308
|
+
* reply would make the session unusable, and the turn is already one they allowed. Only an
|
|
309
|
+
* approval of an MCP tool call (`_meta.codex_approval_kind: "mcp_tool_call"`) from the server
|
|
310
|
+
* entry BayChat is configured under counts; any other server, or any other kind of elicitation
|
|
311
|
+
* (a form asking for input), does not.
|
|
312
|
+
*/
|
|
313
|
+
function isBayChatMcpApproval(params, entry) {
|
|
314
|
+
const meta = (params._meta ?? null);
|
|
315
|
+
return params.serverName === entry && meta?.codex_approval_kind === "mcp_tool_call";
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The key "Allow for this session" is remembered under, or undefined when the request cannot be
|
|
319
|
+
* named exactly enough to be allowed again without asking. A command: its exact text in its exact
|
|
320
|
+
* folder. A file change: its exact set of paths, in the session's folder. Nothing broader —
|
|
321
|
+
* "allowed `npm test` here" must never cover `npm test && curl …`, nor one file another.
|
|
322
|
+
*/
|
|
323
|
+
function sessionAllowKey(req, sessionCwd = "") {
|
|
324
|
+
if (req.method === "item/commandExecution/requestApproval") {
|
|
325
|
+
if (req.params.kind === "writeStdin")
|
|
326
|
+
return undefined; // input to a live process is never the same twice
|
|
327
|
+
if (req.params.networkApprovalContext)
|
|
328
|
+
return undefined; // a host is not a command
|
|
329
|
+
const command = str(req.params.command) || (req.item?.type === "commandExecution" ? req.item.command : "");
|
|
330
|
+
const cwd = str(req.params.cwd) || (req.item?.type === "commandExecution" ? req.item.cwd : "") || sessionCwd;
|
|
331
|
+
return command ? `command\u0000${cwd}\u0000${command}` : undefined;
|
|
332
|
+
}
|
|
333
|
+
if (req.method === "item/fileChange/requestApproval") {
|
|
334
|
+
if (str(req.params.grantRoot))
|
|
335
|
+
return undefined; // a whole folder is a different request
|
|
336
|
+
const paths = req.item?.type === "fileChange" ? req.item.changes.map((c) => (c.movePath ? `${c.path}\u0000${c.movePath}` : c.path)) : [];
|
|
337
|
+
return paths.length ? `files\u0000${sessionCwd}\u0000${[...new Set(paths)].sort().join("\u0001")}` : undefined;
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
/** The folder a remembered command runs in, for forgetting it when files under it change. */
|
|
342
|
+
function commandKeyCwd(key) {
|
|
343
|
+
return key.startsWith("command\u0000") ? key.split("\u0000")[1] : undefined;
|
|
344
|
+
}
|
|
345
|
+
/** A short line for the session log: what was asked, never more than one line. */
|
|
346
|
+
function describeForLog(req) {
|
|
347
|
+
switch (req.method) {
|
|
348
|
+
case "item/commandExecution/requestApproval":
|
|
349
|
+
return `command: ${clipWithCount(collapse(clean(str(req.params.command) || (req.item?.type === "commandExecution" ? req.item.command : ""))), 200).text}`;
|
|
350
|
+
case "item/fileChange/requestApproval":
|
|
351
|
+
return `files: ${req.item?.type === "fileChange" ? req.item.changes.map((c) => `${c.kind} ${shortPath(c.path)}`).join(", ") : "(unknown)"}`;
|
|
352
|
+
case "item/permissions/requestApproval": {
|
|
353
|
+
const d = describePermissions(req.params.permissions);
|
|
354
|
+
return `permissions: ${d.refuse ?? d.lines.join("; ")}`;
|
|
355
|
+
}
|
|
356
|
+
case "mcpServer/elicitation/request":
|
|
357
|
+
return `elicitation from ${clean(str(req.params.serverName))}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function str(value) {
|
|
361
|
+
return typeof value === "string" ? value : "";
|
|
362
|
+
}
|
|
363
|
+
/** Clip to `max`, saying how much was left out; `clipped` tells the caller the card is incomplete. */
|
|
364
|
+
function clipWithCount(text, max) {
|
|
365
|
+
if (text.length <= max)
|
|
366
|
+
return { text, clipped: false };
|
|
367
|
+
const keep = Math.max(0, max - 40);
|
|
368
|
+
return { text: `${text.slice(0, keep)}… [${text.length - keep} more characters not shown]`, clipped: true };
|
|
369
|
+
}
|
|
370
|
+
/** Runs of spaces or tabs as one space, and blank lines as one line break: padding cannot push a
|
|
371
|
+
* part of a command off the card. */
|
|
372
|
+
function collapse(text) {
|
|
373
|
+
return text.replace(/[ \t]{2,}/g, " ").replace(/\n\s*\n+/g, "\n").trim();
|
|
374
|
+
}
|
|
375
|
+
/** A path, cleaned, with its middle elided past PATH_MAX. */
|
|
376
|
+
function shortPath(path) {
|
|
377
|
+
const p = clean(path);
|
|
378
|
+
return p.length <= PATH_MAX ? p : `${p.slice(0, 60)}…${p.slice(-(PATH_MAX - 61))}`;
|
|
379
|
+
}
|
|
380
|
+
// C0 controls except tab and newline, DEL, C1 controls; the bidi and invisible-direction marks that
|
|
381
|
+
// could make a line DISPLAY as something it is not; zero-width characters and the BOM that can hide
|
|
382
|
+
// text; and the Unicode line/paragraph separators a renderer may break on.
|
|
383
|
+
const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
384
|
+
const SEPARATORS = /[\u2028\u2029\u0085]/g;
|
|
385
|
+
const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?/g;
|
|
386
|
+
/** Strip escapes and control characters. `multiline` keeps line breaks (a diff); else they become spaces. */
|
|
387
|
+
function clean(text, multiline = false) {
|
|
388
|
+
const out = text.replace(ANSI, "").replace(/\r\n?/g, "\n").replace(SEPARATORS, "\n").replace(CONTROL, "");
|
|
389
|
+
return multiline ? out : out.replace(/\s*\n\s*/g, " ").trim();
|
|
390
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCodexSessionCommand = runCodexSessionCommand;
|
|
4
|
+
const codex_version_1 = require("./codex-version");
|
|
5
|
+
/**
|
|
6
|
+
* The session commands for a Codex session the RELAY runs (`target.codex`, codex-runner.ts) —
|
|
7
|
+
* there is no terminal to type `/compact` or `/new` into, so each means what the runner can do.
|
|
8
|
+
* Kept out of `daemon.ts`, which only calls in.
|
|
9
|
+
*
|
|
10
|
+
* Returns true when the command was answered here; false hands it back to the daemon (`/help`).
|
|
11
|
+
* A Codex in a terminal never reaches this: its commands are typed into its pane as before.
|
|
12
|
+
* Every path replies — a command produces no agent turn, so silence would read as failure.
|
|
13
|
+
*/
|
|
14
|
+
async function runCodexSessionCommand(codex, session, target, message, command, say) {
|
|
15
|
+
if (!target.codex)
|
|
16
|
+
return false;
|
|
17
|
+
if (command.mode) {
|
|
18
|
+
await say(`"${session}" has no modes: it works in its folder without network, and asks you on your phone before anything more.`);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
switch (command.id) {
|
|
22
|
+
case "compact":
|
|
23
|
+
if ((0, codex_version_1.codexExecFallback)()) {
|
|
24
|
+
await say(`/compact for "${session}" needs \`codex app-server\`, which BAYCHAT_CODEX_TRANSPORT=exec turns off on that computer.`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
await say(codex.compact(session, message.conversationId)
|
|
28
|
+
? `Compacting "${session}"'s memory${codex.status(session).running ? " after its current turn" : ""} — it will say when it is done.`
|
|
29
|
+
: `"${session}" has nothing to compact yet.`);
|
|
30
|
+
return true;
|
|
31
|
+
case "stop":
|
|
32
|
+
await say(codex.stop(session) ? `Stopped "${session}". Anything it had already done stays done.` : `"${session}" is not doing anything right now.`);
|
|
33
|
+
return true;
|
|
34
|
+
case "clear":
|
|
35
|
+
case "new":
|
|
36
|
+
// There is no terminal to type into: forgetting the thread IS the fresh chat. The running
|
|
37
|
+
// turn is stopped first, or it would finish into the memory being closed.
|
|
38
|
+
codex.stop(session);
|
|
39
|
+
codex.reset(session);
|
|
40
|
+
await say(`"${session}" will start a fresh conversation with your next message. Its earlier memory is closed, and nothing allowed "for this session" carries over.`);
|
|
41
|
+
return true;
|
|
42
|
+
case "exit":
|
|
43
|
+
await say(`"${session}" is run by the relay on that computer and has no terminal to detach. Use /stop to cancel a running turn.`);
|
|
44
|
+
return true;
|
|
45
|
+
case "status": {
|
|
46
|
+
const s = codex.status(session);
|
|
47
|
+
const allowed = codex.allowedCount(session);
|
|
48
|
+
await say([
|
|
49
|
+
`Session "${session}" — Codex run by the relay, no terminal`,
|
|
50
|
+
` folder: ${target.codex.cwd}`,
|
|
51
|
+
" approvals: asked on your phone (no network unless you allow it)",
|
|
52
|
+
` now: ${s.running ? "working on a turn" : "idle"}, ${s.queued} message(s) waiting`,
|
|
53
|
+
` memory: ${target.codex.threadId ? "kept between messages" : "fresh on the next message"}`,
|
|
54
|
+
` allowed for this session: ${allowed === 0 ? "nothing" : `${allowed} exact request(s)`}`,
|
|
55
|
+
].join("\n"));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|