@cello-protocol/daemon 0.0.198 → 0.0.200
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/agent-handlers.d.ts.map +1 -1
- package/dist/agent-handlers.js +10 -1
- package/dist/agent-handlers.js.map +1 -1
- package/dist/attendance-wiring.d.ts.map +1 -1
- package/dist/attendance-wiring.js +29 -29
- package/dist/attendance-wiring.js.map +1 -1
- package/dist/away-detection.d.ts +29 -0
- package/dist/away-detection.d.ts.map +1 -1
- package/dist/away-detection.js +33 -0
- package/dist/away-detection.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/onboarding-guidance.d.ts +79 -0
- package/dist/onboarding-guidance.d.ts.map +1 -0
- package/dist/onboarding-guidance.js +95 -0
- package/dist/onboarding-guidance.js.map +1 -0
- package/dist/session-relay-client.d.ts +37 -0
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +183 -4
- package/dist/session-relay-client.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a machine with NO agents is told, and why it is one string in one place.
|
|
3
|
+
*
|
|
4
|
+
* ─── The failure this exists to prevent ──────────────────────────────────────────────────────
|
|
5
|
+
* A new operator installs the plugin, runs `cello login`, and gets `No registered agents to
|
|
6
|
+
* start.` — nine words that name no next command, no token, and no bot. Measured 2026-09-07: the
|
|
7
|
+
* words "waitlist", "cohort" and "wave" appeared NOWHERE in the CLI, the daemon, or any of the
|
|
8
|
+
* five plugin skills, and the bot was referred to only as "the CELLO Operations Agent on
|
|
9
|
+
* Telegram" — a description, never a handle you could search for.
|
|
10
|
+
*
|
|
11
|
+
* ─── THERE ARE TWO TOKENS, AND CONFLATING THEM IS THE DEFECT ─────────────────────────────────
|
|
12
|
+
* The first draft of this text treated them as one thing and got the shape wrong.
|
|
13
|
+
*
|
|
14
|
+
* **Waitlist token** — minted by a wave when you are admitted from the waitlist. The gate
|
|
15
|
+
* handler calls it a grant of NETWORK ACCESS. You present it to the bot ONCE and it is
|
|
16
|
+
* BURNED; the burn links your Telegram account. It is not per-agent and you never see it again.
|
|
17
|
+
*
|
|
18
|
+
* **Agent token** — the `CELLO-` pre-authorization capability the bot issues so a specific
|
|
19
|
+
* agent may register. One per agent, single-use, 24-hour expiry.
|
|
20
|
+
*
|
|
21
|
+
* The system's own name for the first is `waitlist_tokens`, and that is what it is called here.
|
|
22
|
+
* It is deliberately NOT "telegram token": `cello-ops-agent-telegram-bot-token` already exists in
|
|
23
|
+
* the infrastructure as the bot's API credential, and giving two unrelated secrets one name is a
|
|
24
|
+
* support conversation nobody can untangle.
|
|
25
|
+
*
|
|
26
|
+
* ─── WHY THE GATE IS AT THE BOTTOM, NOT THE TOP ──────────────────────────────────────────────
|
|
27
|
+
* An earlier version led with the cohort gate on the theory that it prevents a wasted trip to
|
|
28
|
+
* Telegram. Andre's correction, and the gate handler agrees with him: the gate's first step is
|
|
29
|
+
* "is this Telegram ID already linked? → proceed". Once the waitlist token is burned, the
|
|
30
|
+
* condition is permanently satisfied. Leading with it means everyone past that point reads a
|
|
31
|
+
* standing warning about a door they already walked through, every time they have no agent on a
|
|
32
|
+
* machine — which is also true on a second laptop, after a reset, and after an ecosystem wipe.
|
|
33
|
+
*
|
|
34
|
+
* So the happy path leads and the gate is a CONDITION underneath it, phrased as a question so a
|
|
35
|
+
* reader who is past it skips it instead of re-reading it.
|
|
36
|
+
*
|
|
37
|
+
* ─── Why the gate is bullets and not a paragraph ─────────────────────────────────────────────
|
|
38
|
+
* It was a prose block, and it dropped out of scanning mode exactly where the reader is still
|
|
39
|
+
* scanning. Both halves are now lists — numbered steps, then bulleted steps — so the whole
|
|
40
|
+
* message reads as one shape rather than switching from instructions to explanation halfway
|
|
41
|
+
* down. The bullets are ACTIONS for the same reason: at that point the reader's question is
|
|
42
|
+
* "what do I do", not "what is this thing".
|
|
43
|
+
*
|
|
44
|
+
* ─── Why the handle is DERIVED and not a constant ────────────────────────────────────────────
|
|
45
|
+
* There are two bots — production and staging — and a hardcoded handle sends a staging operator
|
|
46
|
+
* to the production bot, which will refuse their staging waitlist token with a message about a
|
|
47
|
+
* grant they do hold, on a bot they should not be talking to. `CELLO_ENV` already exists in this
|
|
48
|
+
* daemon and already carries `staging`, so the handle derives from it.
|
|
49
|
+
*
|
|
50
|
+
* **The mapping is a whitelist of ONE, deliberately.** `resolveCelloEnv` defaults an unset
|
|
51
|
+
* `CELLO_ENV` to `"local"`, and the overwhelmingly common case — an operator who installed from
|
|
52
|
+
* npm and set nothing — must never be pointed at staging. So only a literal `staging` gets the
|
|
53
|
+
* staging bot and everything else gets production: a misconfiguration sends someone to the real
|
|
54
|
+
* bot, which is the recoverable direction of that error.
|
|
55
|
+
*
|
|
56
|
+
* ─── Why it lives in the daemon ──────────────────────────────────────────────────────────────
|
|
57
|
+
* Two surfaces show it: the CLI (`cello login`) and the MCP shim, for the operator who never
|
|
58
|
+
* opens a terminal. The CLI imports this because `cli` depends on `daemon`. **`connect` depends
|
|
59
|
+
* on NO @cello-protocol package** — it is a 233 KB socket shim by design, and making it depend on
|
|
60
|
+
* the 7.8 MB daemon would put a native SQLCipher build on every session start. So the shim cannot
|
|
61
|
+
* import this, and the text reaches it over the wire: `cello_list_agents` returns it as
|
|
62
|
+
* `onboarding` when the roster is empty. One definition, two renderers, no drift.
|
|
63
|
+
*/
|
|
64
|
+
/** Where someone who has not signed up starts. Live as of 2026-09-07. */
|
|
65
|
+
export const WAITLIST_URL = "https://cello.mygentic.ai/waitlist";
|
|
66
|
+
/** The operations agent on Telegram, by environment. */
|
|
67
|
+
export const BOT_HANDLE_PRODUCTION = "@CelloConnectBot";
|
|
68
|
+
export const BOT_HANDLE_STAGING = "@CelloConnectStagingBot";
|
|
69
|
+
/**
|
|
70
|
+
* Which bot to send this operator to. Only an explicit `staging` diverges — see the whitelist
|
|
71
|
+
* note above for why an unrecognised or unset value must resolve to production.
|
|
72
|
+
*/
|
|
73
|
+
export function botHandle(celloEnv = process.env["CELLO_ENV"]) {
|
|
74
|
+
return celloEnv === "staging" ? BOT_HANDLE_STAGING : BOT_HANDLE_PRODUCTION;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Shown when a machine has zero agents. Plain text, terminal-width, no ANSI — it is rendered
|
|
78
|
+
* verbatim by the CLI and passed through to an agent by the shim, and neither can assume a TTY.
|
|
79
|
+
*/
|
|
80
|
+
export function noAgentsGuidance(celloEnv) {
|
|
81
|
+
return ("No agents on this machine yet. To make one:\n" +
|
|
82
|
+
"\n" +
|
|
83
|
+
` 1. Get an agent token from ${botHandle(celloEnv)} on Telegram\n` +
|
|
84
|
+
" 2. cello create-agent <name>\n" +
|
|
85
|
+
" 3. cello register-agent <name> <token>\n" +
|
|
86
|
+
"\n" +
|
|
87
|
+
"That's it — your agent is live and reachable.\n" +
|
|
88
|
+
"\n" +
|
|
89
|
+
"First time with the bot? It asks for a waitlist token first. To get one:\n" +
|
|
90
|
+
"\n" +
|
|
91
|
+
` - Join the waitlist: ${WAITLIST_URL}\n` +
|
|
92
|
+
" - Wait to be admitted to a launch cohort — you'll be notified\n" +
|
|
93
|
+
" - Give the bot that token once. It is burned on use and never asked for again");
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=onboarding-guidance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"onboarding-guidance.js","sourceRoot":"","sources":["../src/onboarding-guidance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AAEH,yEAAyE;AACzE,MAAM,CAAC,MAAM,YAAY,GAAG,oCAAoC,CAAC;AAEjE,wDAAwD;AACxD,MAAM,CAAC,MAAM,qBAAqB,GAAG,kBAAkB,CAAC;AACxD,MAAM,CAAC,MAAM,kBAAkB,GAAG,yBAAyB,CAAC;AAE5D;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,WAA+B,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;IAC/E,OAAO,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,qBAAqB,CAAC;AAC7E,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAAiB;IAChD,OAAO,CACL,+CAA+C;QAC/C,IAAI;QACJ,gCAAgC,SAAS,CAAC,QAAQ,CAAC,gBAAgB;QACnE,kCAAkC;QAClC,4CAA4C;QAC5C,IAAI;QACJ,iDAAiD;QACjD,IAAI;QACJ,4EAA4E;QAC5E,IAAI;QACJ,0BAA0B,YAAY,IAAI;QAC1C,mEAAmE;QACnE,iFAAiF,CAClF,CAAC;AACJ,CAAC"}
|
|
@@ -5,6 +5,16 @@ import { type RelayReceiptStore } from "./relay-receipt-store.js";
|
|
|
5
5
|
import type { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
6
6
|
import type { SessionOwnChainStore } from "./session-own-chain-store.js";
|
|
7
7
|
export declare const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
|
|
8
|
+
/**
|
|
9
|
+
* How long to wait before re-proving after a transport-class failure.
|
|
10
|
+
*
|
|
11
|
+
* Sized against the measured event, not guessed: libp2p logged
|
|
12
|
+
* `connection-manager closing 1 connections / stopped / started` inside 3ms, and the redial that
|
|
13
|
+
* followed completed ~1s later. A second is comfortably past the restart while still being
|
|
14
|
+
* invisible next to the ~5 minutes the previous behaviour cost (quarantine the relay, wait for the
|
|
15
|
+
* next reachability sweep).
|
|
16
|
+
*/
|
|
17
|
+
export declare const PROOF_RETRY_DELAY_MS = 1000;
|
|
8
18
|
export declare const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
|
|
9
19
|
/** Structure 1 leaf kind: 0x00 = message, 0x02 = control (matches the relay). */
|
|
10
20
|
/**
|
|
@@ -344,6 +354,33 @@ export declare class AgentRelayClient {
|
|
|
344
354
|
* `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
|
|
345
355
|
* stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
|
|
346
356
|
*/
|
|
357
|
+
/**
|
|
358
|
+
* ⚠️ **THE FIRST ATTEMPT AFTER A COLD START IS EXPECTED TO FAIL, AND THAT IS NOT A FAULT ON
|
|
359
|
+
* EITHER SIDE.** Root-caused 2026-09-07 from libp2p's own debug log:
|
|
360
|
+
*
|
|
361
|
+
* ```
|
|
362
|
+
* circuit-relay listener: making reservation on relay
|
|
363
|
+
* reservation failed with status PERMISSION_DENIED <- the relay's gate, working as designed
|
|
364
|
+
* could not add discovered relay
|
|
365
|
+
* connection-manager closing 1 connections / stopped / started
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* libp2p's circuit-relay listener asks the relay for a reservation ON ITS OWN, before any CELLO
|
|
369
|
+
* code has proven anything. The relay's gate refuses an unproven peer — deliberately; its flow is
|
|
370
|
+
* *"asks, is refused, proves itself, and asks again"*. libp2p reacts to that refusal by discarding
|
|
371
|
+
* the relay and RESTARTING ITS CONNECTION MANAGER, which closes every connection — including the
|
|
372
|
+
* healthy one this proof is being opened on. Whichever lands first decides the outcome, so the
|
|
373
|
+
* failure is intermittent (measured 5/5, 2/4, 0/4, 1/6 across cold logins).
|
|
374
|
+
*
|
|
375
|
+
* **Both components are behaving as specified, so the refusal cannot be designed away from here** —
|
|
376
|
+
* removing it means either weakening the gate or reaching into libp2p's internal
|
|
377
|
+
* `transportManager.listen` to defer the circuit listener until after the proof. What the client
|
|
378
|
+
* CAN do is survive it: a transport-class failure is retried on a fresh connection, once.
|
|
379
|
+
*
|
|
380
|
+
* A REFUSAL IS NOT RETRIED. If the relay reached a verdict — no token, slot cap, misconfigured —
|
|
381
|
+
* the answer will be identical a second later, and retrying would spend the operator's reachability
|
|
382
|
+
* on a question already answered. Only a failure that never reached a verdict is retried.
|
|
383
|
+
*/
|
|
347
384
|
proveReservation(node: CelloNode): Promise<boolean>;
|
|
348
385
|
/**
|
|
349
386
|
* Submit a session's CONTENT-leaf hash to the relay. Connects/re-connects from `node` if needed.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-relay-client.d.ts","sourceRoot":"","sources":["../src/session-relay-client.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAC3D,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,OAAO,EAAoB,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AACpF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAGzE,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"session-relay-client.d.ts","sourceRoot":"","sources":["../src/session-relay-client.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAC3D,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,OAAO,EAAoB,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AACpF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAGzE,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,OAAQ,CAAC;AAC1C,eAAO,MAAM,iBAAiB,wBAAwB,CAAC;AACvD,iFAAiF;AACjF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,uBAAuB,EAAE,WAAW,CAAC,MAAM,CAgBtD,CAAC;AAEH,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAE1E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,OAAO,CAAC;IACzB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAqBD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,EACd,KAAK,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1E,gBAAgB,CA+DlB;AAED,eAAO,MAAM,aAAa,IAAO,CAAC;AAClC,iGAAiG;AACjG,eAAO,MAAM,cAAc,IAAO,CAAC;AACnC,uFAAuF;AACvF,eAAO,MAAM,aAAa,IAAO,CAAC;AAClC,uFAAuF;AACvF,eAAO,MAAM,gBAAgB,IAAO,CAAC;AAKrC;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,GAAG,UAAU,CAIvF;AAED,MAAM,WAAW,gBAAgB;IAC/B,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,UAAU,CAAC;IAC5B,eAAe,EAAE,UAAU,CAAC;IAC5B;;;;;OAKG;IACH,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,gDAAgD;AAChD,MAAM,MAAM,YAAY,GACpB;IACE,EAAE,EAAE,IAAI,CAAC;IACT,eAAe,EAAE,MAAM,CAAC;IAKxB,eAAe,CAAC,EAAE,UAAU,CAAC;IAC7B,eAAe,CAAC,EAAE,UAAU,CAAC;IAC7B;;;;;;;;;;;;;OAaG;IACH,gBAAgB,CAAC,EAAE,UAAU,CAAC;CAC/B,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAGN,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,sEAAsE;IACtE,WAAW,EAAE,WAAW,CAAC;IACzB,iFAAiF;IACjF,YAAY,EAAE,UAAU,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,oBAAoB,CAAC;IACrC;;;;OAIG;IACH,aAAa,CAAC,EAAE,oBAAoB,CAAC;IACrC;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,EAAE,MAAM,UAAU,GAAG,SAAS,CAAC;IAC3C;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACpD;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;;;OAQG;IACH,MAAM,EAAE,oCAAoC,GAAG,gCAAgC,CAAC;IAChF,oGAAoG;IACpG,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,yFAAyF;IACzF,uBAAuB,EAAE,OAAO,CAAC;IACjC;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;;;OAUG;IACH,UAAU,EAAE,OAAO,CAAC;CACrB;AAqBD;;;;;;GAMG;AACH;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,2DAA2D;IAC3D,cAAc,EAAE,UAAU,CAAC;IAC3B,oEAAoE;IACpE,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,YAAY,EAAE,UAAU,CAAC;IACzB,YAAY,EAAE,UAAU,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,mBAAmB,EAAE,UAAU,CAAC;CACjC;AAsDD;;;;GAIG;AACH,qBAAa,gBAAgB;;IAO3B,4EAA4E;IAC5E,kBAAkB,IAAI,MAAM,GAAG,IAAI;IAqDnC,+FAA+F;IAC/F,kBAAkB,IAAI,gBAAgB,GAAG,IAAI;gBAyFjC,IAAI,EAAE,oBAAoB;IA8BtC,2FAA2F;IAC3F,IAAI,eAAe,IAAI,MAAM,CAE5B;IAED;;;;OAIG;IACH,eAAe,CACb,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,SAAS,EACf,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,EACjD,UAAU,CAAC,EAAE,oBAAoB;IACjC;;;;;;;;;;;;;;OAcG;IACH,eAAe,CAAC,EAAE,UAAU,GAC3B,IAAI;IAoCP;;;;;;;;;;;;;;;;;OAiBG;IACG,uBAAuB,CAAC,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgFtF,0EAA0E;IAC1E,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI;IAM7C,WAAW,IAAI,OAAO;IAItB;;;;;;;;OAQG;IACH,UAAU,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IA6hBzC;;;;OAIG;IACG,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC;IAIhD;;;;;;;;;;;;;;;;;OAiBG;IACH;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC;IA4YzD;;;;;;;;;OASG;IACG,iBAAiB,CACrB,IAAI,EAAE,SAAS,EACf,SAAS,EAAE,UAAU,EACrB,WAAW,EAAE,UAAU;IACvB;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,YAAY,CAAC;IAQxB;;;;;;;;;;;;;;;;;;OAkBG;IACG,mBAAmB,CACvB,IAAI,EAAE,SAAS,EACf,SAAS,EAAE,UAAU,EACrB,WAAW,EAAE,UAAU,EACvB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,YAAY,CAAC;IAIxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA0CG;IACG,UAAU,CACd,IAAI,EAAE,SAAS,EACf,SAAS,EAAE,UAAU,EACrB,WAAW,EAAE,UAAU,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,UAAU,GAAG,IAAI;IAC/B;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,YAAY,CAAC;IAggBxB,yFAAyF;IACzF;;;;;;;;;;;;;;;;;;;OAmBG;IACH,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,IAAI;IAIvF;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,UAAU,CAAA;KAAE,GAAG,SAAS;IAIhF;;;;;;;;;;;OAWG;IACH,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAMzD;;;;;;OAMG;IACH,WAAW,CAAC,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,IAAI;IAKhE,KAAK,IAAI,IAAI;CAWd"}
|
|
@@ -36,6 +36,16 @@ import { verify } from "@cello-protocol/crypto";
|
|
|
36
36
|
import { extractErrorMessage } from "./error-message.js";
|
|
37
37
|
import { evaluateRelayAck } from "./relay-receipt-store.js";
|
|
38
38
|
export const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
|
|
39
|
+
/**
|
|
40
|
+
* How long to wait before re-proving after a transport-class failure.
|
|
41
|
+
*
|
|
42
|
+
* Sized against the measured event, not guessed: libp2p logged
|
|
43
|
+
* `connection-manager closing 1 connections / stopped / started` inside 3ms, and the redial that
|
|
44
|
+
* followed completed ~1s later. A second is comfortably past the restart while still being
|
|
45
|
+
* invisible next to the ~5 minutes the previous behaviour cost (quarantine the relay, wait for the
|
|
46
|
+
* next reachability sweep).
|
|
47
|
+
*/
|
|
48
|
+
export const PROOF_RETRY_DELAY_MS = 1_000;
|
|
39
49
|
export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
|
|
40
50
|
/** Structure 1 leaf kind: 0x00 = message, 0x02 = control (matches the relay). */
|
|
41
51
|
/**
|
|
@@ -307,6 +317,17 @@ export class AgentRelayClient {
|
|
|
307
317
|
getLastAuthRefusal() {
|
|
308
318
|
return this.#lastAuthRefusal;
|
|
309
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Drop any verdict from an earlier attempt — review HIGH-2.
|
|
322
|
+
*
|
|
323
|
+
* A METHOD rather than an inline `= null`, deliberately: assigning the field directly narrows its
|
|
324
|
+
* type to `null` for the rest of the enclosing method, and the later
|
|
325
|
+
* `#lastAuthRefusal?.reason` read then fails to compile against `never`. Clearing through a call
|
|
326
|
+
* keeps the declared type and says what the reset is for.
|
|
327
|
+
*/
|
|
328
|
+
#clearAuthRefusal() {
|
|
329
|
+
this.#lastAuthRefusal = null;
|
|
330
|
+
}
|
|
310
331
|
#stream = null;
|
|
311
332
|
#connecting = null;
|
|
312
333
|
#closed = false;
|
|
@@ -1163,25 +1184,168 @@ export class AgentRelayClient {
|
|
|
1163
1184
|
* `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
|
|
1164
1185
|
* stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
|
|
1165
1186
|
*/
|
|
1187
|
+
/**
|
|
1188
|
+
* ⚠️ **THE FIRST ATTEMPT AFTER A COLD START IS EXPECTED TO FAIL, AND THAT IS NOT A FAULT ON
|
|
1189
|
+
* EITHER SIDE.** Root-caused 2026-09-07 from libp2p's own debug log:
|
|
1190
|
+
*
|
|
1191
|
+
* ```
|
|
1192
|
+
* circuit-relay listener: making reservation on relay
|
|
1193
|
+
* reservation failed with status PERMISSION_DENIED <- the relay's gate, working as designed
|
|
1194
|
+
* could not add discovered relay
|
|
1195
|
+
* connection-manager closing 1 connections / stopped / started
|
|
1196
|
+
* ```
|
|
1197
|
+
*
|
|
1198
|
+
* libp2p's circuit-relay listener asks the relay for a reservation ON ITS OWN, before any CELLO
|
|
1199
|
+
* code has proven anything. The relay's gate refuses an unproven peer — deliberately; its flow is
|
|
1200
|
+
* *"asks, is refused, proves itself, and asks again"*. libp2p reacts to that refusal by discarding
|
|
1201
|
+
* the relay and RESTARTING ITS CONNECTION MANAGER, which closes every connection — including the
|
|
1202
|
+
* healthy one this proof is being opened on. Whichever lands first decides the outcome, so the
|
|
1203
|
+
* failure is intermittent (measured 5/5, 2/4, 0/4, 1/6 across cold logins).
|
|
1204
|
+
*
|
|
1205
|
+
* **Both components are behaving as specified, so the refusal cannot be designed away from here** —
|
|
1206
|
+
* removing it means either weakening the gate or reaching into libp2p's internal
|
|
1207
|
+
* `transportManager.listen` to defer the circuit listener until after the proof. What the client
|
|
1208
|
+
* CAN do is survive it: a transport-class failure is retried on a fresh connection, once.
|
|
1209
|
+
*
|
|
1210
|
+
* A REFUSAL IS NOT RETRIED. If the relay reached a verdict — no token, slot cap, misconfigured —
|
|
1211
|
+
* the answer will be identical a second later, and retrying would spend the operator's reachability
|
|
1212
|
+
* on a question already answered. Only a failure that never reached a verdict is retried.
|
|
1213
|
+
*/
|
|
1166
1214
|
async proveReservation(node) {
|
|
1167
1215
|
if (this.#closed)
|
|
1168
1216
|
return false;
|
|
1217
|
+
const first = await this.#proveReservationOnce(node);
|
|
1218
|
+
if (first !== "transport_failed")
|
|
1219
|
+
return first;
|
|
1220
|
+
/**
|
|
1221
|
+
* The connection manager has just restarted, so the previous connection is gone and a fresh
|
|
1222
|
+
* dial is required — which `#proveReservationOnce` does at its head. The brief wait is for
|
|
1223
|
+
* libp2p to finish restarting; without it the redial races the restart it is recovering from.
|
|
1224
|
+
*/
|
|
1225
|
+
await new Promise((r) => setTimeout(r, PROOF_RETRY_DELAY_MS));
|
|
1226
|
+
if (this.#closed)
|
|
1227
|
+
return false;
|
|
1228
|
+
this.#logger.info("session.relay.reservation_proof.retry", {
|
|
1229
|
+
relayPeerId: this.#relayPeerId,
|
|
1230
|
+
afterMs: PROOF_RETRY_DELAY_MS,
|
|
1231
|
+
impact: "the first proof never reached a verdict — the connection was destroyed under it, which is " +
|
|
1232
|
+
"what libp2p does to a relay that just refused its automatic reservation. Retrying once on " +
|
|
1233
|
+
"a fresh connection. If this succeeds the agent is reachable and nothing was wrong.",
|
|
1234
|
+
});
|
|
1235
|
+
const second = await this.#proveReservationOnce(node);
|
|
1236
|
+
return second === true;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* One attempt. `true`/`false` are VERDICTS (the relay answered); `"transport_failed"` means no
|
|
1240
|
+
* verdict was reached and the question is still open — the distinction the retry above turns on.
|
|
1241
|
+
*/
|
|
1242
|
+
async #proveReservationOnce(node) {
|
|
1243
|
+
if (this.#closed)
|
|
1244
|
+
return false;
|
|
1245
|
+
/**
|
|
1246
|
+
* ⚠️ **CLEAR THE REFUSAL FIRST — review HIGH-2.** `#lastAuthRefusal` is set when a relay REFUSES
|
|
1247
|
+
* a proof on the merits, and cleared only on auth SUCCESS. Both early returns below are
|
|
1248
|
+
* transport failures that never reach a verdict, so without this a stale refusal from an
|
|
1249
|
+
* earlier attempt survives them — and `session-relay.ts` reads `getLastAuthRefusal()` off a
|
|
1250
|
+
* CACHED client, writes it into `srRelayRefusal`, and quarantines the relay and rebuilds the
|
|
1251
|
+
* receiver when it says `tryAnotherRelay`. One real refusal followed by a later transport blip
|
|
1252
|
+
* would therefore explain the agent's unreachability with a cause that is no longer true, while
|
|
1253
|
+
* churning receivers over it. A transport failure must leave no verdict behind.
|
|
1254
|
+
*/
|
|
1255
|
+
this.#clearAuthRefusal();
|
|
1256
|
+
/**
|
|
1257
|
+
* A FAILED DIAL MUST NOT BE REPORTED AS A FAILED STREAM. This loop used to swallow every dial
|
|
1258
|
+
* error and fall through to `newStream` regardless. `#connect()` below has always tracked this
|
|
1259
|
+
* and logs `session.relay.dial.failed`; the two were written from one shape and this one lost
|
|
1260
|
+
* the check.
|
|
1261
|
+
*
|
|
1262
|
+
* ⚠️ **This branch did NOT cause the cold-login failure** (measured 2026-09-07: the dial
|
|
1263
|
+
* succeeds, because libp2p's `findExistingConnection` returns an already-open connection rather
|
|
1264
|
+
* than dialling). It is a real gap on the malformed-address and limited-connection paths, and
|
|
1265
|
+
* nothing more. Do not read its presence as the reachability fix.
|
|
1266
|
+
*/
|
|
1267
|
+
let dialed = false;
|
|
1268
|
+
let lastDialError = "";
|
|
1169
1269
|
for (const addr of this.#relayAddrs) {
|
|
1170
1270
|
try {
|
|
1171
1271
|
await node.dial(addr);
|
|
1272
|
+
dialed = true;
|
|
1172
1273
|
break;
|
|
1173
1274
|
}
|
|
1174
|
-
catch {
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
lastDialError = extractErrorMessage(err);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (!dialed && this.#relayAddrs.length > 0) {
|
|
1280
|
+
this.#logger.warn("session.relay.reservation_proof.failed", {
|
|
1281
|
+
relayPeerId: this.#relayPeerId,
|
|
1282
|
+
relayAddrs: this.#relayAddrs,
|
|
1283
|
+
reason: "dial",
|
|
1284
|
+
error: lastDialError,
|
|
1285
|
+
impact: "could not open a connection to this relay, so no proof was sent and the relay saw " +
|
|
1286
|
+
"nothing. The agent holds no reservation on it and is unreachable through it until a " +
|
|
1287
|
+
"later attempt succeeds. This is a REACHABILITY fault, not a rejection — the relay did " +
|
|
1288
|
+
"not refuse anything.",
|
|
1289
|
+
});
|
|
1290
|
+
return "transport_failed";
|
|
1175
1291
|
}
|
|
1292
|
+
// TEMPORARY INSTRUMENT (2026-09-07): snapshot immediately BEFORE newStream. Paired with the
|
|
1293
|
+
// snapshot in the catch, this localises the destruction to the newStream call itself rather
|
|
1294
|
+
// than "sometime around the failure".
|
|
1295
|
+
const snap = () => node.getConnections().map((c) => ({
|
|
1296
|
+
toThisRelay: c.peerId === this.#relayPeerId,
|
|
1297
|
+
peer: c.peerId.slice(0, 16),
|
|
1298
|
+
status: c.status,
|
|
1299
|
+
muxer: c.muxerStatus,
|
|
1300
|
+
streams: c.streamCount,
|
|
1301
|
+
protocols: c.streamProtocols,
|
|
1302
|
+
}));
|
|
1303
|
+
const before = snap();
|
|
1176
1304
|
let stream;
|
|
1177
1305
|
try {
|
|
1178
1306
|
stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
|
|
1179
1307
|
}
|
|
1180
1308
|
catch (err) {
|
|
1309
|
+
/**
|
|
1310
|
+
* ⚠️ **DO NOT COLLAPSE THIS TO `reason: "stream"`.** `CelloNode.newStream` throws STRUCTURED
|
|
1311
|
+
* errors — plain objects, not `Error`s — whose `reason` is the only field that says what
|
|
1312
|
+
* happened. This site used to overwrite it with the literal `"stream"`, so every distinct
|
|
1313
|
+
* cause reached the operator as one line. Measured 2026-09-07: a whole clean-room
|
|
1314
|
+
* investigation went to the relay servers because the cause was discarded at capture.
|
|
1315
|
+
*
|
|
1316
|
+
* The set is whatever `mapStreamError` returns — deliberately NOT enumerated here, because
|
|
1317
|
+
* the first version of this comment listed four of the six and the two it missed
|
|
1318
|
+
* (`protocol_not_supported`, `limited_connection_refused`) are exactly the ones a redial
|
|
1319
|
+
* cannot fix.
|
|
1320
|
+
*
|
|
1321
|
+
* ⚠️ **`connection_lost` IS THE CATCH-ALL, NOT A DIAGNOSIS** (`node.ts` says so). It covers a
|
|
1322
|
+
* dead muxer on a live socket, a per-protocol stream cap, and a remote reset during
|
|
1323
|
+
* multistream negotiation alike. **`muxerStatus` is what separates them**, which is why the
|
|
1324
|
+
* connections are dumped below: the transport exposes that field for this exact question and
|
|
1325
|
+
* without it the next cold-login failure is guessed at again.
|
|
1326
|
+
*
|
|
1327
|
+
* And note what this does NOT establish: the relay's handler only runs AFTER negotiation
|
|
1328
|
+
* succeeds, so a relay that reset the stream mid-negotiation would log nothing either. Relay
|
|
1329
|
+
* silence is not relay innocence.
|
|
1330
|
+
*/
|
|
1331
|
+
const raw = (err ?? {});
|
|
1332
|
+
const reason = typeof raw.reason === "string" ? raw.reason : "stream";
|
|
1181
1333
|
this.#logger.warn("session.relay.reservation_proof.failed", {
|
|
1182
|
-
relayPeerId: this.#relayPeerId,
|
|
1334
|
+
relayPeerId: this.#relayPeerId,
|
|
1335
|
+
relayAddrs: this.#relayAddrs,
|
|
1336
|
+
nodePeerId: node.getPeerId(),
|
|
1337
|
+
reason,
|
|
1338
|
+
error: extractErrorMessage(err),
|
|
1339
|
+
// The discriminator. `status` is the SOCKET; `muxerStatus` is the layer that carries data
|
|
1340
|
+
// and the one `newStream` checks first — a connection reading open/closed is the P5 shape.
|
|
1341
|
+
connsBeforeNewStream: before,
|
|
1342
|
+
connsAtFailure: snap(),
|
|
1343
|
+
nodeStatusAtFailure: node.lifecycleStatus?.() ?? "(unavailable)",
|
|
1344
|
+
impact: "no proof was sent, so this relay granted no reservation and the agent is unreachable " +
|
|
1345
|
+
"through it until a later attempt succeeds. The relay did not refuse anything — it may " +
|
|
1346
|
+
"never have seen the stream.",
|
|
1183
1347
|
});
|
|
1184
|
-
return
|
|
1348
|
+
return "transport_failed";
|
|
1185
1349
|
}
|
|
1186
1350
|
try {
|
|
1187
1351
|
const iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
@@ -1239,7 +1403,22 @@ export class AgentRelayClient {
|
|
|
1239
1403
|
stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
|
|
1240
1404
|
}
|
|
1241
1405
|
catch (err) {
|
|
1242
|
-
|
|
1406
|
+
/**
|
|
1407
|
+
* Same error substitution as `proveReservation`'s stream branch, and on the MORE important
|
|
1408
|
+
* path — this is the stream that carries `leaf_deliver` into a live session. `newStream`'s
|
|
1409
|
+
* structured `reason` was being dropped here too, leaving `connection_lost`, a stream cap and
|
|
1410
|
+
* a refused protocol indistinguishable while a conversation is running.
|
|
1411
|
+
*/
|
|
1412
|
+
const raw = (err ?? {});
|
|
1413
|
+
this.#logger.warn("session.relay.stream.failed", {
|
|
1414
|
+
relayPeerId: this.#relayPeerId,
|
|
1415
|
+
reason: typeof raw.reason === "string" ? raw.reason : "stream",
|
|
1416
|
+
error: extractErrorMessage(err),
|
|
1417
|
+
relayConnections: node
|
|
1418
|
+
.getConnections()
|
|
1419
|
+
.filter((c) => c.peerId === this.#relayPeerId)
|
|
1420
|
+
.map((c) => ({ status: c.status, muxerStatus: c.muxerStatus, streams: c.streamCount })),
|
|
1421
|
+
});
|
|
1243
1422
|
return false;
|
|
1244
1423
|
}
|
|
1245
1424
|
// ONE shared lp.decode iterator for the whole stream lifetime — splitting it signals
|