baychat 0.13.1 → 0.15.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 +146 -1
- package/dist/args.js +72 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +521 -0
- package/dist/index.js +47 -26
- package/dist/relay/adapters.js +100 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +440 -36
- package/dist/relay/daemon.js +547 -17
- package/dist/relay/held.js +184 -0
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +320 -0
- package/dist/relay/owner-pid.js +129 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/registry.js +11 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +146 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +283 -0
- package/dist/runtimes.js +126 -46
- package/package.json +2 -2
|
@@ -0,0 +1,184 @@
|
|
|
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.HeldStore = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const config_1 = require("../config");
|
|
40
|
+
const FILE = "relay-held.json";
|
|
41
|
+
function heldPath() {
|
|
42
|
+
return path.join((0, config_1.configDir)(), FILE);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Messages held for a session that is alive but mid-turn, PERSISTED.
|
|
46
|
+
*
|
|
47
|
+
* A socket attach exits on every wake and the turn it triggers runs for minutes
|
|
48
|
+
* before the session re-arms. A message arriving in that window is early, not
|
|
49
|
+
* undeliverable, so the daemon holds it. That much already worked — in memory.
|
|
50
|
+
*
|
|
51
|
+
* In memory was not enough, and the gap was specific: a daemon restart lost
|
|
52
|
+
* every held message, and because the poll cursor had already advanced past
|
|
53
|
+
* them the server never sent them again. They were not pending, not delivered,
|
|
54
|
+
* and not anywhere. `relay status` showed nothing at all, so the only symptom
|
|
55
|
+
* was a person on a phone whose message was never answered — the exact reading
|
|
56
|
+
* of "the relay stopped" that holding was introduced to prevent.
|
|
57
|
+
*
|
|
58
|
+
* So the store is a file, and it is in `relay status`. Held is a THIRD state,
|
|
59
|
+
* neither delivered nor failed, and it has to be visible as itself: printing it
|
|
60
|
+
* as pending would make a working agent look broken, and printing nothing is
|
|
61
|
+
* what we just came from.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ THIS FILE CONTAINS MESSAGE TEXT IN PLAINTEXT. Everything else the relay
|
|
64
|
+
* persists is metadata — session names, resume ids, positions. This is chat
|
|
65
|
+
* content, sitting on disk on the user's own machine for as long as their agent
|
|
66
|
+
* takes to finish a turn. Mode 0600, in the same private config dir as the
|
|
67
|
+
* device credential, and deliberately emptied the moment a batch is handed
|
|
68
|
+
* over. That is the price of not losing messages, and it is worth naming rather
|
|
69
|
+
* than discovering.
|
|
70
|
+
*/
|
|
71
|
+
class HeldStore {
|
|
72
|
+
filePath;
|
|
73
|
+
/** session → conversationId → room. */
|
|
74
|
+
rooms = new Map();
|
|
75
|
+
constructor(filePath = heldPath()) {
|
|
76
|
+
this.filePath = filePath;
|
|
77
|
+
}
|
|
78
|
+
load() {
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Missing or corrupt. An empty store is the correct reading: it means the
|
|
85
|
+
// daemon holds nothing, which is true, rather than crashing a relay on
|
|
86
|
+
// startup over messages it cannot recover anyway.
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!raw || typeof raw !== "object")
|
|
90
|
+
return;
|
|
91
|
+
for (const [session, bySession] of Object.entries(raw)) {
|
|
92
|
+
if (!bySession || typeof bySession !== "object")
|
|
93
|
+
continue;
|
|
94
|
+
const rooms = new Map();
|
|
95
|
+
for (const [conversationId, value] of Object.entries(bySession)) {
|
|
96
|
+
const room = value;
|
|
97
|
+
if (!room || !Array.isArray(room.messages) || room.messages.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
rooms.set(conversationId, {
|
|
100
|
+
conversationId,
|
|
101
|
+
heldAt: typeof room.heldAt === "string" ? room.heldAt : new Date().toISOString(),
|
|
102
|
+
messages: room.messages,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (rooms.size > 0)
|
|
106
|
+
this.rooms.set(session, rooms);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
save() {
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const [session, rooms] of this.rooms) {
|
|
112
|
+
if (rooms.size === 0)
|
|
113
|
+
continue;
|
|
114
|
+
out[session] = Object.fromEntries(rooms);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
118
|
+
if (Object.keys(out).length === 0) {
|
|
119
|
+
// Nothing held: remove the file rather than leaving `{}` behind, so the
|
|
120
|
+
// plaintext does not outlive the hold by a single byte more than it
|
|
121
|
+
// must.
|
|
122
|
+
fs.rmSync(this.filePath, { force: true });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Losing durability is not losing the hold: the in-memory copy still
|
|
129
|
+
// serves this process, which is exactly the behaviour we had before.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Hold a batch for one room.
|
|
134
|
+
*
|
|
135
|
+
* De-duplicated by message id: a cursor re-baseline (409 recovery re-reads
|
|
136
|
+
* from a watermark) can legitimately replay an event, and a room must not
|
|
137
|
+
* show the agent the same line twice when it finally re-arms.
|
|
138
|
+
*/
|
|
139
|
+
add(session, conversationId, messages) {
|
|
140
|
+
const rooms = this.rooms.get(session) ?? new Map();
|
|
141
|
+
const room = rooms.get(conversationId) ?? { conversationId, heldAt: new Date().toISOString(), messages: [] };
|
|
142
|
+
for (const m of messages) {
|
|
143
|
+
if (!room.messages.some((held) => held.id === m.id))
|
|
144
|
+
room.messages.push(m);
|
|
145
|
+
}
|
|
146
|
+
rooms.set(conversationId, room);
|
|
147
|
+
this.rooms.set(session, rooms);
|
|
148
|
+
this.save();
|
|
149
|
+
}
|
|
150
|
+
/** Every room held for a session, oldest hold first. */
|
|
151
|
+
roomsFor(session) {
|
|
152
|
+
const rooms = this.rooms.get(session);
|
|
153
|
+
if (!rooms)
|
|
154
|
+
return [];
|
|
155
|
+
return [...rooms.values()].sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
|
|
156
|
+
}
|
|
157
|
+
/** Drop one room's hold — called only once its messages are on the wire. */
|
|
158
|
+
clearRoom(session, conversationId) {
|
|
159
|
+
const rooms = this.rooms.get(session);
|
|
160
|
+
if (!rooms || !rooms.delete(conversationId))
|
|
161
|
+
return;
|
|
162
|
+
if (rooms.size === 0)
|
|
163
|
+
this.rooms.delete(session);
|
|
164
|
+
this.save();
|
|
165
|
+
}
|
|
166
|
+
/** How many messages are held for one session, across all its rooms. */
|
|
167
|
+
countFor(session) {
|
|
168
|
+
let n = 0;
|
|
169
|
+
for (const room of this.rooms.get(session)?.values() ?? [])
|
|
170
|
+
n += room.messages.length;
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
/** What `relay status` prints. Content is deliberately NOT included. */
|
|
174
|
+
summary() {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const [session, rooms] of this.rooms) {
|
|
177
|
+
for (const room of rooms.values()) {
|
|
178
|
+
out.push({ session, conversationId: room.conversationId, count: room.messages.length, heldAt: room.heldAt });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out.sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.HeldStore = HeldStore;
|
|
@@ -0,0 +1,118 @@
|
|
|
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.MailboxWatcher = void 0;
|
|
37
|
+
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const mailbox_1 = require("./mailbox");
|
|
41
|
+
const parent_watch_1 = require("./parent-watch");
|
|
42
|
+
/**
|
|
43
|
+
* Watch the mailbox root for sessions arming and dying.
|
|
44
|
+
*
|
|
45
|
+
* `fs.watch` AND a periodic sweep, not one or the other. `fs.watch` gives
|
|
46
|
+
* instant pickup but is unreliable across filesystems — notably a tmpfs
|
|
47
|
+
* directory the agent created moments earlier — and a sweep alone would add
|
|
48
|
+
* seconds to every arming. The sweep is also the only thing that can notice a
|
|
49
|
+
* registration whose process has since died, because nothing on disk changes
|
|
50
|
+
* when a process exits and there is no event to watch for.
|
|
51
|
+
*/
|
|
52
|
+
class MailboxWatcher {
|
|
53
|
+
opts;
|
|
54
|
+
seen = new Map();
|
|
55
|
+
isAlive;
|
|
56
|
+
sweepMs;
|
|
57
|
+
watcher;
|
|
58
|
+
timer;
|
|
59
|
+
constructor(opts) {
|
|
60
|
+
this.opts = opts;
|
|
61
|
+
this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
|
|
62
|
+
this.sweepMs = opts.sweepMs ?? 2_000;
|
|
63
|
+
}
|
|
64
|
+
start() {
|
|
65
|
+
fs.mkdirSync(this.opts.root, { recursive: true, mode: 0o700 });
|
|
66
|
+
try {
|
|
67
|
+
this.watcher = fs.watch(this.opts.root, { recursive: true }, () => this.sweep());
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Not every platform or filesystem supports recursive watching. The sweep
|
|
71
|
+
// below is the floor, so this degrades to slower pickup, never to none.
|
|
72
|
+
}
|
|
73
|
+
this.timer = setInterval(() => this.sweep(), this.sweepMs);
|
|
74
|
+
this.timer.unref();
|
|
75
|
+
this.sweep();
|
|
76
|
+
}
|
|
77
|
+
stop() {
|
|
78
|
+
this.watcher?.close();
|
|
79
|
+
if (this.timer)
|
|
80
|
+
clearInterval(this.timer);
|
|
81
|
+
}
|
|
82
|
+
sweep() {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = fs.readdirSync(this.opts.root, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return; // No root yet: nothing has armed. Not a fault.
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory())
|
|
92
|
+
continue;
|
|
93
|
+
const dir = path.join(this.opts.root, entry.name);
|
|
94
|
+
const reg = (0, mailbox_1.readRegistration)(dir);
|
|
95
|
+
if (!reg)
|
|
96
|
+
continue;
|
|
97
|
+
if (!this.isAlive(reg.pid)) {
|
|
98
|
+
this.seen.delete(reg.session);
|
|
99
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
this.opts.onDrop(reg.session, `mailbox process ${reg.pid} is no longer running`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Identity is the registration's CONTENT, not its timestamp.
|
|
104
|
+
//
|
|
105
|
+
// `registeredAt` has millisecond resolution, so two armings inside one
|
|
106
|
+
// millisecond share a timestamp — which in a test is a flake and in
|
|
107
|
+
// production is a re-arm that goes unannounced. Hashing the content is
|
|
108
|
+
// exact: anything the daemon would act on differently is a different hash,
|
|
109
|
+
// and a byte-identical rewrite is one the daemon already has right.
|
|
110
|
+
const stamp = crypto.createHash("sha256").update(JSON.stringify(reg)).digest("hex");
|
|
111
|
+
if (this.seen.get(reg.session) === stamp)
|
|
112
|
+
continue;
|
|
113
|
+
this.seen.set(reg.session, stamp);
|
|
114
|
+
this.opts.onRegister(reg);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
exports.MailboxWatcher = MailboxWatcher;
|
|
@@ -0,0 +1,320 @@
|
|
|
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.WAKE_FIFO = exports.REGISTER_FILE = void 0;
|
|
37
|
+
exports.resolveMailboxRoot = resolveMailboxRoot;
|
|
38
|
+
exports.mailboxRootCandidates = mailboxRootCandidates;
|
|
39
|
+
exports.pickUsableMailboxDir = pickUsableMailboxDir;
|
|
40
|
+
exports.sanitizeSessionDir = sanitizeSessionDir;
|
|
41
|
+
exports.sessionMailboxDir = sessionMailboxDir;
|
|
42
|
+
exports.writeRegistration = writeRegistration;
|
|
43
|
+
exports.readRegistration = readRegistration;
|
|
44
|
+
exports.registrationToTarget = registrationToTarget;
|
|
45
|
+
exports.createWakeFifo = createWakeFifo;
|
|
46
|
+
exports.ensureWakeFifo = ensureWakeFifo;
|
|
47
|
+
exports.wakeFifoPath = wakeFifoPath;
|
|
48
|
+
exports.awaitWakeFifo = awaitWakeFifo;
|
|
49
|
+
exports.writeWake = writeWake;
|
|
50
|
+
const child_process_1 = require("child_process");
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const os = __importStar(require("os"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
/**
|
|
55
|
+
* Where a sandboxed agent and the relay meet on disk.
|
|
56
|
+
*
|
|
57
|
+
* THE AGENT CHOOSES, NOT THE DAEMON. Only the agent's own process knows what its
|
|
58
|
+
* sandbox permits: on 2026-08-30 a Codex shell could create files under /tmp but
|
|
59
|
+
* was refused `connect()` on a socket beside them, and `/run/user/1000` was
|
|
60
|
+
* granted as a writable root while the socket in it stayed unreachable. This is
|
|
61
|
+
* the same rule `SessionTarget.runtimeBin` already follows — the session
|
|
62
|
+
* identifies its own environment and the daemon does not guess for it.
|
|
63
|
+
*
|
|
64
|
+
* Order: an explicit override, then XDG_RUNTIME_DIR (user-private 0700 tmpfs,
|
|
65
|
+
* cleared on logout, so nothing outlives the session), then a uid-scoped
|
|
66
|
+
* directory under TMPDIR. The uid is in the tmp name because /tmp is shared: two
|
|
67
|
+
* users must never resolve to one root, and the 0700 mode the caller creates it
|
|
68
|
+
* with is what keeps either out of the other's.
|
|
69
|
+
*/
|
|
70
|
+
function resolveMailboxRoot(env = process.env, uid = process.getuid?.() ?? 0) {
|
|
71
|
+
return mailboxRootCandidates(env, uid)[0];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Every root worth trying, best first.
|
|
75
|
+
*
|
|
76
|
+
* There is more than one because PREFERRING a root is not the same as being
|
|
77
|
+
* able to USE it. Measured 2026-08-30: inside bubblewrap, `/run/user/1000` is
|
|
78
|
+
* statable — the relay socket in it can be stat'ed and refused at `connect()` —
|
|
79
|
+
* but `mkdir` under it fails ENOENT. An agent that resolved one root and
|
|
80
|
+
* assumed it would work gave up there instead of trying `/tmp`, which the same
|
|
81
|
+
* sandbox creates files in happily.
|
|
82
|
+
*
|
|
83
|
+
* The daemon watches all of these, because it cannot know which one the agent
|
|
84
|
+
* turned out to be able to use.
|
|
85
|
+
*/
|
|
86
|
+
function mailboxRootCandidates(env = process.env, uid = process.getuid?.() ?? 0) {
|
|
87
|
+
// An explicit override is EXCLUSIVE, not merely first. "Use this directory"
|
|
88
|
+
// is what someone setting it means, and treating it as a preference left both
|
|
89
|
+
// the daemon and `doctor` still reading the machine's real mailbox roots —
|
|
90
|
+
// which made the tests non-hermetic and, worse, let a leftover mailbox for a
|
|
91
|
+
// session of the same name hijack a delivery meant for another root.
|
|
92
|
+
if (env.BAYCHAT_MAILBOX_DIR)
|
|
93
|
+
return [env.BAYCHAT_MAILBOX_DIR];
|
|
94
|
+
const roots = [];
|
|
95
|
+
if (env.XDG_RUNTIME_DIR)
|
|
96
|
+
roots.push(path.join(env.XDG_RUNTIME_DIR, "baychat-mailbox"));
|
|
97
|
+
roots.push(path.join(env.TMPDIR ?? os.tmpdir(), `baychat-mailbox-${uid}`));
|
|
98
|
+
return [...new Set(roots)];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The first root this process can actually create — PROVED, not assumed.
|
|
102
|
+
*
|
|
103
|
+
* This is the agent's half of "the session identifies its own environment": it
|
|
104
|
+
* finds out by doing, because nothing else on the machine can tell it what its
|
|
105
|
+
* sandbox permits.
|
|
106
|
+
*/
|
|
107
|
+
function pickUsableMailboxDir(session, env = process.env, uid = process.getuid?.() ?? 0, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 })) {
|
|
108
|
+
const failures = [];
|
|
109
|
+
for (const root of mailboxRootCandidates(env, uid)) {
|
|
110
|
+
// The SESSION directory, not the root. Measured 2026-08-30: the root often
|
|
111
|
+
// already exists because the unsandboxed daemon created it, so probing the
|
|
112
|
+
// root succeeds as a no-op and the real refusal only surfaces one level
|
|
113
|
+
// down — where the agent had already committed to a root it cannot use.
|
|
114
|
+
// Prove the exact directory you are about to write into.
|
|
115
|
+
const dir = sessionMailboxDir(root, session);
|
|
116
|
+
try {
|
|
117
|
+
mkdir(dir);
|
|
118
|
+
return dir;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
failures.push(`${dir} (${err.message})`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`no usable mailbox directory — tried ${failures.join(", ")}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A session name is user input; it must never become a path traversal.
|
|
128
|
+
*
|
|
129
|
+
* Characters outside the safe set are COLLAPSED rather than dropped: dropping
|
|
130
|
+
* would let "a/b" and "ab" resolve to one directory, so two sessions would share
|
|
131
|
+
* a mailbox and each would take the other's wakes.
|
|
132
|
+
*/
|
|
133
|
+
function sanitizeSessionDir(name) {
|
|
134
|
+
return name.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
135
|
+
}
|
|
136
|
+
function sessionMailboxDir(root, session) {
|
|
137
|
+
return path.join(root, sanitizeSessionDir(session));
|
|
138
|
+
}
|
|
139
|
+
exports.REGISTER_FILE = "register.json";
|
|
140
|
+
exports.WAKE_FIFO = "wake.fifo";
|
|
141
|
+
function writeRegistration(dir, reg) {
|
|
142
|
+
// 0600: this file names the session's resume id, which is the handle for
|
|
143
|
+
// "continue that conversation". It is not public inside a shared /tmp.
|
|
144
|
+
fs.writeFileSync(path.join(dir, exports.REGISTER_FILE), JSON.stringify(reg, null, 2), { mode: 0o600 });
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Read a registration, or `undefined` for anything we cannot fully trust.
|
|
148
|
+
*
|
|
149
|
+
* Never throws. The daemon watches this directory while the agent writes into
|
|
150
|
+
* it, so a truncated or half-flushed file is an ordinary event, not a fault —
|
|
151
|
+
* and the sweep behind the watcher picks it up a moment later anyway.
|
|
152
|
+
*/
|
|
153
|
+
function readRegistration(dir) {
|
|
154
|
+
let raw;
|
|
155
|
+
try {
|
|
156
|
+
raw = fs.readFileSync(path.join(dir, exports.REGISTER_FILE), "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(raw);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
const reg = parsed;
|
|
169
|
+
if (typeof reg.session !== "string" || typeof reg.runtime !== "string")
|
|
170
|
+
return undefined;
|
|
171
|
+
if (typeof reg.fifo !== "string" || typeof reg.pid !== "number")
|
|
172
|
+
return undefined;
|
|
173
|
+
if (typeof reg.registeredAt !== "string")
|
|
174
|
+
return undefined;
|
|
175
|
+
return reg;
|
|
176
|
+
}
|
|
177
|
+
/** The registry cares about a SessionTarget, whichever channel described it. */
|
|
178
|
+
function registrationToTarget(reg) {
|
|
179
|
+
return {
|
|
180
|
+
name: reg.session,
|
|
181
|
+
runtime: reg.runtime,
|
|
182
|
+
resumeId: reg.resumeId,
|
|
183
|
+
resumeSource: reg.resumeSource,
|
|
184
|
+
resumeEvidence: reg.resumeEvidence,
|
|
185
|
+
resumeCwd: reg.resumeCwd,
|
|
186
|
+
cwd: reg.cwd,
|
|
187
|
+
runtimeBin: reg.runtimeBin,
|
|
188
|
+
ownerPid: reg.ownerPid,
|
|
189
|
+
// Persisted, so a LATER headless resume still knows this session is
|
|
190
|
+
// sandboxed and must be told to re-arm in the foreground. `status()`
|
|
191
|
+
// recomputes the live rung for display; this is the remembered one.
|
|
192
|
+
transport: "fifo",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Create this session's wake FIFO, replacing any previous one.
|
|
197
|
+
*
|
|
198
|
+
* Replacing rather than reusing is deliberate: a FIFO left behind by a crashed
|
|
199
|
+
* attach has no reader, and a stale reader from a previous arming could
|
|
200
|
+
* otherwise steal this session's next wake. A fresh inode makes both impossible.
|
|
201
|
+
*
|
|
202
|
+
* `mkfifo(1)` rather than a binding: Node has no `mkfifo`, and a sandboxed agent
|
|
203
|
+
* has been shown to run it (2026-08-30, `mkfifo -m 600` under bubblewrap).
|
|
204
|
+
*/
|
|
205
|
+
function createWakeFifo(dir) {
|
|
206
|
+
const fifo = path.join(dir, exports.WAKE_FIFO);
|
|
207
|
+
fs.rmSync(fifo, { force: true });
|
|
208
|
+
(0, child_process_1.execFileSync)("mkfifo", ["-m", "600", fifo]);
|
|
209
|
+
return fifo;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Make sure a wake FIFO exists at this path — WITHOUT disturbing one that does.
|
|
213
|
+
*
|
|
214
|
+
* This is the daemon's entry point, and it is deliberately not `createWakeFifo`.
|
|
215
|
+
* That function unlinks and recreates, which was right when the AGENT owned the
|
|
216
|
+
* FIFO (a stale one from a crashed attach has no reader, and a fresh inode stops
|
|
217
|
+
* an old reader stealing the next wake). Once the daemon took ownership the risk
|
|
218
|
+
* inverted: an agent may be blocked on this FIFO right now, and unlinking it
|
|
219
|
+
* leaves that reader waiting forever on an inode nothing can reach while the
|
|
220
|
+
* daemon writes to a new one where nobody is.
|
|
221
|
+
*
|
|
222
|
+
* Measured 2026-08-30 23:54: Codex registered twice nine seconds apart, the
|
|
223
|
+
* second registration recreated the FIFO, and the next message was recorded
|
|
224
|
+
* `pending` with a live reader sitting on the orphaned inode. Same shape as the
|
|
225
|
+
* orphaned attach sockets earlier that evening — a wake sent somewhere nobody
|
|
226
|
+
* is listening.
|
|
227
|
+
*
|
|
228
|
+
* A path that exists but is NOT a FIFO is replaced: nothing can be blocked on a
|
|
229
|
+
* regular file, so there is no reader to orphan, and a wake written into one
|
|
230
|
+
* would be silently lost.
|
|
231
|
+
*/
|
|
232
|
+
function ensureWakeFifo(dir) {
|
|
233
|
+
const fifo = path.join(dir, exports.WAKE_FIFO);
|
|
234
|
+
try {
|
|
235
|
+
if (fs.statSync(fifo).isFIFO())
|
|
236
|
+
return fifo;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
/* not there — create it below */
|
|
240
|
+
}
|
|
241
|
+
return createWakeFifo(dir);
|
|
242
|
+
}
|
|
243
|
+
/** Where this session's FIFO will live, without creating it. */
|
|
244
|
+
function wakeFifoPath(dir) {
|
|
245
|
+
return path.join(dir, exports.WAKE_FIFO);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Wait for the daemon to create this session's FIFO.
|
|
249
|
+
*
|
|
250
|
+
* THE DAEMON CREATES IT, NOT THE AGENT. Node has no `mkfifo` binding, so making
|
|
251
|
+
* one means spawning `mkfifo(1)` — and spawning a helper binary is precisely
|
|
252
|
+
* what a sandbox restricts. Measured 2026-08-30: inside bubblewrap the agent
|
|
253
|
+
* could create its directory and write its registration, then died with
|
|
254
|
+
* `spawnSync mkfifo EPERM`.
|
|
255
|
+
*
|
|
256
|
+
* So the privileged step moved to the side that has the privilege. The agent
|
|
257
|
+
* declares the path it wants in `register.json`; the daemon, which is not
|
|
258
|
+
* sandboxed, creates the FIFO there; the agent waits for it to appear. Nothing
|
|
259
|
+
* the agent must do is now anything but opening and reading a file.
|
|
260
|
+
*/
|
|
261
|
+
async function awaitWakeFifo(fifo, timeoutMs = 30_000, pollMs = 100) {
|
|
262
|
+
const deadline = Date.now() + timeoutMs;
|
|
263
|
+
for (;;) {
|
|
264
|
+
try {
|
|
265
|
+
if (fs.statSync(fifo).isFIFO())
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* not there yet */
|
|
270
|
+
}
|
|
271
|
+
if (Date.now() >= deadline) {
|
|
272
|
+
throw new Error(`the relay did not create ${fifo} within ${Math.round(timeoutMs / 1000)}s — it may not be watching this mailbox root. Run \`baychat doctor\`.`);
|
|
273
|
+
}
|
|
274
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Hand one wake to a blocked reader, or say why we could not — NEVER block.
|
|
279
|
+
*
|
|
280
|
+
* `O_NONBLOCK` on a write-only FIFO open fails with ENXIO when no reader has it
|
|
281
|
+
* open. That is not an error condition: it is the honest answer "this session
|
|
282
|
+
* registered, but it is not waiting right now", and it must become `pending`
|
|
283
|
+
* rather than `delivered`.
|
|
284
|
+
*
|
|
285
|
+
* A completed write is the one piece of real evidence this transport has that a
|
|
286
|
+
* message was TAKEN rather than merely sent — which is more than either of the
|
|
287
|
+
* other two rungs can offer.
|
|
288
|
+
*/
|
|
289
|
+
function writeWake(fifoPath, payload) {
|
|
290
|
+
let stat;
|
|
291
|
+
try {
|
|
292
|
+
stat = fs.statSync(fifoPath);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return { ok: false, reason: `no mailbox FIFO at ${fifoPath}` };
|
|
296
|
+
}
|
|
297
|
+
if (!stat.isFIFO())
|
|
298
|
+
return { ok: false, reason: `${fifoPath} is not a FIFO — refusing to write a wake into it` };
|
|
299
|
+
let fd;
|
|
300
|
+
try {
|
|
301
|
+
fd = fs.openSync(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
const code = err.code;
|
|
305
|
+
if (code === "ENXIO")
|
|
306
|
+
return { ok: false, reason: "session registered a mailbox but is not waiting on it right now" };
|
|
307
|
+
return { ok: false, reason: `could not open ${fifoPath}: ${code ?? "unknown error"}` };
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
fs.writeSync(fd, payload);
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
// The reader went away mid-write. Nothing was taken; say so.
|
|
315
|
+
return { ok: false, reason: `reader left mid-wake: ${err.code ?? "write failed"}` };
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
fs.closeSync(fd);
|
|
319
|
+
}
|
|
320
|
+
}
|