@cello-protocol/daemon 0.0.34 → 0.0.36
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/daemon.d.ts.map +1 -1
- package/dist/daemon.js +158 -35
- package/dist/daemon.js.map +1 -1
- package/dist/session-category.d.ts.map +1 -1
- package/dist/session-category.js +5 -0
- package/dist/session-category.js.map +1 -1
- package/dist/session-node-manager.d.ts +15 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +38 -2
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
package/dist/daemon.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAQrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAQ/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAsB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAO/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AA0InF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AA2CD,eAAO,MAAM,sBAAsB,QAAsB,CAAC;AAE1D,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAi9K7E"}
|
package/dist/daemon.js
CHANGED
|
@@ -463,8 +463,6 @@ export async function startDaemon(config) {
|
|
|
463
463
|
assignment: carry,
|
|
464
464
|
};
|
|
465
465
|
};
|
|
466
|
-
// Stub: all connections marked as 'unverified' until connection validation is wired
|
|
467
|
-
const connections = [];
|
|
468
466
|
// M7-SIGNAL-001: Instantiate SignalingManager — owns directory signaling stream lifecycle.
|
|
469
467
|
const defaultConnect = async () => {
|
|
470
468
|
throw new Error("directory_signaling_not_configured");
|
|
@@ -818,10 +816,12 @@ export async function startDaemon(config) {
|
|
|
818
816
|
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
819
817
|
if (!record || record.status !== "active")
|
|
820
818
|
return;
|
|
821
|
-
// M8C-CONTACT-1:
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
819
|
+
// M8C-CONTACT-1 / CC-1: select the ack wording by whether the sender is a known contact —
|
|
820
|
+
// STRANGER_TEXT ("Dispatched.") for unknown, AWAY_TEXT[kind] for known. Post-CC-1 the inbound
|
|
821
|
+
// accept path no longer auto-adds the sender, so an unattended stranger STAYS unknown across
|
|
822
|
+
// every inbound interaction (promotion now requires operator engagement — an outbound initiate,
|
|
823
|
+
// a cello_send reply, or an explicit contact add). No subsequent add is coupled to this line's
|
|
824
|
+
// ordering anymore; it is a plain read of current contact state.
|
|
825
825
|
const isKnown = sessionNodeManager.isContact(agentName, record.counterparty_pubkey);
|
|
826
826
|
awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
|
|
827
827
|
try {
|
|
@@ -1555,11 +1555,52 @@ export async function startDaemon(config) {
|
|
|
1555
1555
|
interruptedAt: row.interrupted_at ?? new Date(row.updated_at).toISOString(),
|
|
1556
1556
|
}));
|
|
1557
1557
|
}
|
|
1558
|
+
// CC-5/F21: reap dead half-open sessions on READ (compute-on-read, like reapExpiredInboundSessions —
|
|
1559
|
+
// no background timer). A session the standing receiver opened from an inbound offer the initiator
|
|
1560
|
+
// ABANDONED stays "active" forever (the counterparty never joins), clutters the open/active lists, and
|
|
1561
|
+
// its normal close fires an unsealable bilateral seal. Mark such a session terminal ("abandoned") once
|
|
1562
|
+
// it is PROVABLY dead: counterparty never established (liveness != "alive" AND 0 RECEIVED messages —
|
|
1563
|
+
// message_count alone counts our own auto-"Dispatched." ack, so it is NOT the signal) + age past the
|
|
1564
|
+
// grace TTL (a genuinely fresh session still setting up must survive).
|
|
1565
|
+
// CC-10 (live 2026-07-08 Phase-2 block): scan 'interrupted' too, not just 'active'. A daemon restart
|
|
1566
|
+
// flips dead half-opens to 'interrupted'; those classify as "failed" (invisible in every list) yet
|
|
1567
|
+
// still count toward the unknown-sender acceptance bound (D18 deliberately counts 'interrupted') — so
|
|
1568
|
+
// a stranger whose first handshakes died was silently locked out FOREVER. Reaping only 0-RECEIVED
|
|
1569
|
+
// ghosts keeps D18 intact: the disconnect-evasion attacker's sessions always carry received content.
|
|
1570
|
+
const HALF_OPEN_TTL_MS = Number(process.env["CELLO_HALF_OPEN_TTL_MS"]) || 5 * 60 * 1000;
|
|
1571
|
+
function reapDeadHalfOpenSessions(agentName) {
|
|
1572
|
+
const now = Date.now();
|
|
1573
|
+
const candidates = [...sessionNodeManager.getSessionsByStatus("active"), ...sessionNodeManager.getSessionsByStatus("interrupted")];
|
|
1574
|
+
for (const row of candidates) {
|
|
1575
|
+
if (agentName !== undefined && row.agent_name !== agentName)
|
|
1576
|
+
continue;
|
|
1577
|
+
if (now - row.created_at <= HALF_OPEN_TTL_MS)
|
|
1578
|
+
continue; // too young — may just be setting up
|
|
1579
|
+
if (sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id) === "alive")
|
|
1580
|
+
continue; // live
|
|
1581
|
+
if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
|
|
1582
|
+
continue; // counterparty spoke
|
|
1583
|
+
// Non-awaited: abandonSession flips the DB status synchronously (before its first await), so THIS
|
|
1584
|
+
// read reflects it; the async node teardown finishes in the background. CC-10 reviewer LOW: only
|
|
1585
|
+
// log "reaped" if the status flip actually wrote — a swallowed write failure already logs
|
|
1586
|
+
// session.status.write.failed, and reporting success over it would hide a still-counting ghost.
|
|
1587
|
+
void sessionNodeManager.abandonSession(row.agent_name, row.session_id)
|
|
1588
|
+
.then((flipped) => {
|
|
1589
|
+
if (flipped) {
|
|
1590
|
+
logger.info("session.half_open.reaped", { agentName: row.agent_name, sessionId: row.session_id, priorStatus: row.status, ageMs: now - row.created_at });
|
|
1591
|
+
}
|
|
1592
|
+
})
|
|
1593
|
+
.catch((err) => {
|
|
1594
|
+
logger.warn("session.half_open.reap.failed", { agentName: row.agent_name, sessionId: row.session_id, reason: err instanceof Error ? err.message : String(err) });
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1558
1598
|
// M8B F16: per-session liveness for ACTIVE sessions, shared by both status surfaces
|
|
1559
1599
|
// ("status" for the CLI, "cello_status" for MCP). The signal (session.liveness.changed,
|
|
1560
1600
|
// tracked in the node manager) existed but nothing consumed it — a dead counterparty
|
|
1561
1601
|
// was invisible to the operator.
|
|
1562
1602
|
function buildActiveSessions() {
|
|
1603
|
+
reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
|
|
1563
1604
|
return sessionNodeManager.getSessionsByStatus("active").map((row) => ({
|
|
1564
1605
|
sessionId: row.session_id,
|
|
1565
1606
|
agentName: row.agent_name,
|
|
@@ -1577,11 +1618,22 @@ export async function startDaemon(config) {
|
|
|
1577
1618
|
// M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
|
|
1578
1619
|
// no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
|
|
1579
1620
|
// aggregate below (kept for backward compatibility).
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1621
|
+
// CC-8 (F5 parity): the CLI `cello status` surface must show online vs registered like the MCP
|
|
1622
|
+
// cello_status does. The stored `a.state` is stale — it stays "registered" even when the agent is
|
|
1623
|
+
// online, because startAgentInternal only adds to onlineAgents and never mutates the record — so
|
|
1624
|
+
// derive readiness from onlineAgents here, exactly as getAgentsForConnection (F5) already does for
|
|
1625
|
+
// the MCP surface. A load_failed agent keeps its state so a broken agent stays visible as broken.
|
|
1626
|
+
// (No `selected` here: this is the daemon-wide surface; selection is a per-connection concept and
|
|
1627
|
+
// the CLI opens an ephemeral connection that never runs cello_use_agent — see M8C-DECISIONS D24.)
|
|
1628
|
+
agents: agents.map((a) => {
|
|
1629
|
+
const online = onlineAgents.has(a.name);
|
|
1630
|
+
const state = a.state === "load_failed" ? "load_failed" : online ? "online" : "registered";
|
|
1631
|
+
return {
|
|
1632
|
+
...a,
|
|
1633
|
+
state,
|
|
1634
|
+
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
|
|
1635
|
+
};
|
|
1636
|
+
}),
|
|
1585
1637
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
|
|
1586
1638
|
retryQueueDepth: retryQueue.getTotalDepth(),
|
|
1587
1639
|
interrupted_sessions,
|
|
@@ -2127,6 +2179,23 @@ export async function startDaemon(config) {
|
|
|
2127
2179
|
// committed at this point (RegistrationManager awaits the persist before returning success).
|
|
2128
2180
|
// SI-001: never log a secret — only the agent name + PUBLIC key.
|
|
2129
2181
|
logger.info("persist.identity.persisted", { agentName: name, agentPubkey: agentPubkeyHex });
|
|
2182
|
+
// CC-2 (2026-07-07): registration succeeded — arm this agent's standing receiver NOW so a
|
|
2183
|
+
// brand-new agent can receive inbound immediately. Without this the agent reports
|
|
2184
|
+
// standing_receiver_ready:false and cannot receive until the operator restarts (logout/login),
|
|
2185
|
+
// so a fresh registration looks broken. Uses the SAME idempotent path login and cello_use_agent
|
|
2186
|
+
// arm through (startAgentInternal → onlineAgents + directory signaling + ensureStandingReceiver +
|
|
2187
|
+
// agent_state_changed). A start failure must NOT fail the (already durably persisted)
|
|
2188
|
+
// registration — surface it as a warning and let the operator recover via login.
|
|
2189
|
+
const armResult = startAgentInternal(name);
|
|
2190
|
+
if (!armResult.ok) {
|
|
2191
|
+
logger.warn("registration.standing_receiver.arm_failed", { agentName: name, reason: armResult.reason });
|
|
2192
|
+
}
|
|
2193
|
+
else {
|
|
2194
|
+
// arm_INITIATED, not armed: startAgentInternal returns ok once the agent is online + signaling
|
|
2195
|
+
// is up, but ensureStandingReceiverForAgent runs fire-and-forget (its own failure emits
|
|
2196
|
+
// session.standing_receiver.ensure.failed) — so this event marks the start, not readiness.
|
|
2197
|
+
logger.info("registration.standing_receiver.arm_initiated", { agentName: name });
|
|
2198
|
+
}
|
|
2130
2199
|
// Capture-now-or-lose-it: persist the agent→user link (using it is future
|
|
2131
2200
|
// trust-layer work). L1: the agent is already registered at this point —
|
|
2132
2201
|
// a link-write failure must NOT be reported as a registration failure.
|
|
@@ -2690,7 +2759,6 @@ export async function startDaemon(config) {
|
|
|
2690
2759
|
daemon: "running",
|
|
2691
2760
|
directory_signaling: directorySignalingStatus(),
|
|
2692
2761
|
agents: getAgentsForConnection(connectionId),
|
|
2693
|
-
connections,
|
|
2694
2762
|
// M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
|
|
2695
2763
|
// the daemon-wide getStatus() surfaces them.
|
|
2696
2764
|
interrupted_sessions: buildInterruptedSessions(),
|
|
@@ -2739,10 +2807,13 @@ export async function startDaemon(config) {
|
|
|
2739
2807
|
// to the MCP response.
|
|
2740
2808
|
handlers.set("cello_initiate_session", async (params, connectionId) => {
|
|
2741
2809
|
const connState = perConnectionState.get(connectionId);
|
|
2742
|
-
|
|
2810
|
+
// CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
|
|
2811
|
+
// connection's current agent, else the sole online agent (removes the no_current_agent papercut
|
|
2812
|
+
// after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
|
|
2813
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
2814
|
+
if (!agentName) {
|
|
2743
2815
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
2744
2816
|
}
|
|
2745
|
-
const agentName = connState.currentAgent;
|
|
2746
2817
|
const correlationId = randomUUID();
|
|
2747
2818
|
// AC-004/AC-019: the advertised address is chosen from the standing receiver's
|
|
2748
2819
|
// current dialability. Not dialable (or AutoNAT unavailable) → relay circuit.
|
|
@@ -2861,7 +2932,9 @@ export async function startDaemon(config) {
|
|
|
2861
2932
|
// SI-001: no auto-seal on session_interrupted receipt; operator must call explicitly
|
|
2862
2933
|
handlers.set("cello_close_session", async (params, connectionId) => {
|
|
2863
2934
|
const connState = perConnectionState.get(connectionId);
|
|
2864
|
-
|
|
2935
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
2936
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
2937
|
+
if (!agentName) {
|
|
2865
2938
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
2866
2939
|
}
|
|
2867
2940
|
// round-2 BLOCKING: the public IPC contract field is snake_case `session_id`
|
|
@@ -2881,7 +2954,7 @@ export async function startDaemon(config) {
|
|
|
2881
2954
|
// the ownership scope. A session_id owned only by a DIFFERENT agent does not exist in this
|
|
2882
2955
|
// agent's namespace (returns null → session_not_found), which is correct for loopback (two
|
|
2883
2956
|
// agents can hold the same session_id on one daemon).
|
|
2884
|
-
const record = sessionNodeManager.getSessionRecord(
|
|
2957
|
+
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
2885
2958
|
if (!record) {
|
|
2886
2959
|
return {
|
|
2887
2960
|
ok: false,
|
|
@@ -2891,7 +2964,7 @@ export async function startDaemon(config) {
|
|
|
2891
2964
|
}
|
|
2892
2965
|
// Ownership: redundant now that the lookup is agent-scoped (record.agent_name === currentAgent),
|
|
2893
2966
|
// kept as a defensive invariant.
|
|
2894
|
-
if (record.agent_name !==
|
|
2967
|
+
if (record.agent_name !== agentName) {
|
|
2895
2968
|
return {
|
|
2896
2969
|
ok: false,
|
|
2897
2970
|
reason: "session_not_owned",
|
|
@@ -2906,6 +2979,26 @@ export async function startDaemon(config) {
|
|
|
2906
2979
|
guidance: "This session is already sealed. No further action is needed — check cello_list_sessions to view its sealed record and the FROST notarization.",
|
|
2907
2980
|
};
|
|
2908
2981
|
}
|
|
2982
|
+
// CC-5/F21 terminal-escape: force-abandon a session that can never be bilaterally sealed — a
|
|
2983
|
+
// half-open handshake the counterparty never joined, whose normal close fires a seal the absent/
|
|
2984
|
+
// rejecting counterparty can't complete (seal_interrupted_rejected_by_counterparty / timeout). Marks
|
|
2985
|
+
// it locally-terminal ("abandoned") with NO seal, so it leaves the open list. Additive: placed BEFORE
|
|
2986
|
+
// the seal branches, it never touches the seal flow. Owner-scoped (the lookup above is agent-scoped)
|
|
2987
|
+
// and idempotent. NOT for a healthy session — a normal close (no force) still seals so both parties
|
|
2988
|
+
// get a notarized receipt; force is the escape hatch for a provably unsealable ghost.
|
|
2989
|
+
if (params?.force === true) {
|
|
2990
|
+
if (record.status === "abandoned") {
|
|
2991
|
+
return { ok: true, status: "abandoned", reason: "already_abandoned", guidance: "This session was already force-abandoned." };
|
|
2992
|
+
}
|
|
2993
|
+
await sessionNodeManager.abandonSession(record.agent_name, sessionId);
|
|
2994
|
+
logger.info("session.force_abandoned", { agentName: record.agent_name, sessionId, priorStatus: record.status });
|
|
2995
|
+
return {
|
|
2996
|
+
ok: true,
|
|
2997
|
+
status: "abandoned",
|
|
2998
|
+
reason: "force_abandoned",
|
|
2999
|
+
guidance: `Session ${sessionId} was force-abandoned — marked terminal locally with no bilateral seal. Use force only for a half-open session that cannot be sealed; a normal close (no force) still attempts the seal so both parties get a notarized receipt.`,
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
2909
3002
|
// AC-011: seal-interrupted already in progress
|
|
2910
3003
|
if (sealInterruptedInProgress.has(sealKey(record.agent_name, sessionId))) {
|
|
2911
3004
|
return {
|
|
@@ -3174,10 +3267,13 @@ export async function startDaemon(config) {
|
|
|
3174
3267
|
}
|
|
3175
3268
|
// DOD-LOOP-1: the certificate is keyed by (agent, session_id) — read the current agent's.
|
|
3176
3269
|
const connState = perConnectionState.get(connectionId);
|
|
3177
|
-
|
|
3270
|
+
// CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
|
|
3271
|
+
// connection's current agent, else the sole online agent (removes the no_current_agent papercut
|
|
3272
|
+
// after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
|
|
3273
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
3274
|
+
if (!agentName) {
|
|
3178
3275
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
3179
3276
|
}
|
|
3180
|
-
const agentName = connState.currentAgent;
|
|
3181
3277
|
const cert = sessionNodeManager.getSealCertificate(agentName, sessionId);
|
|
3182
3278
|
if (cert) {
|
|
3183
3279
|
return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
|
|
@@ -3229,10 +3325,12 @@ export async function startDaemon(config) {
|
|
|
3229
3325
|
return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See cello_list_sessions." };
|
|
3230
3326
|
}
|
|
3231
3327
|
const connState = perConnectionState.get(connectionId);
|
|
3232
|
-
|
|
3328
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
3329
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
3330
|
+
if (!agentName) {
|
|
3233
3331
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
3234
3332
|
}
|
|
3235
|
-
const { messages, undecryptable } = sessionNodeManager.readTranscript(
|
|
3333
|
+
const { messages, undecryptable } = sessionNodeManager.readTranscript(agentName, sessionId);
|
|
3236
3334
|
// undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
|
|
3237
3335
|
// so the reader can tell a real gap from an empty transcript.
|
|
3238
3336
|
// M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
|
|
@@ -3293,10 +3391,13 @@ export async function startDaemon(config) {
|
|
|
3293
3391
|
// the live ones. Read from persisted SQLite, so it survives a daemon restart / fresh connection.
|
|
3294
3392
|
handlers.set("cello_list_sessions", async (params, connectionId) => {
|
|
3295
3393
|
const connState = perConnectionState.get(connectionId);
|
|
3296
|
-
|
|
3394
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
3395
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
3396
|
+
if (!agentName) {
|
|
3297
3397
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
3298
3398
|
}
|
|
3299
|
-
|
|
3399
|
+
reapDeadHalfOpenSessions(agentName); // CC-5/F21: drop provably-dead half-open sessions before listing
|
|
3400
|
+
return selectSessions(sessionNodeManager.getSessionsForAgent(agentName), params);
|
|
3300
3401
|
});
|
|
3301
3402
|
// list_sessions (daemon-wide, for the `cello sessions` CLI which has no current agent): same
|
|
3302
3403
|
// filter/limit semantics, across ALL agents.
|
|
@@ -3956,6 +4057,11 @@ export async function startDaemon(config) {
|
|
|
3956
4057
|
// senders — a per-sender cap (many sessions from ONE stranger) and a global cap (many
|
|
3957
4058
|
// sessions across ALL strangers combined). Known contacts are exempt ("bounded only by
|
|
3958
4059
|
// disk" — DoD). Checked FIRST, before any standing-receiver work, so a refusal is cheap.
|
|
4060
|
+
// CC-10: reap provably-dead ghosts for THIS agent before counting — otherwise invisible
|
|
4061
|
+
// interrupted 0-received sessions (post-restart shape) consume the sender's budget forever
|
|
4062
|
+
// and lock the stranger out with no list read ever clearing them. Safe here: abandonSession
|
|
4063
|
+
// flips the DB status synchronously, so the bound query below sees the reaped state.
|
|
4064
|
+
reapDeadHalfOpenSessions(agentName);
|
|
3959
4065
|
const bound = sessionNodeManager.checkUnknownSenderAcceptanceBound(agentName, parsed.participantAPubkeyHex);
|
|
3960
4066
|
if (!bound.ok) {
|
|
3961
4067
|
logger.warn("session.inbound.accept.failed", {
|
|
@@ -4039,12 +4145,15 @@ export async function startDaemon(config) {
|
|
|
4039
4145
|
void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
|
|
4040
4146
|
// M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
|
|
4041
4147
|
// (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
|
|
4042
|
-
// ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
|
|
4043
|
-
// soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
|
|
4044
|
-
// first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
|
|
4045
|
-
// — they become known immediately after, for every subsequent interaction).
|
|
4046
4148
|
void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
|
|
4047
|
-
|
|
4149
|
+
// CC-1 (2026-07-07): do NOT auto-add the requester here. Accepting the *connection* must not
|
|
4150
|
+
// grant *trust*. The old auto-add promoted any stranger who knocked once to "known" (Level-4
|
|
4151
|
+
// fast-track), which defeated BOTH the screening layer AND the ABUSE-1 acceptance caps
|
|
4152
|
+
// (checkUnknownSenderAcceptanceBound exempts contacts, so sessions 2+ bypassed the per-sender
|
|
4153
|
+
// cap — confirmed live 2026-07-07). Promotion to "known" now requires operator engagement only:
|
|
4154
|
+
// an outbound cello_initiate_session (below, ~3137), the operator replying INTO the session via
|
|
4155
|
+
// cello_send, or an explicit cello_contact_add. An unattended stranger is never auto-whitelisted.
|
|
4156
|
+
// See docs/planning/.../2026-07-07_1700_four-level-screening-policy.md (D21).
|
|
4048
4157
|
}
|
|
4049
4158
|
finally {
|
|
4050
4159
|
inboundInFlight.delete(parsed.sessionIdHex);
|
|
@@ -4233,10 +4342,13 @@ export async function startDaemon(config) {
|
|
|
4233
4342
|
// contract (core/adapter-claude-code/src/server.ts) so the E2E fixture migration is drop-in.
|
|
4234
4343
|
handlers.set("cello_await_session", async (params, connectionId) => {
|
|
4235
4344
|
const connState = perConnectionState.get(connectionId);
|
|
4236
|
-
|
|
4345
|
+
// CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
|
|
4346
|
+
// connection's current agent, else the sole online agent (removes the no_current_agent papercut
|
|
4347
|
+
// after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
|
|
4348
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
4349
|
+
if (!agentName) {
|
|
4237
4350
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
4238
4351
|
}
|
|
4239
|
-
const agentName = connState.currentAgent;
|
|
4240
4352
|
const timeoutMs = typeof params?.["timeout_ms"] === "number" ? params["timeout_ms"] : 30_000;
|
|
4241
4353
|
const toResponse = (e) => ({
|
|
4242
4354
|
type: "new_session",
|
|
@@ -4670,7 +4782,9 @@ export async function startDaemon(config) {
|
|
|
4670
4782
|
// ─── CELLO-M7-DAEMON-004: cello_send (live send + daemon-owned tree append) ──
|
|
4671
4783
|
handlers.set("cello_send", async (params, connectionId) => {
|
|
4672
4784
|
const connState = perConnectionState.get(connectionId);
|
|
4673
|
-
|
|
4785
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > current > sole online agent.
|
|
4786
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
4787
|
+
if (!agentName)
|
|
4674
4788
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
4675
4789
|
// round-2 BLOCKING: read the snake_case public field cello-mcp.ts actually sends.
|
|
4676
4790
|
const sessionId = params?.session_id;
|
|
@@ -4679,11 +4793,11 @@ export async function startDaemon(config) {
|
|
|
4679
4793
|
return { ok: false, reason: "missing_params", guidance: "Provide 'session_id' (hex) and 'content' (string) parameters." };
|
|
4680
4794
|
}
|
|
4681
4795
|
// DOD-LOOP-1: the (agent, session_id) lookup is itself the ownership scope.
|
|
4682
|
-
const record = sessionNodeManager.getSessionRecord(
|
|
4796
|
+
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
4683
4797
|
if (!record) {
|
|
4684
4798
|
return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_list_sessions for active sessions." };
|
|
4685
4799
|
}
|
|
4686
|
-
if (record.agent_name !==
|
|
4800
|
+
if (record.agent_name !== agentName) {
|
|
4687
4801
|
return { ok: false, reason: "session_not_owned", guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it, then retry." };
|
|
4688
4802
|
}
|
|
4689
4803
|
if (record.status !== "active") {
|
|
@@ -4849,6 +4963,14 @@ export async function startDaemon(config) {
|
|
|
4849
4963
|
// blocked by session_not_current on its own just-sent message.
|
|
4850
4964
|
advanceConnectionCursor(connectionId, sessionId, leafIndex);
|
|
4851
4965
|
void newRootHex;
|
|
4966
|
+
// CC-1 (2026-07-07): operator engagement promotes the counterparty to a known contact. A
|
|
4967
|
+
// committed reply — past the read-before-write gate, content now on the wire AND in the tree —
|
|
4968
|
+
// IS the operator choosing to trust this sender; the inbound-accept path deliberately no longer
|
|
4969
|
+
// auto-adds (that defeated screening + anti-spam). For an OUTBOUND session the counterparty is
|
|
4970
|
+
// already a contact (cello_initiate_session added it), so this is an idempotent no-op there; it
|
|
4971
|
+
// matters for inbound-originated sessions, where the reply is the trust signal. addContact is
|
|
4972
|
+
// INSERT OR IGNORE — it never refreshes added_at.
|
|
4973
|
+
sessionNodeManager.addContact(record.agent_name, recipientPubkey);
|
|
4852
4974
|
if (modified) {
|
|
4853
4975
|
logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
|
|
4854
4976
|
}
|
|
@@ -4900,9 +5022,10 @@ export async function startDaemon(config) {
|
|
|
4900
5022
|
const RECEIVE_DEFAULT_TIMEOUT_MS = 30000; // matches the cello-mcp shim's documented default
|
|
4901
5023
|
const handleReceive = async (params, connectionId) => {
|
|
4902
5024
|
const connState = perConnectionState.get(connectionId);
|
|
4903
|
-
|
|
5025
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > current > sole online agent.
|
|
5026
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
5027
|
+
if (!agentName)
|
|
4904
5028
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
4905
|
-
const agentName = connState.currentAgent;
|
|
4906
5029
|
// Read the snake_case public field cello-mcp.ts actually sends.
|
|
4907
5030
|
const sessionId = params?.session_id;
|
|
4908
5031
|
if (!sessionId) {
|