@ctrl-spc/cs 0.7.3 → 0.7.5
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/dist/companion.js +178 -18
- package/dist/daemon-lock.js +48 -0
- package/dist/daemon.js +114 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/mcp.js +90 -339
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +57 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +967 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/steps.js +168 -0
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
package/dist/companion.js
CHANGED
|
@@ -4,9 +4,9 @@ import { COMPANION_PORT } from './env.js';
|
|
|
4
4
|
import { openBrowser } from './browser.js';
|
|
5
5
|
import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
|
|
6
6
|
import { getClient, signIn, NotLoggedIn } from './supabase.js';
|
|
7
|
-
import { startPresence, stopPresence } from './presence.js';
|
|
7
|
+
import { startPresence, stopPresence, liveClient, isPresenceRunning } from './presence.js';
|
|
8
8
|
import { detectAgents } from './agents.js';
|
|
9
|
-
import { agentToolsBadgeState } from './mcp.js';
|
|
9
|
+
import { agentToolsBadgeState, foreignToolsServerAlive, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
|
|
10
10
|
import { loadProjects, saveMapping } from './projects.js';
|
|
11
11
|
import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
|
|
12
12
|
import { hostedRemoteIdentity } from './git-remote.js';
|
|
@@ -14,9 +14,64 @@ import { chooseFolder, detectGitRemote } from './folders.js';
|
|
|
14
14
|
import { renderCompanionUi } from './companion-ui.js';
|
|
15
15
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
16
16
|
const VERSION = '0.1.0';
|
|
17
|
+
/** Whether THIS process is the one refreshing the machine's token. False once
|
|
18
|
+
* the companion has found a daemon already serving and deferred to it, so
|
|
19
|
+
* `requestClient`'s fallback stops building a rival refresher. Starts true
|
|
20
|
+
* because a companion with no daemon beside it is the common case (bare `cs`
|
|
21
|
+
* is `openCompanion()`, and `cs open` never installs autostart). */
|
|
22
|
+
let companionRefreshing = true;
|
|
23
|
+
/**
|
|
24
|
+
* Resolves once `companionRefreshing` holds this boot's real decision.
|
|
25
|
+
*
|
|
26
|
+
* The server starts listening before that decision is taken: the probe inside
|
|
27
|
+
* `startPresenceUnlessDaemonServes` costs up to a second, and the
|
|
28
|
+
* `startPresence()` behind it costs seconds more. A browser tab left open by a
|
|
29
|
+
* previous `cs open` polls `GET /api/session` every two seconds, so without
|
|
30
|
+
* this it can be served during that window, while `companionRefreshing` is
|
|
31
|
+
* still its `true` initializer, by a refreshing client on a machine where a
|
|
32
|
+
* daemon is already rotating the token. That is the very collision the
|
|
33
|
+
* deferral exists to prevent, so no authenticated request is served until the
|
|
34
|
+
* decision is in.
|
|
35
|
+
*
|
|
36
|
+
* It is only ever a decision barrier. A failure to start presence is already
|
|
37
|
+
* handled by the `onError` callback and must not become a permanently wedged
|
|
38
|
+
* server, so the assignment below settles this promise on every path.
|
|
39
|
+
*/
|
|
40
|
+
let refreshDecided = Promise.resolve();
|
|
17
41
|
function url(token = companionToken()) {
|
|
18
42
|
return `http://127.0.0.1:${COMPANION_PORT}/?token=${encodeURIComponent(token)}`;
|
|
19
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Come online, unless a daemon on this machine already has.
|
|
46
|
+
*
|
|
47
|
+
* `foreignToolsServerAlive()` probes the fixed tools-server port over loopback
|
|
48
|
+
* and does NOT exclude the calling process, so this must be asked before
|
|
49
|
+
* anything in this process could bind that port. Both call sites (boot and
|
|
50
|
+
* `POST /api/login`) ask exactly once, at a moment where only another `cs` could
|
|
51
|
+
* be answering.
|
|
52
|
+
*
|
|
53
|
+
* When one is: presence, the tools server, agent registration, the orchestrator
|
|
54
|
+
* and the recovery sweeps are all the daemon's job, and running a second copy
|
|
55
|
+
* here duplicates them on the one rotating refresh token this machine has.
|
|
56
|
+
*
|
|
57
|
+
* `companionRefreshing` is assigned on BOTH branches rather than left at its
|
|
58
|
+
* initializer, because login runs this again: a companion that deferred to a
|
|
59
|
+
* daemon and later signs in with that daemon gone must stop reading as
|
|
60
|
+
* non-refreshing.
|
|
61
|
+
*/
|
|
62
|
+
async function startPresenceUnlessDaemonServes(onError) {
|
|
63
|
+
if (await foreignToolsServerAlive()) {
|
|
64
|
+
companionRefreshing = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
companionRefreshing = true;
|
|
68
|
+
try {
|
|
69
|
+
await startPresence();
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
onError(err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
20
75
|
/** Confirms the process already holding the port is really our companion before
|
|
21
76
|
* we hand it the token via the browser URL. */
|
|
22
77
|
async function isOurCompanion(token) {
|
|
@@ -50,6 +105,14 @@ export async function openCompanion() {
|
|
|
50
105
|
*/
|
|
51
106
|
export async function serveCompanion({ open = false } = {}) {
|
|
52
107
|
const token = companionToken();
|
|
108
|
+
// Assigned BEFORE the socket is accepting, so there is no instant at which a
|
|
109
|
+
// request could be served without waiting for the decision. `resolve` is
|
|
110
|
+
// called on every path out of the block below, including the failures, so a
|
|
111
|
+
// presence that never started leaves the server answering rather than hung.
|
|
112
|
+
let decided;
|
|
113
|
+
refreshDecided = new Promise((resolve) => {
|
|
114
|
+
decided = resolve;
|
|
115
|
+
});
|
|
53
116
|
const server = createServer((req, res) => {
|
|
54
117
|
void handle(req, res, token).catch((err) => {
|
|
55
118
|
if (!res.headersSent)
|
|
@@ -63,6 +126,9 @@ export async function serveCompanion({ open = false } = {}) {
|
|
|
63
126
|
});
|
|
64
127
|
}
|
|
65
128
|
catch (err) {
|
|
129
|
+
// This process never took the port, so it will serve nothing; settle anyway
|
|
130
|
+
// rather than leave a resolved-by-nobody promise behind for a later caller.
|
|
131
|
+
decided();
|
|
66
132
|
if (err.code === 'EADDRINUSE') {
|
|
67
133
|
// Port is taken. Only treat it as our own resident companion (and hand it
|
|
68
134
|
// the token via the browser) once a health probe confirms it — otherwise
|
|
@@ -83,15 +149,19 @@ export async function serveCompanion({ open = false } = {}) {
|
|
|
83
149
|
}
|
|
84
150
|
// Come online immediately if already signed in (terminal `cs login` or a
|
|
85
151
|
// prior GUI sign-in). Not signed in is fine — the GUI shows the sign-in screen.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
console.warn(`Presence did not start: ${err.message}`);
|
|
152
|
+
try {
|
|
153
|
+
if (readSession()) {
|
|
154
|
+
await startPresenceUnlessDaemonServes((err) => {
|
|
155
|
+
if (!(err instanceof NotLoggedIn))
|
|
156
|
+
console.warn(`Presence did not start: ${err.message}`);
|
|
157
|
+
});
|
|
93
158
|
}
|
|
94
159
|
}
|
|
160
|
+
finally {
|
|
161
|
+
// Signed out, presence started, or the probe itself threw: the decision is
|
|
162
|
+
// as made as it is going to get, and requests must not wait past it.
|
|
163
|
+
decided();
|
|
164
|
+
}
|
|
95
165
|
console.log(`Companion running — ${url(token)}`);
|
|
96
166
|
if (open)
|
|
97
167
|
openBrowser(url(token));
|
|
@@ -101,7 +171,7 @@ export async function serveCompanion({ open = false } = {}) {
|
|
|
101
171
|
if (closing)
|
|
102
172
|
return;
|
|
103
173
|
closing = true;
|
|
104
|
-
void stopPresence(
|
|
174
|
+
void stopPresence().finally(() => {
|
|
105
175
|
server.close(() => resolve());
|
|
106
176
|
process.exit(0);
|
|
107
177
|
});
|
|
@@ -166,6 +236,75 @@ function agentToolsState(signedIn) {
|
|
|
166
236
|
const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
|
|
167
237
|
return agentToolsBadgeState({ signedIn, installed });
|
|
168
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* The client an authenticated API request is served through.
|
|
241
|
+
*
|
|
242
|
+
* ═══ IT REUSES PRESENCE'S CLIENT, AND THAT IS THE WHOLE POINT OF THIS
|
|
243
|
+
* FUNCTION. ═══ `getClient()` builds a BRAND NEW client every call, with
|
|
244
|
+
* `autoRefreshToken: true` and an `onAuthStateChange` that writes
|
|
245
|
+
* `session.json` back (`supabase.ts:61-89`). Calling it per HTTP request meant
|
|
246
|
+
* `cs open` ran a whole family of refreshing, disk-writing clients beside the
|
|
247
|
+
* daemon's, all on the ONE rotating refresh token the machine has. Supabase
|
|
248
|
+
* rotates that token on every refresh and revokes the previous family member
|
|
249
|
+
* outside a 10-second reuse window (`supabase/config.toml:171-175`), so two
|
|
250
|
+
* independent refreshers race: one wins, the loser replays a superseded token,
|
|
251
|
+
* GoTrue revokes the family, and the token on disk is PERMANENTLY dead rather
|
|
252
|
+
* than merely stale. Investigation
|
|
253
|
+
* `.bugs/20260831-supabase-health/investigations/04-refresh-token-loop.md` §3
|
|
254
|
+
* names this row 6 of its rotation-collision map and calls it the most likely
|
|
255
|
+
* precipitating cause of the 2026-08-30 incident, after which the daemon's
|
|
256
|
+
* heartbeat spun on `400 refresh_token_not_found` roughly 360 times an hour
|
|
257
|
+
* with no way to self-heal.
|
|
258
|
+
*
|
|
259
|
+
* So the rule `daemon.ts` and `panel3/run.ts` already state in their own
|
|
260
|
+
* comments now holds here too: ONE REFRESH LOOP PER PROCESS, and one writer of
|
|
261
|
+
* `session.json`. When presence is running, its client is the one that owns
|
|
262
|
+
* refreshing, and the companion reads it rather than standing up a rival.
|
|
263
|
+
*
|
|
264
|
+
* Per process was never enough on its own, because `cs start` and `cs open` are
|
|
265
|
+
* two processes over one `session.json`: the rule is ONE REFRESH LOOP PER
|
|
266
|
+
* MACHINE. A companion that boots (or signs in) while a daemon is already
|
|
267
|
+
* serving defers to it entirely, starting no presence of its own, and everything
|
|
268
|
+
* below follows from that decision.
|
|
269
|
+
*
|
|
270
|
+
* ═══ AND `getClient()` REMAINS THE FALLBACK, BECAUSE NULL HERE MEANS "NO
|
|
271
|
+
* PRESENCE", NOT "SIGNED OUT". ═══ `liveClient()` is null before the first
|
|
272
|
+
* sign-in, and after `stopPresence()`, and in a companion serving on a machine
|
|
273
|
+
* whose presence failed to start, and in one that found a daemon already
|
|
274
|
+
* serving and deferred to it. Only `getClient()` can answer the signed-in
|
|
275
|
+
* question, by reading the session off disk and throwing `NotLoggedIn` when
|
|
276
|
+
* there is none, so the fallback is what every caller's 401 path still hangs
|
|
277
|
+
* off.
|
|
278
|
+
*
|
|
279
|
+
* What the fallback may NOT do is refresh while another `cs` process on this
|
|
280
|
+
* machine is refreshing, which is the deferring case above: `cs start` is
|
|
281
|
+
* already rotating the one token family, and a client built here with
|
|
282
|
+
* `refreshing: true` would race it exactly as the per-request family did.
|
|
283
|
+
* `companionRefreshing` carries the decision this process took at boot or at
|
|
284
|
+
* sign-in, and `getClient({ refreshing: false })` then returns a client that was
|
|
285
|
+
* never handed the refresh token, so it cannot rotate anything. It still reads
|
|
286
|
+
* the session off disk and still throws `NotLoggedIn`, including when the stored
|
|
287
|
+
* access token has already expired, so the 401 boundary is unchanged. A single
|
|
288
|
+
* companion request building a single client is not the defect; a client per
|
|
289
|
+
* request forever, beside the daemon's, is.
|
|
290
|
+
*
|
|
291
|
+
* ═══ READ AT CALL TIME, NEVER CACHED. ═══ `POST /api/login` signs in and then
|
|
292
|
+
* takes the deferral decision above, which on the no-daemon branch starts
|
|
293
|
+
* presence. A companion that resolved this once at startup would hold the
|
|
294
|
+
* pre-login null for the life of the process and go on building its own clients
|
|
295
|
+
* after the very moment presence became available to share. The login path also
|
|
296
|
+
* reassigns `companionRefreshing`, so caching would freeze the boot decision
|
|
297
|
+
* too, past a sign-in that legitimately changed it.
|
|
298
|
+
*
|
|
299
|
+
* Exported so the rule above is what a test holds: with no presence running it
|
|
300
|
+
* falls through to `getClient()`, refreshing only when no other `cs` process on
|
|
301
|
+
* this machine is, and lets `NotLoggedIn` out rather than softening it into a
|
|
302
|
+
* null, which is the half of the behaviour reachable without a real session and
|
|
303
|
+
* a live network.
|
|
304
|
+
*/
|
|
305
|
+
export async function requestClient() {
|
|
306
|
+
return liveClient() ?? (await getClient({ refreshing: companionRefreshing }));
|
|
307
|
+
}
|
|
169
308
|
async function handle(req, res, token) {
|
|
170
309
|
if (!allowedHost(req.headers.host)) {
|
|
171
310
|
res.writeHead(421, { 'Content-Type': 'text/plain' }).end('Misdirected request.');
|
|
@@ -203,12 +342,16 @@ async function handle(req, res, token) {
|
|
|
203
342
|
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found');
|
|
204
343
|
}
|
|
205
344
|
async function handleApi(req, res, path, query) {
|
|
345
|
+
// Nothing here is served until this boot's refresh decision is in, so no
|
|
346
|
+
// request can be answered by a client built from the `true` initializer on a
|
|
347
|
+
// machine where a daemon is the one refreshing. See `refreshDecided`.
|
|
348
|
+
await refreshDecided;
|
|
206
349
|
if (req.method === 'GET' && path === '/api/session') {
|
|
207
350
|
const machine = getMachineIdentity();
|
|
208
351
|
let email = null;
|
|
209
352
|
if (readSession()) {
|
|
210
353
|
try {
|
|
211
|
-
email = (await (await
|
|
354
|
+
email = (await (await requestClient()).auth.getUser()).data.user?.email ?? null;
|
|
212
355
|
}
|
|
213
356
|
catch { /* stale/expired session reads as signed out */ }
|
|
214
357
|
}
|
|
@@ -232,12 +375,9 @@ async function handleApi(req, res, path, query) {
|
|
|
232
375
|
}
|
|
233
376
|
try {
|
|
234
377
|
const result = await signIn(email, password);
|
|
235
|
-
|
|
236
|
-
await startPresence();
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
378
|
+
await startPresenceUnlessDaemonServes((err) => {
|
|
239
379
|
console.warn(`Presence did not start after sign-in: ${err.message}`);
|
|
240
|
-
}
|
|
380
|
+
});
|
|
241
381
|
json(res, 200, { ok: true, email: result.email });
|
|
242
382
|
}
|
|
243
383
|
catch (err) {
|
|
@@ -248,7 +388,27 @@ async function handleApi(req, res, path, query) {
|
|
|
248
388
|
if (req.method === 'POST' && path === '/api/logout') {
|
|
249
389
|
// Logout also unregisters ctrl-spc from the detected agents (Phase 3), so no
|
|
250
390
|
// dead server entry is left behind for the next agent run.
|
|
251
|
-
|
|
391
|
+
if (isPresenceRunning()) {
|
|
392
|
+
await stopPresence({ unregister: true });
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
/* ═══ A COMPANION THAT DEFERRED TO A DAEMON STILL HAS TO UNREGISTER. ═══
|
|
396
|
+
`stopPresence` returns early when this process holds no presence
|
|
397
|
+
(`presence.ts:603-604`), so without this branch logout would delete
|
|
398
|
+
`session.json` and leave the `ctrl-spc` entry in Claude's and Codex's
|
|
399
|
+
configs pointing at a server whose session is gone.
|
|
400
|
+
|
|
401
|
+
Only the unregistration is copied, deliberately. The other two things
|
|
402
|
+
`stopPresence` does belong to a process that owns them, and this one
|
|
403
|
+
owns neither: the running tools server is the daemon's, and the daemon
|
|
404
|
+
still owns and heartbeats `cliv2_agents`. Stamping `stopped_at` from
|
|
405
|
+
here would mark a machine offline that is genuinely online. */
|
|
406
|
+
const agents = detectAgents();
|
|
407
|
+
if (agents.includes('claude'))
|
|
408
|
+
void unregisterFromClaude();
|
|
409
|
+
if (agents.includes('codex'))
|
|
410
|
+
void unregisterFromCodex();
|
|
411
|
+
}
|
|
252
412
|
clearSession();
|
|
253
413
|
json(res, 200, { ok: true });
|
|
254
414
|
return;
|
|
@@ -256,7 +416,7 @@ async function handleApi(req, res, path, query) {
|
|
|
256
416
|
// Everything below needs a signed-in session.
|
|
257
417
|
let client;
|
|
258
418
|
try {
|
|
259
|
-
client = await
|
|
419
|
+
client = await requestClient();
|
|
260
420
|
}
|
|
261
421
|
catch (err) {
|
|
262
422
|
if (err instanceof NotLoggedIn) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { configDir } from './config.js';
|
|
4
|
+
import { processIsAlive } from './win-shell.js';
|
|
5
|
+
/**
|
|
6
|
+
* One `cs start` per config dir.
|
|
7
|
+
*
|
|
8
|
+
* Two daemons on one machine share one `cliv2_agents` row and take turns
|
|
9
|
+
* overwriting it every heartbeat. The case that produced it: `cs start` was
|
|
10
|
+
* running, `npm i -g` replaced dist under it, and a second `cs start` came up
|
|
11
|
+
* on the new code. The row then flipped between 0.7.0 and 0.7.4 every few
|
|
12
|
+
* seconds and the Machines popover flickered with it.
|
|
13
|
+
*
|
|
14
|
+
* Keyed on the config dir, not the machine id, so the documented two-instance
|
|
15
|
+
* setup (`CTRL_SPC_V2_CONFIG_DIR` plus `CTRL_SPC_V2_MACHINE_ID`) still works.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: a pid file, not an OS lock. A pid recycled onto an unrelated
|
|
18
|
+
* process after a crash reads as "already running" until that process exits;
|
|
19
|
+
* `rm ~/.config/ctrl-spc-v2/daemon.pid` is the way out. Upgrade to an
|
|
20
|
+
* exclusive-open lock if that is ever hit in practice.
|
|
21
|
+
*/
|
|
22
|
+
function lockPath() {
|
|
23
|
+
return join(configDir(), 'daemon.pid');
|
|
24
|
+
}
|
|
25
|
+
/** Claims the lock for this process, or returns the pid of the daemon that holds it. */
|
|
26
|
+
export function claimDaemonLock() {
|
|
27
|
+
const path = lockPath();
|
|
28
|
+
if (existsSync(path)) {
|
|
29
|
+
const pid = Number(readFileSync(path, 'utf8').trim());
|
|
30
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
|
|
31
|
+
return { held: true, pid };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(configDir(), { recursive: true });
|
|
35
|
+
writeFileSync(path, String(process.pid));
|
|
36
|
+
return { held: false };
|
|
37
|
+
}
|
|
38
|
+
/** Removes the lock if this process wrote it. Never throws: it runs during shutdown. */
|
|
39
|
+
export function releaseDaemonLock() {
|
|
40
|
+
try {
|
|
41
|
+
const path = lockPath();
|
|
42
|
+
if (Number(readFileSync(path, 'utf8').trim()) === process.pid)
|
|
43
|
+
rmSync(path);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// no lock, or not ours
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ensureAutostart } from './autostart.js';
|
|
2
|
-
import {
|
|
2
|
+
import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
|
|
3
|
+
import { liveClient, startPresence, stopPresence } from './presence.js';
|
|
3
4
|
/* ═══ THE ONE IMPORT ANYTHING OUTSIDE `panel3/` MAKES INTO IT. ═══ Named in
|
|
4
5
|
`.implementations/19-agent-panel-v3/conventions.md` and enforced by
|
|
5
6
|
`test/panel3-isolation.contract.test.mjs`, whose MOUNTS map allows this file
|
|
@@ -28,14 +29,87 @@ import { startPanel } from './panel3/run.js';
|
|
|
28
29
|
* starts doing the right thing with nothing else typed.
|
|
29
30
|
*/
|
|
30
31
|
export async function runDaemon() {
|
|
32
|
+
/* One daemon per config dir. See daemon-lock.ts for the flicker this stops.
|
|
33
|
+
Exits rather than replacing the holder: under launchd's KeepAlive a
|
|
34
|
+
replacement would be restarted and would then replace the replacer. */
|
|
35
|
+
const lock = claimDaemonLock();
|
|
36
|
+
if (lock.held) {
|
|
37
|
+
console.error(`cs start is already running on this computer (pid ${lock.pid}). Stop it first to start another.`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
31
41
|
ensureAutostart(); // default-on: install the login item unless the user opted out
|
|
32
|
-
const { machineName, agents
|
|
33
|
-
|
|
42
|
+
const { machineName, agents } = await startPresence();
|
|
43
|
+
/* ═══ THE PANEL READS PRESENCE'S CLIENT, IT IS NOT HANDED A COPY OF IT. ═══
|
|
44
|
+
`startPresence` used to return the client it had just built and this line
|
|
45
|
+
passed that value to `startPanel`, which held it for the process's life. The
|
|
46
|
+
heartbeat's catch block REBUILDS that client when `session.json` comes to
|
|
47
|
+
hold a different refresh token, and since the rebuild guard landed a
|
|
48
|
+
successful rebuild is the normal path rather than a throw. The value handed
|
|
49
|
+
over here was therefore a client the database refuses and nobody watches,
|
|
50
|
+
while the panel's two-second poll went on writing `panel3_machines` with it.
|
|
51
|
+
That table took 37,051 writes in the incident window, the largest single
|
|
52
|
+
share of it.
|
|
53
|
+
`liveClient` is presence's own accessor, so the panel asks the module that
|
|
54
|
+
owns the presence lifecycle rather than being told once. It cannot answer
|
|
55
|
+
null here: `startPresence` has returned, so presence is running. */
|
|
56
|
+
/* ═══ AND THE LAST CLIENT IT SAW IS KEPT, FOR THE ONE READ THAT HAPPENS AFTER
|
|
57
|
+
PRESENCE HAS ALREADY STOPPED. ═══ `startPanel` returns `stop: () =>
|
|
58
|
+
stopListening(current(), ...)`, and `current()` is evaluated in the arrow,
|
|
59
|
+
BEFORE `stopListening` is entered, so `stopListening`'s own best-effort
|
|
60
|
+
try/catch cannot cover it. On `SIGNED_OUT` both watchers are bound to this
|
|
61
|
+
same client and auth-js runs them in registration order: presence's fires
|
|
62
|
+
first and `stopPresence` sets its module singleton to null synchronously,
|
|
63
|
+
so by the time the daemon's watcher reaches `shutdown` -> `panel.stop()`
|
|
64
|
+
the getter below has nothing live to answer with. Throwing there is a throw
|
|
65
|
+
inside `shutdown` before its first await, which `void shutdown(1)` does not
|
|
66
|
+
contain: `panel3_machines.stopped_at` never gets written, the card goes on
|
|
67
|
+
saying an agent is working, `Promise.all` rejects, `process.exit` is never
|
|
68
|
+
reached, and the panel's `for(;;)` spins on a caught-and-logged failure
|
|
69
|
+
every two seconds forever. launchd's `KeepAlive` cannot help a process that
|
|
70
|
+
never exits. `shutdown()` must reach `process.exit` on every path.
|
|
71
|
+
|
|
72
|
+
THE FALLBACK IS THE LAST CLIENT PRESENCE ITSELF HELD, not the one it built
|
|
73
|
+
at startup: a rebuild replaces `p.client`, and a goodbye written with the
|
|
74
|
+
pre-rebuild snapshot is the same refused write this branch exists to stop.
|
|
75
|
+
Recording it on every successful read is what keeps it current without a
|
|
76
|
+
second subscription to the presence lifecycle.
|
|
77
|
+
|
|
78
|
+
THE THROW IS NOT WEAKENED FOR THE POLL, and that is the point of ordering
|
|
79
|
+
the two lines this way. A poll only reaches the fallback once presence has
|
|
80
|
+
stopped, which in this process means a shutdown is already in flight and
|
|
81
|
+
the exit is milliseconds away; every other poll, for the whole life of the
|
|
82
|
+
daemon, reads a live client or fails loudly exactly as before. What the
|
|
83
|
+
fallback buys is the goodbye write, which `stopListening` documents as
|
|
84
|
+
best-effort precisely because it is the last thing this process does.
|
|
85
|
+
|
|
86
|
+
THE ONE THING THAT WINDOW COSTS, NAMED RATHER THAN LEFT TO BE REDISCOVERED:
|
|
87
|
+
a poll landing in it used to throw at the getter and be swallowed by the
|
|
88
|
+
per-poll catch, so it could not write at all. Now it takes the fallback and
|
|
89
|
+
reaches `sayListening`, whose upsert deliberately sends `stopped_at: null`
|
|
90
|
+
on every poll, since that is what lets a restarted daemon come back on a row
|
|
91
|
+
stamped as gone. If such a poll's write lands AFTER `stopListening`'s stamp,
|
|
92
|
+
it erases the goodbye and the row reads listening again. That is bounded and
|
|
93
|
+
self-healing: `LISTENING_WINDOW_MS` is 15s, so the freshness check calls the
|
|
94
|
+
machine gone 15 seconds later whatever the column says, and the process is
|
|
95
|
+
already exiting. Not worth a second seam to close, and worth knowing about
|
|
96
|
+
if a machine is ever seen lingering for a few seconds after `cs start` ends. */
|
|
97
|
+
let lastKnownClient = liveClient();
|
|
98
|
+
const panel = startPanel(() => {
|
|
99
|
+
const live = liveClient();
|
|
100
|
+
if (live) {
|
|
101
|
+
lastKnownClient = live;
|
|
102
|
+
return live;
|
|
103
|
+
}
|
|
104
|
+
if (!lastKnownClient)
|
|
105
|
+
throw new Error('the presence loop is not running, so there is no session to work through');
|
|
106
|
+
return lastKnownClient;
|
|
107
|
+
});
|
|
34
108
|
console.log(`CTRL+SPC — this computer: ${machineName}`);
|
|
35
109
|
console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
|
|
36
110
|
console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
|
|
37
111
|
let stopping = false;
|
|
38
|
-
async function shutdown() {
|
|
112
|
+
async function shutdown(code = 0) {
|
|
39
113
|
if (stopping)
|
|
40
114
|
return;
|
|
41
115
|
stopping = true;
|
|
@@ -47,11 +121,45 @@ export async function runDaemon() {
|
|
|
47
121
|
so the panel's own signal handling stands down when it is started from
|
|
48
122
|
here (see `run()`). Both are best-effort and neither throws, which is why
|
|
49
123
|
they can settle together. */
|
|
50
|
-
await Promise.all([panel.stop(), stopPresence(
|
|
51
|
-
|
|
124
|
+
await Promise.all([panel.stop(), stopPresence()]);
|
|
125
|
+
releaseDaemonLock();
|
|
126
|
+
process.exit(code);
|
|
52
127
|
}
|
|
53
128
|
process.on('SIGINT', () => void shutdown());
|
|
54
129
|
process.on('SIGTERM', () => void shutdown());
|
|
130
|
+
/* ═══ AND THE PANEL GOES DOWN WITH THE SESSION TOO. ═══ presence.ts stops its
|
|
131
|
+
own three loops on `SIGNED_OUT`, which leaves this process holding a panel
|
|
132
|
+
that would go on upserting `panel3_machines` every few seconds with a token
|
|
133
|
+
the database refuses. It shares this client, so it hears the same event: one
|
|
134
|
+
shutdown, both halves, and the exit says the daemon needs a person rather
|
|
135
|
+
than pretending it is still online. Non-zero because this is a failure, not
|
|
136
|
+
a Ctrl-C. The login item's `KeepAlive` restarts either way, and that is the
|
|
137
|
+
point: a restart refuses at `getClient` after one refresh attempt and exits
|
|
138
|
+
again, so an unattended machine retries on launchd's throttle instead of
|
|
139
|
+
polling four times a second forever. */
|
|
140
|
+
/* ═══ BOUND TO WHATEVER CLIENT IS LIVE AT THIS INSTANT, WHICH IS WHY IT IS
|
|
141
|
+
READ AND NOT CAPTURED. ═══ This used to bind to the client `startPresence`
|
|
142
|
+
returned. A rebuild replaces that client, and presence re-binds its OWN
|
|
143
|
+
watcher to the replacement (`bindSignedOutWatcher`) while this one would
|
|
144
|
+
stay on the discarded client, where `SIGNED_OUT` can never fire again. That
|
|
145
|
+
leaves the daemon's half of the shutdown deaf for the rest of the process.
|
|
146
|
+
Reading it here binds to the current one; a client rebuilt LATER is
|
|
147
|
+
presence's watcher to carry, and that one calls `stopPresence`, which clears
|
|
148
|
+
the intervals this process depends on. This is not a second terminal stop,
|
|
149
|
+
it is the same one bound to a client that exists.
|
|
150
|
+
|
|
151
|
+
THE NULL IS THROWN ON RATHER THAN SKIPPED, matching the getter above. `?.`
|
|
152
|
+
would read as though no client were an expected state, and it is not:
|
|
153
|
+
`startPresence` has returned, so presence is running. Worse, it would
|
|
154
|
+
silently drop half the shutdown in exactly the state it claims to guard
|
|
155
|
+
against, leaving a daemon that answers a dead session by doing nothing. */
|
|
156
|
+
const live = liveClient();
|
|
157
|
+
if (!live)
|
|
158
|
+
throw new Error('the presence loop is not running, so there is no session to watch');
|
|
159
|
+
live.auth.onAuthStateChange((event) => {
|
|
160
|
+
if (event === 'SIGNED_OUT')
|
|
161
|
+
void shutdown(1);
|
|
162
|
+
});
|
|
55
163
|
// Keep the event loop alive.
|
|
56
164
|
await new Promise(() => { });
|
|
57
165
|
}
|
package/dist/firewall.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ═══ THE WEB FIREWALL IN FRONT OF THE DATABASE. ═══
|
|
3
|
+
*
|
|
4
|
+
* Supabase puts a Cloudflare web application firewall in front of PostgREST.
|
|
5
|
+
* When a request body carries text its pattern matcher reads as an injection
|
|
6
|
+
* payload, the edge answers 403 with a 4.5 KB HTML block page, the row is never
|
|
7
|
+
* written, and — because of how the database client handles a non-JSON error
|
|
8
|
+
* body — that entire page arrives as `error.message`.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THE CLIENT ACTUALLY HANDS BACK, established by reading its source rather
|
|
11
|
+
* than inferred: the promise RESOLVES, `error` is `{ message: <the whole page> }`
|
|
12
|
+
* with no `code`, `details` or `hint`, and the 403 status is real but is thrown
|
|
13
|
+
* away by both of this package's error guards, which destructure only the data
|
|
14
|
+
* and the error. So recognition is on the body. There is no other option here
|
|
15
|
+
* short of changing every call site of both guards.
|
|
16
|
+
*
|
|
17
|
+
* THE PHRASES BELOW WERE CAPTURED FROM A REAL REFUSAL, not paraphrased from
|
|
18
|
+
* memory. One request carrying the shape the incident report describes was sent
|
|
19
|
+
* to this project's REST endpoint with no credentials; the firewall answers
|
|
20
|
+
* ahead of authentication, so the page is reproducible at will and costs
|
|
21
|
+
* nothing. Inventing the markers and the test fixture from the same paraphrase
|
|
22
|
+
* would have agreed with itself and matched nothing in production.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE RAY IDENTIFIER SURVIVES when the rest of the page does not. Supabase
|
|
25
|
+
* exposes no control over this firewall on any plan: no dashboard setting, no
|
|
26
|
+
* management endpoint, no documented exception procedure. The one route to a
|
|
27
|
+
* rule exclusion is a support ticket, and that ticket needs the ray identifier
|
|
28
|
+
* of a real refusal. It appears in the page body and in a response header, and
|
|
29
|
+
* the header is not visible where this runs. Discard the page and keep twelve
|
|
30
|
+
* characters, or the only copy anyone ever holds is gone.
|
|
31
|
+
*
|
|
32
|
+
* THE FALLBACK IS TO CHANGE NOTHING. A message this does not recognise is
|
|
33
|
+
* returned byte for byte, so an ordinary database failure still reads exactly as
|
|
34
|
+
* it does today, and if Cloudflare rewords the page one day the caller is back
|
|
35
|
+
* to the behaviour that prompted this work rather than to a new wrong one.
|
|
36
|
+
*/
|
|
37
|
+
/** The heading, the sub-line and the footer element of the block page, exactly
|
|
38
|
+
* as captured. The title says "Attention Required", which a CHALLENGE page also
|
|
39
|
+
* says, so the title is deliberately not one of these: a challenge is not a body
|
|
40
|
+
* problem and telling somebody to reword their document would be a lie. */
|
|
41
|
+
const BLOCK_PAGE_MARKERS = ['you have been blocked', 'you are unable to access', 'cf-error-details'];
|
|
42
|
+
/** `Cloudflare Ray ID: ` then the identifier, wrapped in a tag on the real page. */
|
|
43
|
+
const RAY_ID = /cloudflare ray id:\s*(?:<[^>]*>\s*)*([0-9a-f]{8,})/i;
|
|
44
|
+
/**
|
|
45
|
+
* What a caller reads instead of the block page.
|
|
46
|
+
*
|
|
47
|
+
* It says REQUEST rather than body, and makes the advice conditional, because
|
|
48
|
+
* both guards this passes through carry more reads than writes and one of them
|
|
49
|
+
* also sits behind the commands a person types. Telling somebody who ran a read
|
|
50
|
+
* to reword their document would be worse than the HTML.
|
|
51
|
+
*/
|
|
52
|
+
function refusalSentence(rayId) {
|
|
53
|
+
const reference = rayId ? ` (Cloudflare ray ${rayId})` : '';
|
|
54
|
+
return ('the firewall in front of the database refused this request because something in it reads as ' +
|
|
55
|
+
'attack-shaped text; if you were saving a document, reword the part that reads like an injection ' +
|
|
56
|
+
`and try again${reference}`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Translate a database error message, leaving anything that is not the
|
|
60
|
+
* firewall's block page exactly as it was.
|
|
61
|
+
*
|
|
62
|
+
* Takes and returns a string so every call site is one call and nothing has to
|
|
63
|
+
* learn a new shape. Callers keep their own `could not <verb> <subject>: ` or
|
|
64
|
+
* `<tool> failed: ` prefix, so the operation that failed is still named.
|
|
65
|
+
*/
|
|
66
|
+
export function readableWriteError(message) {
|
|
67
|
+
const lowered = message.toLowerCase();
|
|
68
|
+
const isBlockPage = message.trimStart().startsWith('<') &&
|
|
69
|
+
lowered.includes('cloudflare') &&
|
|
70
|
+
BLOCK_PAGE_MARKERS.some((marker) => lowered.includes(marker));
|
|
71
|
+
if (!isBlockPage)
|
|
72
|
+
return message;
|
|
73
|
+
return refusalSentence(RAY_ID.exec(message)?.[1]);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Taught VERBATIM in the description of every tool that writes a body, and in
|
|
77
|
+
* the standing instructions of both agent surfaces, for the reason the citation
|
|
78
|
+
* teaching constant in mcp.ts records: a convention that lives in only one
|
|
79
|
+
* description is a convention half the flows never see.
|
|
80
|
+
*
|
|
81
|
+
* EVERY SHAPE IS NAMED IN WORDS AND NONE IS DEMONSTRATED. This paragraph is not
|
|
82
|
+
* only read by agents, it is WRITTEN THROUGH THE FIREWALL IT DESCRIBES — into
|
|
83
|
+
* the stored brief on every start and every dispatch. A worked example here
|
|
84
|
+
* would refuse every brief write and stop the product from starting anything.
|
|
85
|
+
*/
|
|
86
|
+
export const FIREWALL_WRITING_RULE = 'This body is saved through a web firewall that refuses text shaped like an attack. Keep out of it: ' +
|
|
87
|
+
'query fragments that read as database injection, such as a quoted value closed early and followed by ' +
|
|
88
|
+
'an always-true comparison, a set-combining select, or a statement that drops a table; shell injection, ' +
|
|
89
|
+
'such as a pipe or semicolon followed by a command name, a command inside backticks, or a command inside ' +
|
|
90
|
+
'a dollar-parenthesis substitution; an interpolation placed next to a call that executes or matches a ' +
|
|
91
|
+
'string; script-carrying links and inline error or load handlers on a tag; repeated parent-directory ' +
|
|
92
|
+
'steps in a path; and template expressions wrapping a call in double braces or in a dollar-brace. When ' +
|
|
93
|
+
'you must describe one of these, describe it in words rather than writing it out. If a save comes back ' +
|
|
94
|
+
'refused by the firewall, that is why: reword the body, do not retry it unchanged, and do not write ' +
|
|
95
|
+
'around the tool.';
|