@securecoms/agent-bridge 0.26.4 → 0.26.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +202 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +260 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +192 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +477 -0
- package/dist/bin.js +401 -75
- package/dist/bridge-agent.d.ts +157 -4
- package/dist/consumer-grants.d.ts +22 -0
- package/dist/harness-report.d.ts +14 -0
- package/dist/http-server.d.ts +10 -0
- package/dist/index.js +401 -75
- package/dist/room-tools.d.ts +23 -0
- package/dist/service/runtime-config-dir.d.ts +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +0 -77
- package/package.json +1 -1
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
|
|
2
|
+
import type { HarnessReport } from "./harness-report.js";
|
|
2
3
|
import type { MemoryContextResult } from "@agentvault/connect-mls-store";
|
|
3
4
|
/**
|
|
4
5
|
* M6 (ADR-0013 Decision H): what the sweep observed about one channel.
|
|
@@ -58,9 +59,15 @@ export type BridgeToolGrant = {
|
|
|
58
59
|
* Tool grants (architect ruling, 2026-10-02): until channel-grant persistence
|
|
59
60
|
* lands, the ONLY non-empty grant list is OWNER_DM_DEFAULT_READ_GRANTS
|
|
60
61
|
* (`current_time` + `web_search`, read mode), and only on a DM whose sender is
|
|
61
|
-
*
|
|
62
|
+
* that DM's pinned human counterpart AND server-marked `is_agent_owner` (or a
|
|
63
|
+
* relayed voice turn whose speaker is). The pin is per channel, so the
|
|
64
|
+
* owner distinction rests on the server's flag: see authorizationFor's LIMIT
|
|
65
|
+
* note. Every
|
|
62
66
|
* other audience, an unpinned or anomalous owner, and the kill switch
|
|
63
67
|
* `SECURECOMS_OWNER_DM_DEFAULT_GRANTS` (anything but unset/`on`) get `[]`.
|
|
68
|
+
* PR R adds exactly one more non-empty list, ROOM_DEFAULT_READ_GRANTS
|
|
69
|
+
* (`current_time` only), for an INTERNAL room's same-org human sender; see
|
|
70
|
+
* authorizationFor.
|
|
64
71
|
* SEC-06 is LIVE for these two tools: docs/security/sec-06-runtime-bounds-audit.md.
|
|
65
72
|
*/
|
|
66
73
|
/**
|
|
@@ -71,17 +78,42 @@ export type BridgeToolGrant = {
|
|
|
71
78
|
* and `http_request` are deliberately absent.
|
|
72
79
|
*/
|
|
73
80
|
export declare const OWNER_DM_DEFAULT_READ_GRANTS: readonly BridgeToolGrant[];
|
|
81
|
+
/**
|
|
82
|
+
* PR R (spec §7A.1a; controller ruling 2026-10-05, audit r1: current_time
|
|
83
|
+
* only). Granted only in a room classified INTERNAL from the server roster
|
|
84
|
+
* (every row, leaf rows included, in the agent owner's org), to a same-org
|
|
85
|
+
* human sender, while the roster is as fresh as the agent's MLS epoch, while
|
|
86
|
+
* a declared per-tool consumer is attached (audit C-B1), and while
|
|
87
|
+
* SECURECOMS_ROOM_DEFAULT_GRANTS is on (plan OQ-3). Frozen; the runtime intersects
|
|
88
|
+
* it with ROOM_SAFE_READ_TOOLS (turn-loop.ts).
|
|
89
|
+
*/
|
|
90
|
+
export declare const ROOM_DEFAULT_READ_GRANTS: readonly BridgeToolGrant[];
|
|
74
91
|
/**
|
|
75
92
|
* Kill switch for OWNER_DM_DEFAULT_READ_GRANTS, read once at BridgeAgent
|
|
76
93
|
* construction (daemon startup). Unset or exactly `on` keeps the grants;
|
|
77
94
|
* any other value (`off`, `0`, `false`, empty) sends `[]` on every turn.
|
|
78
95
|
*/
|
|
79
96
|
export declare function ownerDmDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Kill switch for ROOM_DEFAULT_READ_GRANTS (plan OQ-3, RULED), read once at
|
|
99
|
+
* BridgeAgent construction exactly like ownerDmDefaultGrantsEnabled. Unset or
|
|
100
|
+
* exactly `on` keeps the room default; any other value sends `[]` on every
|
|
101
|
+
* room turn. It never touches the owner DM.
|
|
102
|
+
*/
|
|
103
|
+
export declare function roomDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
80
104
|
export interface BridgeAuthorization {
|
|
81
105
|
readonly version: 1;
|
|
82
106
|
readonly audience: AgentAudience;
|
|
83
107
|
readonly sender_membership: "active" | "unknown";
|
|
84
108
|
readonly tool_grants: readonly BridgeToolGrant[];
|
|
109
|
+
/**
|
|
110
|
+
* Audit r2 R2-T1 (plan OQ-6 ruling): "owner" ONLY on authorizationFor's
|
|
111
|
+
* ownerRelayed branch: the channel's server-marked relay sent a turn whose
|
|
112
|
+
* relayed speaker is the channel's pinned owner. Set by the daemon, never
|
|
113
|
+
* copied from message text. A harness connector honours a grant on
|
|
114
|
+
* audience owner_dm or on this mark, nothing else.
|
|
115
|
+
*/
|
|
116
|
+
readonly relay_speaker?: "owner";
|
|
85
117
|
}
|
|
86
118
|
export interface BridgeMessage {
|
|
87
119
|
channel_id: string;
|
|
@@ -513,6 +545,15 @@ export declare class SendForbiddenError extends Error {
|
|
|
513
545
|
readonly channelId: string;
|
|
514
546
|
constructor(channelId: string);
|
|
515
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* Audit M-B1 (controller ruling 2026-10-05): a reply to a room-granted turn
|
|
550
|
+
* would be sealed at an epoch the room was not classified at, and the room is
|
|
551
|
+
* not internal there. Dropped. A SendForbiddenError, so both transports treat
|
|
552
|
+
* it as terminal (send_forbidden: the runtime acks, nothing redelivers).
|
|
553
|
+
*/
|
|
554
|
+
export declare class RoomClassChangedSendError extends SendForbiddenError {
|
|
555
|
+
constructor(channelId: string);
|
|
556
|
+
}
|
|
516
557
|
/**
|
|
517
558
|
* Stranded sender (spec 2026-09-26 §7.4): the backend refused a publish with
|
|
518
559
|
* 409 `mls.group_superseded`: the envelope was sealed under a group that is no
|
|
@@ -892,6 +933,21 @@ export declare class BridgeAgent {
|
|
|
892
933
|
private readonly selfHealSweepEnabled;
|
|
893
934
|
/** Read once at startup; see ownerDmDefaultGrantsEnabled. */
|
|
894
935
|
private readonly ownerDmDefaultGrants;
|
|
936
|
+
/** Read once at startup; see roomDefaultGrantsEnabled. */
|
|
937
|
+
private readonly roomDefaultGrants;
|
|
938
|
+
/**
|
|
939
|
+
* Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
|
|
940
|
+
* cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
|
|
941
|
+
*/
|
|
942
|
+
private readonly ownerDmCycleFresh;
|
|
943
|
+
/**
|
|
944
|
+
* Audit M-B1: room-granted turns, keyed by the inbound message id, with the
|
|
945
|
+
* channel, MLS group and epoch the room was classified at (review I-1: an
|
|
946
|
+
* epoch NUMBER alone repeats across channels and across a supersede). A
|
|
947
|
+
* reply to one is sealed only there, or re-classified once
|
|
948
|
+
* (publishBodyForReply). Forgotten at the runtime's ack.
|
|
949
|
+
*/
|
|
950
|
+
private readonly roomGrantEpochs;
|
|
895
951
|
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
896
952
|
private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
|
|
897
953
|
/**
|
|
@@ -1505,6 +1561,25 @@ export declare class BridgeAgent {
|
|
|
1505
1561
|
* there so a retry can resend the exact bytes).
|
|
1506
1562
|
*/
|
|
1507
1563
|
private publishBody;
|
|
1564
|
+
/**
|
|
1565
|
+
* Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
|
|
1566
|
+
* only at the epoch the room was classified at. On a move, the room is
|
|
1567
|
+
* re-classified ONCE from a roster fetched at the new epoch: still internal,
|
|
1568
|
+
* the reply is sealed at that epoch; anything else, it is dropped. The check
|
|
1569
|
+
* throws before an envelope is built, so nothing is stored or posted.
|
|
1570
|
+
*
|
|
1571
|
+
* `inReplyTo` is the STORED intent's inbound id, not the send's options, so
|
|
1572
|
+
* the redelivery replay (replayReplyIntents, which has no in_reply_to in its
|
|
1573
|
+
* options) is checked the same as a live send.
|
|
1574
|
+
*/
|
|
1575
|
+
private publishBodyForReply;
|
|
1576
|
+
/**
|
|
1577
|
+
* Re-read the room at the agent's current group and epoch; true only if it
|
|
1578
|
+
* is still internal there. Review I-1: the entry must come from THIS re-read
|
|
1579
|
+
* (a fail-soft refresh leaves an older entry whose epoch label can repeat
|
|
1580
|
+
* across a supersede), and the group must not move around the reads.
|
|
1581
|
+
*/
|
|
1582
|
+
private reclassifyForReply;
|
|
1508
1583
|
private enforceBodyRateFloor;
|
|
1509
1584
|
/** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
|
|
1510
1585
|
private afterPublish;
|
|
@@ -1943,6 +2018,19 @@ export declare class BridgeAgent {
|
|
|
1943
2018
|
private enclavesToCover;
|
|
1944
2019
|
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
1945
2020
|
private freshRoster;
|
|
2021
|
+
/** The whole cache entry when fresh, else undefined (TTL; the epoch is checked by the caller). */
|
|
2022
|
+
private freshRosterEntry;
|
|
2023
|
+
/** The agent's held MLS group id for a channel; null when none (fail closed). */
|
|
2024
|
+
private heldGroupFor;
|
|
2025
|
+
/**
|
|
2026
|
+
* Review I-1 + fix round 2: the agent's held (group, epoch) for a channel.
|
|
2027
|
+
* The group is read on BOTH sides of the epoch read, so the epoch provably
|
|
2028
|
+
* belongs to it (a supersede in between installs a new group whose epoch
|
|
2029
|
+
* counter restarts); a group that moved, or none held, is null (fail closed).
|
|
2030
|
+
*/
|
|
2031
|
+
private heldPosition;
|
|
2032
|
+
/** The agent's own MLS epoch for a channel; null when no group is held (fail closed). */
|
|
2033
|
+
private heldEpochFor;
|
|
1946
2034
|
/**
|
|
1947
2035
|
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
1948
2036
|
* GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
|
|
@@ -1951,6 +2039,15 @@ export declare class BridgeAgent {
|
|
|
1951
2039
|
* attribution must NEVER block or drop a message.
|
|
1952
2040
|
*/
|
|
1953
2041
|
private refreshChannelRoster;
|
|
2042
|
+
/**
|
|
2043
|
+
* S-1 (roster) + §7A.5 + audit M-B4: once per ingest cycle, per distinct
|
|
2044
|
+
* channel, re-read the roster when the TTL lapsed OR the epoch moved since
|
|
2045
|
+
* the fetch, then record which DMs are epoch-fresh for the rest of this
|
|
2046
|
+
* cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
|
|
2047
|
+
*/
|
|
2048
|
+
private refreshRostersForCycle;
|
|
2049
|
+
/** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
|
|
2050
|
+
private authorizeTurn;
|
|
1954
2051
|
/**
|
|
1955
2052
|
* Classify the sender only from server-authenticated channel/roster data.
|
|
1956
2053
|
* Missing or stale metadata is UNKNOWN with zero grants, never a permissive
|
|
@@ -1959,14 +2056,32 @@ export declare class BridgeAgent {
|
|
|
1959
2056
|
*
|
|
1960
2057
|
* Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
|
|
1961
2058
|
* the kill switch is on, the channel is a DM, and EITHER
|
|
1962
|
-
* (a) audience is owner_dm AND the sender is the pinned
|
|
1963
|
-
* (`channelOwner`, never "anomalous")
|
|
1964
|
-
*
|
|
2059
|
+
* (a) audience is owner_dm AND the sender is the channel's pinned DM
|
|
2060
|
+
* counterpart (`channelOwner`, never "anomalous"). A server-marked
|
|
2061
|
+
* `is_agent_owner` on a member who is NOT the DM's single human
|
|
2062
|
+
* counterpart gets nothing; on the counterpart it is trusted (see
|
|
2063
|
+
* LIMIT below); OR
|
|
1965
2064
|
* (b) the sender is this channel's server-marked relay (`is_relay`) and the
|
|
1966
2065
|
* relayed `speaker_identity_id` (passed by the caller only for relay
|
|
1967
2066
|
* turns) is that same pinned owner, whose roster row is the agent's
|
|
1968
2067
|
* owner. A speaker claim from any non-relay sender gets nothing.
|
|
2068
|
+
* Both need the roster fetched at `heldEpoch` (the agent's current MLS
|
|
2069
|
+
* epoch for the channel), or the DM marked epoch-fresh at this ingest
|
|
2070
|
+
* cycle's refresh (audit M-B4).
|
|
2071
|
+
* PR R: ROOM_DEFAULT_READ_GRANTS only when the room kill switch is on, a
|
|
2072
|
+
* per-tool consumer is attached, the roster was fetched at `heldEpoch`
|
|
2073
|
+
* (no cycle carve-out), the room is INTERNAL (roomIsCrossOrg === false
|
|
2074
|
+
* over every row, leaf rows included) and the audience is team_room.
|
|
1969
2075
|
* Everything else gets the frozen empty list.
|
|
2076
|
+
*
|
|
2077
|
+
* LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
|
|
2078
|
+
* is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
|
|
2079
|
+
* ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
|
|
2080
|
+
* separates Bob from the real owner is only the server's `is_agent_owner`
|
|
2081
|
+
* flag (connect_channels.py: counterpart user == agent's creator). A
|
|
2082
|
+
* backend that sets that flag wrongly gives Bob the grants, and a harness
|
|
2083
|
+
* connector turns grants into its full toolset. Binding the owner identity
|
|
2084
|
+
* once, outside server control, is an open design follow-up.
|
|
1970
2085
|
*/
|
|
1971
2086
|
private authorizationFor;
|
|
1972
2087
|
/**
|
|
@@ -1985,6 +2100,13 @@ export declare class BridgeAgent {
|
|
|
1985
2100
|
* startup: `on` = default grants enabled
|
|
1986
2101
|
* meta_fresh / roster_fresh whether the channel meta / roster caches were
|
|
1987
2102
|
* fresh (either stale forces audience=unknown)
|
|
2103
|
+
* room_class the room's class from the server roster (`na`
|
|
2104
|
+
* for a DM): internal | cross_org | unknown
|
|
2105
|
+
* epoch_fresh the roster was fetched at the agent's current
|
|
2106
|
+
* MLS epoch for the channel
|
|
2107
|
+
* room_kill_switch SECURECOMS_ROOM_DEFAULT_GRANTS as read at startup
|
|
2108
|
+
* per_tool_consumer a consumer that declared per-tool grant
|
|
2109
|
+
* enforcement is attached
|
|
1988
2110
|
*/
|
|
1989
2111
|
private logAuthzDecision;
|
|
1990
2112
|
/**
|
|
@@ -2298,6 +2420,31 @@ export declare class BridgeAgent {
|
|
|
2298
2420
|
* ws-server.ts right after prior.cleanup() in the eviction path.
|
|
2299
2421
|
*/
|
|
2300
2422
|
noteSessionEvicted(): void;
|
|
2423
|
+
/** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
|
|
2424
|
+
private harness;
|
|
2425
|
+
/**
|
|
2426
|
+
* Record a connector's harness report (from its /v1/ws hello). WARN once
|
|
2427
|
+
* each time the attached harness turns to an inactive veto: such a harness
|
|
2428
|
+
* refuses every turn without a tool grant, so its rooms go quiet.
|
|
2429
|
+
*/
|
|
2430
|
+
recordHarnessReport(report: HarnessReport): void;
|
|
2431
|
+
/** Forget `report` when the connector that sent it disconnects. A report
|
|
2432
|
+
* from a newer connector (a different object) is kept. */
|
|
2433
|
+
clearHarnessReport(report: HarnessReport): void;
|
|
2434
|
+
/** The latest harness report, or null (GET /v1/status). */
|
|
2435
|
+
harnessReport(): HarnessReport | null;
|
|
2436
|
+
/**
|
|
2437
|
+
* Audit C-B1: the attached /v1/ws consumer that declared per-tool grant
|
|
2438
|
+
* enforcement (its hello carried "per_tool_grants" and no harness report),
|
|
2439
|
+
* or null. authorizationFor issues a room grant only while one is attached
|
|
2440
|
+
* (Task R2); every delivery path also strips room grants for any other
|
|
2441
|
+
* consumer (consumer-grants.ts). The token is the socket's session object.
|
|
2442
|
+
*/
|
|
2443
|
+
private perToolConsumer;
|
|
2444
|
+
notePerToolConsumer(token: object): void;
|
|
2445
|
+
/** Forget `token` when its socket closes; a newer consumer's token is kept. */
|
|
2446
|
+
clearPerToolConsumer(token: object): void;
|
|
2447
|
+
perToolConsumerAttached(): boolean;
|
|
2301
2448
|
health(): {
|
|
2302
2449
|
status: "ok";
|
|
2303
2450
|
identity_id: string;
|
|
@@ -2369,6 +2516,12 @@ export declare class BridgeAgent {
|
|
|
2369
2516
|
* agent_sessions stays >=1 means two connectors are flapping the same
|
|
2370
2517
|
* identity (split-brain), not one stable harness. */
|
|
2371
2518
|
evictions: number;
|
|
2519
|
+
/** Harness tool gate H4: is the attached harness's tool veto active?
|
|
2520
|
+
* null when no connector is attached with a report. false means that
|
|
2521
|
+
* harness refuses every turn without a tool grant. Only this boolean is
|
|
2522
|
+
* on the unauthenticated /healthz; name and version are on the
|
|
2523
|
+
* authenticated GET /v1/status. */
|
|
2524
|
+
harness_tool_veto_active: boolean | null;
|
|
2372
2525
|
uptime_s: number;
|
|
2373
2526
|
version: string;
|
|
2374
2527
|
/** M6: is the self-heal sweep running at all? It is the SOLE writer of the
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BridgeMessage } from "./bridge-agent.js";
|
|
2
|
+
/**
|
|
3
|
+
* The /v1/ws hello feature a consumer sends to declare per-tool grant
|
|
4
|
+
* enforcement. The runtime exports the same value (connect-agent-runtime
|
|
5
|
+
* data-plane.ts PER_TOOL_GRANTS_FEATURE); the two packages share no
|
|
6
|
+
* dependency, so ws-server.test.ts pins them equal and drives the bridge with
|
|
7
|
+
* the runtime's own hello frame.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
|
|
10
|
+
/**
|
|
11
|
+
* `m` unchanged for a per-tool consumer; otherwise `m` with any room grant
|
|
12
|
+
* stripped to []. The owner's own turns, which a harness has always been
|
|
13
|
+
* granted for, pass unchanged: the owner's DM, and the owner-spoken relayed
|
|
14
|
+
* voice turn the daemon marks relay_speaker "owner" (audit r2 R2-T1; the SAME
|
|
15
|
+
* rule the connectors apply, Task R2a). Everything else with grants is
|
|
16
|
+
* stripped: an allowlist fails closed on any new granted audience.
|
|
17
|
+
*
|
|
18
|
+
* Never mutates `m`: the buffer and in-flight records keep the original, so a
|
|
19
|
+
* later per-tool consumer still receives it. The stripped copy keeps every
|
|
20
|
+
* other field (audience, relay_speaker) as is.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withConsumerGrants(m: BridgeMessage, perToolConsumer: boolean): BridgeMessage;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A connector's self-report, carried on its `/v1/ws` hello (harness tool
|
|
3
|
+
* gate, fix round 1 H4): which harness is attached and whether its tool veto
|
|
4
|
+
* is active. Untrusted input from a loopback client: every field is closed to
|
|
5
|
+
* a known set or a bounded version token, so only booleans/versions reach
|
|
6
|
+
* /healthz, GET /v1/status and the service log.
|
|
7
|
+
*/
|
|
8
|
+
export interface HarnessReport {
|
|
9
|
+
readonly name: "hermes" | "openclaw" | "other";
|
|
10
|
+
readonly version: string | null;
|
|
11
|
+
/** "inactive": the harness refuses every turn without a tool grant. */
|
|
12
|
+
readonly tool_veto: "active" | "inactive" | "unknown";
|
|
13
|
+
}
|
|
14
|
+
export declare function parseHarnessReport(raw: unknown): HarnessReport | null;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -16,6 +16,16 @@ export declare function writeSse(res: {
|
|
|
16
16
|
writableEnded?: boolean;
|
|
17
17
|
write: (s: string) => boolean;
|
|
18
18
|
}, msgs: BridgeMessage[], onWriteError?: (error: unknown) => void): BridgeMessage[];
|
|
19
|
+
/**
|
|
20
|
+
* Audit C-B1 / r2 R2-T2: deliver one SSE batch. An SSE consumer never declares
|
|
21
|
+
* per-tool enforcement, so the WRITTEN copies have room grants stripped; the
|
|
22
|
+
* unwritten tail is requeued as the ORIGINAL messages, never a stripped copy,
|
|
23
|
+
* so a later per-tool consumer still receives the grant.
|
|
24
|
+
*/
|
|
25
|
+
export declare function deliverSseBatch(res: Parameters<typeof writeSse>[0], batch: BridgeMessage[], agent: {
|
|
26
|
+
ackRuntimeMessages(ids: string[]): void;
|
|
27
|
+
requeueFront(msgs: BridgeMessage[]): void;
|
|
28
|
+
}, onWriteError?: (error: unknown) => void): void;
|
|
19
29
|
/**
|
|
20
30
|
* Once-per-connection write-failure reporter (#335). A throwing `res.write`
|
|
21
31
|
* means the socket is dying — teardown follows via the request's `'close'` —
|