From 30b95563559f52ae7c6ea6a90d229865583dce2d Mon Sep 17 00:00:00 2001 From: Samuel Date: Sat, 7 Nov 2026 12:14:50 +0100 Subject: [PATCH] feat(gateway): add authenticated loopback text relay (#2624) Adds a relay connector to the gateway: an HTTP loopback transport to an operator-owned bridge process, configured per connector from an absolute secrets file (OPENRIG_RELAY_URL, OPENRIG_RELAY_OUTBOUND_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN), with no environment fallback. Outbound, each decision freezes its complete relayEnvelope on its DispatchBuffer entry before the first attempt and posts it whole, once per attempt, with deliveryId set to the decision ID; a 2xx records relay-owner-notification-accepted and drains the entry, a failure records relay-owner-notification-transport-failed and keeps it for replay of the same bytes, and a stop mid-attempt records neither. Inbound, POST /api/gateway/relay/inbound requires the connector's bearer token from a direct loopback socket peer (forwarding headers are ignored), attributes the reply by sender.handle against that connector's registered people, and commits the answer or resolve with its audit record through the Mission Control write contract, deduplicating an exact replay of the same eventId. Inbound size limits are a documented protocol contract, outbound references are fixed-width digests that the inbound contract accepts whole, and a ledger whose state can't be read returns a scoped retryable 503. Readiness warns, rather than going unready, on group or other bits on a readable secrets file and on one token used for both directions within a connector. docs/reference/gateway-relay.md describes the topology, custody, configuration and wire protocol. --- docs/reference/gateway-participants.md | 5 +- docs/reference/gateway-relay.md | 239 ++ packages/cli/src/commands/gateway.ts | 22 +- .../test/gateway-human-registry-verb.test.ts | 17 + .../domain/gateway/connections-projection.ts | 46 +- .../daemon/src/domain/gateway/dispatcher.ts | 17 +- .../src/domain/gateway/gateway-subsystem.ts | 3 + .../src/domain/gateway/human-readiness.ts | 51 + .../src/domain/gateway/human-registry.ts | 112 +- .../gateway/operator-delivery-engine.ts | 9 +- .../daemon/src/domain/gateway/relay-config.ts | 420 +++ .../src/domain/gateway/relay-inbound.ts | 897 +++++++ .../src/domain/gateway/relay-outbound.ts | 939 +++++++ .../src/domain/gateway/slack/open-asks.ts | 16 +- .../domain/gateway/slack/slack-subsystem.ts | 269 +- .../mission-control-write-contract.ts | 153 +- .../src/domain/policies/quiet-release.ts | 9 +- .../daemon/src/domain/queue-repository.ts | 250 +- .../daemon/src/domain/queue-transition-log.ts | 264 +- .../daemon/src/domain/queue-wake-ladder.ts | 14 +- .../daemon/src/domain/quiet-hold-custody.ts | 13 +- packages/daemon/src/routes/gateway.ts | 98 +- packages/daemon/src/server.ts | 11 +- packages/daemon/src/startup.ts | 17 +- .../test/archive-identity-provenance.test.ts | 230 +- .../test/complete-human-notifications.test.ts | 140 +- .../daemon/test/decision-revision.test.ts | 187 +- .../daemon/test/delivery-rules-engine.test.ts | 139 +- .../daemon/test/fixtures/recording-relay.ts | 601 +++++ .../test/gateway-channel-lifecycle.test.ts | 88 +- .../daemon/test/gateway-human-handle.test.ts | 97 + .../test/gateway-human-readiness.test.ts | 243 +- .../test/gateway-human-registry.test.ts | 80 + .../daemon/test/gateway-relay-inbound.test.ts | 2392 +++++++++++++++++ .../test/gateway-relay-integration.test.ts | 1261 +++++++++ .../test/gateway-subsystem-activation.test.ts | 103 + .../test/gateway-subsystem-wire.test.ts | 177 ++ .../test/queue-list-delivery-ledger.test.ts | 85 + .../daemon/test/queue-wake-ladder.test.ts | 75 + .../daemon/test/quiet-hold-custody.test.ts | 109 +- .../daemon/test/quiet-hold-release.test.ts | 240 ++ packages/daemon/test/relay-config.test.ts | 302 +++ packages/daemon/test/relay-outbound.test.ts | 1580 +++++++++++ .../daemon/test/slack-post-open-asks.test.ts | 141 + .../test/slack-subsystem-cutover.test.ts | 410 +++ .../daemon/test/steering-only-asks.test.ts | 110 +- packages/tui/src/attention/attention-model.ts | 27 +- .../tui/test/feed-open-human-updates.test.ts | 42 + 48 files changed, 12552 insertions(+), 198 deletions(-) create mode 100644 docs/reference/gateway-relay.md create mode 100644 packages/daemon/src/domain/gateway/relay-config.ts create mode 100644 packages/daemon/src/domain/gateway/relay-inbound.ts create mode 100644 packages/daemon/src/domain/gateway/relay-outbound.ts create mode 100644 packages/daemon/test/fixtures/recording-relay.ts create mode 100644 packages/daemon/test/gateway-relay-inbound.test.ts create mode 100644 packages/daemon/test/gateway-relay-integration.test.ts create mode 100644 packages/daemon/test/relay-config.test.ts create mode 100644 packages/daemon/test/relay-outbound.test.ts diff --git a/docs/reference/gateway-participants.md b/docs/reference/gateway-participants.md index f7352a0e2..ebd38ea26 100644 --- a/docs/reference/gateway-participants.md +++ b/docs/reference/gateway-participants.md @@ -14,7 +14,7 @@ returns `503` with `error: "human_registry_unavailable"`. The result does not in ## Participants A participant is either: -- **a person**, registered with `rig gateway human add`, with or without a connector binding; or +- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or - **a shared channel**: one vetted Slack channel registered as one participant, `@external`, not a person per sender. @@ -22,6 +22,9 @@ People and channels share one address space: a channel can't take a person's id. A registered person is addressed as `@external`, including without Slack: for example, `rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering. +A person can also bind to a local bridge adapter using a `relay` binding: +`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"` +(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture). `human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address. These aliases (including `human-@host` and `human-@kernel`) are not agent sessions: reply with `rig queue create --destination human@host --human-intent update --summary "" --evidence-ref "" --body "..." --verify`, diff --git a/docs/reference/gateway-relay.md b/docs/reference/gateway-relay.md new file mode 100644 index 000000000..4207f5436 --- /dev/null +++ b/docs/reference/gateway-relay.md @@ -0,0 +1,239 @@ +# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference + +OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings. + +OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms. + +``` ++--------------------+ HTTP Loopback +--------------------+ +| OpenRig Daemon | POST ---> | Operator Bridge | +| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) | ++--------------------+ +--------------------+ + | + Platform-specific transport + v + +--------------------+ + | External Platform | + | (Mobile / Client) | + +--------------------+ +``` + +--- + +## 1. System Topology & Custody Boundaries + +### Outbound Custody & Recovery +- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`. +- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting. +- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = ` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping). +- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently. +- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt. +- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout. + +### Inbound Custody & Resolution +- The operator bridge posts replies to `POST /api/gateway/relay/inbound`. +- Requests require `Authorization: Bearer ` matching a configured, ready relay profile. +- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token. +- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform. +- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`). + +### Exclusions & Scope +The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends. + +--- + +## 2. Secrets & Environment Configuration + +Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:`). + +### Security & File Requirements +- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected. +- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`. +- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file. +- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional. + +### Required Secrets Keys + +```ini +OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook +OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value +OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value +``` + +- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected. +- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge. +- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies. + +--- + +## 3. Wire Protocol Specifications + +### Outbound Envelope (`POST `) + +```json +{ + "v": 1, + "deliveryId": "dec-019283-abc", + "recipient": { "handle": "alice-ops" }, + "intent": "decision", + "outcome": "interrupt", + "summary": "Database Migration Approval", + "body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.", + "replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6..." +} +``` + +- `v`: Protocol version integer (`1`). +- `deliveryId`: Stable whole-decision delivery identifier (``). +- `recipient.handle`: Recipient's registered safe alias for this connector. +- `intent`: `"decision"` (actionable question) or `"update"` (informational notice). +- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`). +- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`). +- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting. +- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests). +- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`). + +### Inbound Envelope (`POST /api/gateway/relay/inbound`) + +```json +{ + "eventId": "evt-relay-20261107-001", + "sender": { "handle": "alice-ops" }, + "replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...", + "text": "Approved. Proceed with rollout.", + "ordering": "after-question" +} +``` + +- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`). +- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint. +- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`. +- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing. +- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question. + +#### Inbound Statuses & Responses +- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`. +- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored. +- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`). +- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored). +- **404 Not Found**: Queue item referenced in `replyRef` does not exist. +- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`. +- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes). +- **503 Service Unavailable**: + - `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`). + - `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects. + - `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.) + - `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen. + +--- + +## 4. Identity & Binding Configuration + +Bindings are managed through human fragment files (`gateway/humans/.yaml`) and CLI verbs (`rig gateway human`). + +### Binding Syntax +``` +relay::file::[:handle=] +``` +- `kind`: `relay`. +- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`). +- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`). +- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch). +- `handle`: Optional platform alias (`handle=`). Required for inbound reply resolution; omitted means outbound-only. + +Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle. + +--- + +## 5. Operational Lifecycle + +### Step 1: Provision Secrets File +Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`): +```bash +mkdir -p "$HOME/.config/openrig-relay" +touch "$HOME/.config/openrig-relay/relay.env" +chmod 600 "$HOME/.config/openrig-relay/relay.env" + +cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env" +OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook +OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token +OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token +EOF +``` + +### Step 2: Register Human Binding +```bash +# Add a person with a primary relay binding +rig gateway human add alice --display-name "Alice Smith" --delivery-class B \ + --binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops" + +# Replace an existing binding slot (requires the full binding spec) +rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops" +``` + +> [!NOTE] +> `rig gateway human set alice binding.` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment. + +Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed: + +```bash +rig gateway default alice +``` + +### Step 3: Activate Daemon +When bindings or secrets files change, restart the daemon process: +```bash +rig daemon stop +rig daemon start +``` +*(OpenRig does not provide a `rig daemon restart` command).* + +### Step 4: Verify Readiness & Status +```bash +# Check delivery readiness for all participants +rig gateway human list + +# Inspect detailed readiness status for an individual +rig gateway human show alice + +# Check daemon process status +rig daemon status +``` + +In `rig gateway human show --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running. + +### Step 5: Removing Bindings or Participants +Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved. +```bash +rig gateway human remove alice +``` +*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).* + +--- + +## 6. Troubleshooting Common Conditions + +| Condition | Observation | Resolution | +| :--- | :--- | :--- | +| **Missing Secrets File** | `deliveryReadiness.state: not-ready`
`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. | +| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)
`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 ` or `chmod 400 `). | +| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)
`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. | +| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`
`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. | +| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`
`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. | +| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. | +| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. | +| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). | +| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. | +| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. | + +### Conservative Seen-Ledger Recovery (`seen_store_unavailable`) + +When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`/state/relay-inbound-seen.jsonl`): + +1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection. +2. **Preserve a full backup**: Copy `` to a timestamped backup outside `state/` before making any edit. +3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:` or `processed:`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay::"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible. +4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (``) and `status` (`received:` or `processed:`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup. +5. **Resume and retry**: After a verified, history-preserving repair is saved at ``, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`. + +For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`. diff --git a/packages/cli/src/commands/gateway.ts b/packages/cli/src/commands/gateway.ts index d3b27e54e..201b71081 100644 --- a/packages/cli/src/commands/gateway.ts +++ b/packages/cli/src/commands/gateway.ts @@ -175,7 +175,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { .requiredOption("--display-name ", "Human-readable display name") .option( "--binding ]>", - "Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file::primary:handle=; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle= makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.", + "Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file::primary:handle=; tokens come from 'rig slack setup --secrets-env-file', never inline; or file: for relay: relay::file::primary:handle=). Optional handle= makes the binding inbound-resolvable (unique per kind for Slack, unique per connectorRef+handle for relay); omit it for outbound-only.", (v: string, acc: string[] = []) => { acc.push(v); return acc; }, ) .requiredOption("--delivery-class ", "Notification loudness class (the notifications register selection)") @@ -253,16 +253,24 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { .addHelpText("after", ` Binding specification details: Format: ]> - - kind: Connector kind (e.g. "slack") - - connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal" or "main") - - secretsRef: Pointer or label identifying where secrets are resolved (e.g. "file:/path/to/slack.env" or "vault://slack/user"). - NOTE: Never pass credentials or tokens inline here; credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file'). + - kind: Connector kind: "slack" or "relay" + - connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal", "main", or "local-bridge") + - secretsRef: Pointer or label identifying where secrets are resolved: + * For Slack: "file:/path/to/slack.env" or "vault://slack/user". Credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file'). + * For Relay: "file:" pointing to a chmod 600 file holding OPENRIG_RELAY_URL (HTTP loopback only, e.g. http://127.0.0.1:8088), OPENRIG_RELAY_OUTBOUND_TOKEN, and OPENRIG_RELAY_INBOUND_TOKEN (distinct non-empty values; no environment fallback). + NOTE: Never pass credentials or tokens inline here. - role: "primary" (exactly one binding must be primary) or "secondary" - - handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing + - handle: Optional platform user identifier (e.g. "handle=U12345678" for Slack, or "handle=" matching /^[A-Za-z0-9._-]+$/ for relay) for inbound routing. For relay, handles are unique per connectorRef. -Example: +Examples: + # Slack binding rig gateway human add alice --display-name "Alice" \\ --binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\ + --delivery-class B + + # Relay binding (loopback bridge adapter) + rig gateway human add bob --display-name "Bob" \\ + --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops" \\ --delivery-class B`); // ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through diff --git a/packages/cli/test/gateway-human-registry-verb.test.ts b/packages/cli/test/gateway-human-registry-verb.test.ts index 747714a29..bd0c587ae 100644 --- a/packages/cli/test/gateway-human-registry-verb.test.ts +++ b/packages/cli/test/gateway-human-registry-verb.test.ts @@ -48,6 +48,23 @@ describe("rig gateway human add verb (post-relocate)", () => { expect(bindingOpt).toBeDefined(); expect(bindingOpt!.description).toContain("connectorRef and secretsRef are config/vault labels"); expect(bindingOpt!.description).toContain("tokens come from 'rig slack setup --secrets-env-file'"); + expect(bindingOpt!.description).toContain("or file: for relay"); + }); + + it("gateway human add help text documents relay binding syntax and requirements", () => { + const gw = createProgram().commands.find((c) => c.name() === "gateway"); + const human = gw!.commands.find((c) => c.name() === "human"); + const addCmd = human!.commands.find((c) => c.name() === "add"); + let help = ""; + addCmd!.configureOutput({ + writeOut: (text) => { help += text; }, + writeErr: () => {}, + }); + addCmd!.outputHelp(); + expect(help).toContain("relay::file::primary:handle="); + expect(help).toContain("OPENRIG_RELAY_URL"); + expect(help).toContain("OPENRIG_RELAY_OUTBOUND_TOKEN"); + expect(help).toContain("OPENRIG_RELAY_INBOUND_TOKEN"); }); it("add writes a fragment + projection; address DERIVED; vault-pointer secretsRef (with ':') survives", async () => { diff --git a/packages/daemon/src/domain/gateway/connections-projection.ts b/packages/daemon/src/domain/gateway/connections-projection.ts index 2a216449c..74dd84944 100644 --- a/packages/daemon/src/domain/gateway/connections-projection.ts +++ b/packages/daemon/src/domain/gateway/connections-projection.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { loadConfig, configPathFor, DEFAULT_CONFIG, type SlackConnectorConfig } from "./slack/config.js"; import { resolveSecret } from "./slack/secrets.js"; import { loadHumanRegistry } from "./human-registry.js"; +import { loadRelayProfiles } from "./relay-config.js"; import { channelStateDigest } from "./channel-operations.js"; import type { SettingsStore } from "../user-settings/settings-store.js"; @@ -82,33 +83,58 @@ export function connectionsProjection(home: string, gateway: Record; try { registry = loadHumanRegistry(home, { readOnly: true }); } catch { registry = { ok: false, error: "registry unavailable" }; } + const relayProfilesResult = registry.ok ? loadRelayProfiles(home, { entities: registry.entities }) : null; + const relayProfileValues = relayProfilesResult ? [...relayProfilesResult.profiles.values()] : []; + const relayTokens = relayProfileValues + .flatMap((p) => [p.credentials?.inboundToken, p.credentials?.outboundToken]) + .filter((t): t is string => Boolean(t)); + const safeText = (v: unknown): string | null => { + const base = text(v); + if (base === null) return null; + let out = base; + for (const tok of relayTokens) out = out.split(tok).join("[redacted]"); + return out; + }; + const relaySummary = { + configured: relayProfileValues.length > 0, + ready: relayProfileValues.length > 0 && relayProfileValues.every((p) => p.ready), + profiles: relayProfileValues.map((p) => ({ + connectorRef: safeText(p.connectorRef), + state: p.state, + configured: p.configured, + ready: p.ready, + reason: safeText(p.reason), + nextAction: safeText(p.nextAction), + })), + }; const instance = ["host.name", "workspace.root", "workspace.operator_seat_name"] as const; const browserKey = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 16); return { - observedAt: new Date().toISOString(), home: text(home), pid: process.pid, - settingsSource: text(settings?.configPath), + observedAt: new Date().toISOString(), home: safeText(home), pid: process.pid, + settingsSource: safeText(settings?.configPath), settings: instance.map((key) => { try { const r = settings?.resolveOne(key); - return { key, value: text(r?.value), source: r?.source ?? "unavailable" }; + return { key, value: safeText(r?.value), source: r?.source ?? "unavailable" }; } catch { return { key, value: null, source: "unavailable" }; } }), - configSource: { state: configState, path: text(configPath), sourceState }, configuration, - running: { state: text(gateway?.state) ?? "unavailable", activatedAt: text(gateway?.activatedAt), + configSource: { state: configState, path: safeText(configPath), sourceState }, configuration, + running: { state: safeText(gateway?.state) ?? "unavailable", activatedAt: safeText(gateway?.activatedAt), outboundReady: typeof connector?.outboundReady === "boolean" ? connector.outboundReady : null, inboundReady: typeof connector?.inboundReady === "boolean" ? connector.inboundReady : null, - inboundState: text(inbound?.state) ?? "unverified", + inboundState: safeText(inbound?.state) ?? "unverified", // Delivery health beside the socket state: an open socket is not proof that events arrive. - inboundDelivery: text(inbound?.delivery), inboundEventsMissingSince: text(inbound?.eventsMissingSince), applied }, + inboundDelivery: safeText(inbound?.delivery), inboundEventsMissingSince: safeText(inbound?.eventsMissingSince), applied }, state, nextAction, - registry: { state: registry.ok ? "available" : "unavailable", path: text(join(home, "gateway", "humans")) }, + relay: relaySummary, + registry: { state: registry.ok ? "available" : "unavailable", path: safeText(join(home, "gateway", "humans")) }, humans: registry.ok ? registry.entities.map((h) => ({ browserKey: browserKey(h.entityId), - entityId: text(h.entityId) ?? "[withheld]", address: text(h.address) ?? "[withheld]", displayName: text(h.displayName), class: h.class, away: h.prefs.away ?? null, + entityId: safeText(h.entityId) ?? "[withheld]", address: safeText(h.address) ?? "[withheld]", displayName: safeText(h.displayName), class: h.class, away: h.prefs.away ?? null, deliveryClass: h.prefs.deliveryClass, availability: h.prefs.availability ?? (h.prefs.away ? "away" : "available"), excluded: cfg ? cfg.outboundDestinations.length > 0 && !cfg.outboundDestinations.includes(h.address) : null, bindings: h.connectorBindings.map((b) => ({ browserKey: browserKey(JSON.stringify([b.kind, b.connectorRef, b.role, b.handle ?? null])), - kind: b.kind, ref: text(b.connectorRef), role: b.role, handle: text(b.handle), credentialReference: Boolean(b.secretsRef) })), + kind: b.kind, ref: safeText(b.connectorRef), role: b.role, handle: safeText(b.handle), credentialReference: Boolean(b.secretsRef) })), })) : [], }; } diff --git a/packages/daemon/src/domain/gateway/dispatcher.ts b/packages/daemon/src/domain/gateway/dispatcher.ts index fa36930c8..ff52590a7 100644 --- a/packages/daemon/src/domain/gateway/dispatcher.ts +++ b/packages/daemon/src/domain/gateway/dispatcher.ts @@ -18,6 +18,8 @@ export interface DispatcherDeps { send: (decision: OutboundDecision) => void; /** Injectable id source (tests pin decisionIds; prod = a UUID). */ newDecisionId?: () => string; + /** Optional synchronous preparation seam run BEFORE durable enqueue (e.g. to freeze relay envelope/refs on the decision payload). */ + prepare?: (decision: OutboundDecision) => OutboundDecision | void; } export type DispatchResult = @@ -54,7 +56,20 @@ export class GatewayDispatcher { if (opts?.decisionId && this.deps.buffer.pending().some((d) => d.decisionId === decisionId)) { return { ok: true, decisionId }; // idempotent accept: already durably enqueued; replay owns the redrive } - const decision: OutboundDecision = { kind: "outbound_decision", decisionId, op, entityBindingRef, payload }; + let payloadToEnqueue = payload; + if (this.deps.prepare) { + const prepared = this.deps.prepare({ kind: "outbound_decision", decisionId, op, entityBindingRef, payload }); + if (prepared && prepared.payload !== undefined) { + payloadToEnqueue = prepared.payload; + } + } + const decision: OutboundDecision = { + kind: "outbound_decision", + decisionId, + op, + entityBindingRef, + payload: payloadToEnqueue, + }; this.deps.buffer.enqueue(decision); // durable BEFORE dispatch this.deps.send(decision); return { ok: true, decisionId }; diff --git a/packages/daemon/src/domain/gateway/gateway-subsystem.ts b/packages/daemon/src/domain/gateway/gateway-subsystem.ts index 7980a3f04..e510a9cd2 100644 --- a/packages/daemon/src/domain/gateway/gateway-subsystem.ts +++ b/packages/daemon/src/domain/gateway/gateway-subsystem.ts @@ -41,6 +41,8 @@ export interface InProcessWireOpts { /** The platform delivery seam. Until the relay cutover wires Slack, startup passes an * honest unwired refusal — dispatches are retained durably, never silently dropped. */ deliver: SubsystemDeliverFn; + /** Optional synchronous preparation seam run BEFORE durable enqueue so relay envelopes/refs are frozen once. */ + prepare?: (decision: OutboundDecision) => OutboundDecision | void; /** Ops the delivery layer advertises. The shipped proof-9 rail holds in-process: an * unadvertised op is refused, never attempted. Empty = nothing dispatchable yet. */ ops?: string[]; @@ -72,6 +74,7 @@ export function buildInProcessWire(opts: InProcessWireOpts): GatewayWire { const dispatcher: GatewayDispatcher = new GatewayDispatcher({ buffer, send: (decision) => { void deliverAndAck(decision); }, + ...(opts.prepare ? { prepare: opts.prepare } : {}), }); dispatcher.onCapability({ kind: "capability", diff --git a/packages/daemon/src/domain/gateway/human-readiness.ts b/packages/daemon/src/domain/gateway/human-readiness.ts index 5e20fbec4..0a28562f2 100644 --- a/packages/daemon/src/domain/gateway/human-readiness.ts +++ b/packages/daemon/src/domain/gateway/human-readiness.ts @@ -2,6 +2,8 @@ import type { HumanFragment } from "./human-registry.js"; import type { SlackConnectorConfig } from "./slack/config.js"; import { verifyScopes, verifyChannelMembership, type ScopeVerdict } from "./slack/slack-api.js"; import { mappedChannels } from "./slack/channel-map.js"; +import { loadRelayProfiles, type LoadRelayProfilesResult } from "./relay-config.js"; +import type { SecretFsOps } from "./slack/secrets.js"; export type HumanDeliveryReadinessState = "ready" | "not-ready" | "indeterminate"; @@ -23,12 +25,17 @@ export interface HumanDeliveryReadinessInput { config: SlackConnectorConfig; gatewayState: string; botToken: string | null; + /** Optional preloaded relay profiles; when omitted for a relay primary binding, loaded from the full human registry. */ + relayProfiles?: LoadRelayProfilesResult | null; + home?: string; } export interface HumanDeliveryReadinessDeps { verifyScopes?: (token: string, required: string[]) => Promise; verifyMembership?: (token: string, channel: string) => Promise<{ ok: boolean; isMember: boolean; error?: string }>; now?: () => Date; + fsops?: SecretFsOps; + entities?: readonly HumanFragment[]; } /** Resolve the registered human's PRIMARY binding to delivery truth in one record. @@ -49,6 +56,50 @@ export async function resolveHumanDeliveryReadiness( connector: { kind: primary.kind, ref: primary.connectorRef }, checkedAt, }; + if (primary.kind === "relay") { + const loaded = input.relayProfiles !== undefined + ? input.relayProfiles + : loadRelayProfiles(input.home, { entities: deps.entities, fsops: deps.fsops }); + const profile = loaded?.ok ? loaded.get(primary.connectorRef) : undefined; + const defaultNextAction = "rig gateway human show " + input.human.entityId + " --json"; + if (!profile || !profile.ready) { + return { + state: "not-ready", + configured: profile?.configured ?? false, + enabled: true, + active: false, + ready: false, + ...base, + reason: profile?.reason + ?? (loaded && !loaded.ok && loaded.registryError + ? loaded.registryError + : `relay profile '${primary.connectorRef}' is not configured`), + nextAction: profile?.nextAction ?? defaultNextAction, + }; + } + if (input.gatewayState !== "active") { + return { + state: "not-ready", + configured: true, + enabled: true, + active: false, + ready: false, + ...base, + reason: `gateway subsystem is ${input.gatewayState}`, + nextAction: "rig daemon logs", + }; + } + return { + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + ...base, + reason: profile.reason, + nextAction: null, + }; + } const configured = primary.kind === "slack" && input.botToken !== null && input.config.channel !== null; const enabled = input.config.enabled; const active = enabled && input.gatewayState === "active"; diff --git a/packages/daemon/src/domain/gateway/human-registry.ts b/packages/daemon/src/domain/gateway/human-registry.ts index 092d63daa..9773964a4 100644 --- a/packages/daemon/src/domain/gateway/human-registry.ts +++ b/packages/daemon/src/domain/gateway/human-registry.ts @@ -16,7 +16,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, rmSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { join, dirname, isAbsolute } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { getOpenRigHome } from "../../openrig-compat.js"; import { DispatchBuffer } from "./dispatch-buffer.js"; @@ -24,7 +24,8 @@ import { parseSessionName } from "../session-name.js"; // ── Schema (closed enums; extend only additively behind the contract) ── export const HUMAN_ENTITY_CLASSES = new Set(["human"]); // M1's only class -export const HUMAN_CONNECTOR_KINDS = new Set(["slack"]); // M1's only kind +export const HUMAN_CONNECTOR_KINDS = new Set(["slack", "relay"]); +export type HumanConnectorKind = "slack" | "relay"; export const HUMAN_BINDING_ROLES = new Set(["primary", "secondary"]); export const HUMAN_DELIVERY_CLASSES = new Set(["A", "B", "C", "D"]); // entityId is the fragment key + filename: a stable slug that survives platform @@ -109,7 +110,7 @@ function unknownKey(obj: Record, allowed: Set): string } export interface HumanConnectorBinding { - kind: "slack"; + kind: HumanConnectorKind; connectorRef: string; /** POINTER at the connector's vault (secrets-on-connector) — NEVER the secret. */ secretsRef: string; @@ -118,11 +119,24 @@ export interface HumanConnectorBinding { /** A6 v3: the platform-native id of this human on the connector (e.g. Slack user id). * OPTIONAL — a handle-less binding is OUTBOUND-ONLY (delivery works; it is NOT * inbound-resolvable, so an inbound event on it fails admission LOUDLY). REQUIRED to be - * inbound-resolvable. UNIQUE per kind across ALL bindings of ALL humans (one platform id = - * exactly one human; a duplicate is a registration conflict, REFUSED). */ + * inbound-resolvable. UNIQUE per kind (Slack) or per connectorRef (relay) across ALL + * bindings of ALL humans (one platform id = one human; a duplicate is a registration + * conflict, REFUSED). */ handle?: string; } +function handleScopeKey(binding: Pick & { handle: string }): string { + return binding.kind === "relay" + ? JSON.stringify(["relay", binding.connectorRef, binding.handle]) + : `${binding.kind}:${binding.handle}`; +} + +function handleScopeLabel(binding: Pick & { handle: string }): string { + return binding.kind === "relay" + ? `relay (${binding.connectorRef}) handle "${binding.handle}"` + : `${binding.kind} handle "${binding.handle}"`; +} + export interface HumanPrefs { /** Per-ENTITY loudness — a SELECTION from the notifications register (spec §6); * A3 carries it, never redefines it. Forward-compatible if the register grows. */ @@ -214,7 +228,7 @@ export function validateHumanFragment(raw: unknown): ValidateResult { return { ok: false, error: `connectorBindings[${i}].handle "${String(b.handle)}" must match ${HANDLE_PATTERN} (a platform id — no ':' '@' or whitespace, which could forge a ref)` }; } } - const binding: HumanConnectorBinding = { kind: "slack", connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" }; + const binding: HumanConnectorBinding = { kind: b.kind as HumanConnectorKind, connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" }; if (b.handle !== undefined) { handle = b.handle as string; binding.handle = handle; } bindings.push(binding); } @@ -222,15 +236,20 @@ export function validateHumanFragment(raw: unknown): ValidateResult { if (bindings.length > 0 && primaryCount !== 1) { return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` }; } - // A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind across this human's bindings — - // one human must not claim the same platform id twice. (Cross-fragment uniqueness is enforced - // in projectHumans, which sees every human at once.) + // A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind (Slack) or per connectorRef + // (relay) across this human's bindings — one human must not claim the same platform id twice. + // (Cross-fragment uniqueness is enforced in projectHumans, which sees every human at once.) const seenHandles = new Set(); for (const b of bindings) { if (b.handle === undefined) continue; - const key = `${b.kind}:${b.handle}`; + const key = handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle }); if (seenHandles.has(key)) { - return { ok: false, error: `duplicate ${b.kind} handle "${b.handle}" across this human's bindings — a handle is unique per kind (one platform id = one human)` }; + return { + ok: false, + error: b.kind === "relay" + ? `duplicate relay (${b.connectorRef}) handle "${b.handle}" across this human's bindings — a relay handle is unique per connectorRef` + : `duplicate ${b.kind} handle "${b.handle}" across this human's bindings — a handle is unique per kind (one platform id = one human)`, + }; } seenHandles.add(key); } @@ -321,10 +340,10 @@ function validateEntityCollection(entities: readonly HumanFragment[]): string | entityIds.add(entity.entityId); for (const binding of entity.connectorBindings) { if (binding.handle === undefined) continue; - const key = `${binding.kind}:${binding.handle}`; + const key = handleScopeKey({ kind: binding.kind, connectorRef: binding.connectorRef, handle: binding.handle }); const prior = handleOwner.get(key); if (prior !== undefined && prior !== entity.entityId) { - return `${binding.kind} handle "${binding.handle}" is claimed by both "${prior}" and "${entity.entityId}" — a handle maps to exactly one human (registration conflict)`; + return `${handleScopeLabel({ kind: binding.kind, connectorRef: binding.connectorRef, handle: binding.handle })} is claimed by both "${prior}" and "${entity.entityId}" — a handle maps to exactly one human (registration conflict)`; } handleOwner.set(key, entity.entityId); } @@ -403,6 +422,41 @@ export function resolveSlackHandle( }; } +export type RelayHandleResolution = + | { kind: "registered"; entityId: string; address: string; connectorRef: string; handle: string } + | { kind: "unregistered"; connectorRef: string; handle: string; error: string }; + +/** Resolve an INBOUND relay handle scoped to the authenticated relay `connectorRef`. + * Unlike Slack (where user IDs are globally unique across kind=slack), relay aliases are + * scoped to `(kind="relay", connectorRef, handle)` so an authenticated relay can assert + * only its own bindings while allowing multiple registered people on the same relay to + * answer with their own entity address. Handle-less relay bindings remain outbound-only. */ +export function resolveRelayHandle( + connectorRef: string, + handle: string, + entities: readonly HumanFragment[], +): RelayHandleResolution { + if (connectorRef.length > 0 && handle.length > 0) { + for (const e of entities) { + for (const b of e.connectorBindings) { + if (b.kind === "relay" && b.connectorRef === connectorRef && b.handle !== undefined && b.handle === handle) { + return { kind: "registered", entityId: e.entityId, address: e.address, connectorRef, handle }; + } + } + } + } + return { + kind: "unregistered", + connectorRef, + handle, + error: + `inbound relay (${connectorRef}) sender "${handle}" is not a registered human (no relay binding for "${connectorRef}" with handle "${handle}"). ` + + `Register the human + their handle first: rig gateway human add --display-name … ` + + `--binding relay:${connectorRef}:file::primary:handle=${handle} --delivery-class …; ` + + `until then this message is REFUSED (it was NOT landed as a fabricated human seat).`, + }; +} + export function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } { try { mkdirSync(dirname(path), { recursive: true }); @@ -451,20 +505,8 @@ export function addHumanFragment( } // A6 v3 pin-1: reject a handle already claimed by a DIFFERENT human BEFORE writing (never leave a // conflicting fragment on disk with a failed re-projection). projectHumans is the load-time backstop. - const existing = projectHumans(home); - if (!existing.ok) return { ok: false, error: `cannot validate handle uniqueness — existing registry is invalid: ${existing.error}` }; - const claimed = new Map(); - for (const e of existing.entities) { - if (e.entityId === v.fragment.entityId) continue; // a replace of the same human is fine - for (const b of e.connectorBindings) if (b.handle !== undefined) claimed.set(`${b.kind}:${b.handle}`, e.entityId); - } - for (const b of v.fragment.connectorBindings) { - if (b.handle === undefined) continue; - const owner = claimed.get(`${b.kind}:${b.handle}`); - if (owner !== undefined) { - return { ok: false, error: `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)` }; - } - } + const conflict = handleConflict(v.fragment, home); + if (conflict) return { ok: false, error: conflict }; const w = atomicWrite(file, stringifyYaml(v.fragment)); if (!w.ok) return { ok: false, error: w.error }; // A new or replaced record may author any availability: it's a change of its own. @@ -570,6 +612,12 @@ export function parseBindingSpec(spec: string): if (!kind || !connectorRef || !secretsRef || !role) { return { ok: false, error: `binding spec fields must be non-empty: kind:connectorRef:secretsRef:role[:handle=] (got "${spec}")` }; } + if (kind === "relay" && secretsRef.startsWith("file:")) { + const filePath = secretsRef.slice("file:".length); + if (!filePath || filePath.startsWith("//") || !isAbsolute(filePath)) { + return { ok: false, error: `binding file secretsRef must be file: (got "${spec}")` }; + } + } return { ok: true, binding: handle !== undefined ? { kind, connectorRef, secretsRef, role, handle } : { kind, connectorRef, secretsRef, role } }; } @@ -667,13 +715,17 @@ function handleConflict(fragment: HumanFragment, home: string): string | undefin const claimed = new Map(); for (const e of existing.entities) { if (e.entityId === fragment.entityId) continue; - for (const b of e.connectorBindings) if (b.handle !== undefined) claimed.set(`${b.kind}:${b.handle}`, e.entityId); + for (const b of e.connectorBindings) { + if (b.handle !== undefined) { + claimed.set(handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle }), e.entityId); + } + } } for (const b of fragment.connectorBindings) { if (b.handle === undefined) continue; - const owner = claimed.get(`${b.kind}:${b.handle}`); + const owner = claimed.get(handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle })); if (owner !== undefined) { - return `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)`; + return `${handleScopeLabel({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle })} is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)`; } } return undefined; diff --git a/packages/daemon/src/domain/gateway/operator-delivery-engine.ts b/packages/daemon/src/domain/gateway/operator-delivery-engine.ts index 4e0f374c7..d2359185d 100644 --- a/packages/daemon/src/domain/gateway/operator-delivery-engine.ts +++ b/packages/daemon/src/domain/gateway/operator-delivery-engine.ts @@ -6,6 +6,7 @@ // dispatcher and the REAL tick. import type { QueueItem, QueueRepository } from "../queue-repository.js"; +import { RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX } from "../queue-transition-log.js"; import { DispatchBuffer } from "./dispatch-buffer.js"; import { lastMeaningfulTransition } from "../queue-waiting.js"; import type { DispatchResult } from "./dispatcher.js"; @@ -92,6 +93,7 @@ export function makeOperatorDeliveryEngine(deps: { if (note.startsWith("escalation-rung: operator dispatched-to-engine")) resolved = null; if (note.split(/\s+/).includes(`notification_key=${key}`)) { if (note.startsWith("slack-owner-notification-posted ")) resolved = "posted"; + if (note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)) resolved = "accepted"; if (note.startsWith("delivery-termination:")) resolved = "terminated"; } } @@ -134,9 +136,12 @@ export function makeOperatorDeliveryEngine(deps: { // Already-accepted decisions returned above retain gateway custody, even if readiness later changes. try { const readiness = await (deps.readiness?.(human) ?? resolveHumanDeliveryReadiness({ - human, config: cfg, gatewayState: deps.gatewayState?.() ?? "unavailable", + human, + config: cfg, + gatewayState: deps.gatewayState?.() ?? "unavailable", botToken: resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined }), - })); + home: deps.home, + }, { entities: reg.entities })); if (!readiness.ready) return { decision: `unavailable:human-delivery:${readiness.reason}`, resolved: false, dispatched: false }; } catch { return { decision: "unavailable:human-delivery:readiness-unavailable", resolved: false, dispatched: false }; diff --git a/packages/daemon/src/domain/gateway/relay-config.ts b/packages/daemon/src/domain/gateway/relay-config.ts new file mode 100644 index 000000000..75d65cede --- /dev/null +++ b/packages/daemon/src/domain/gateway/relay-config.ts @@ -0,0 +1,420 @@ +import { isAbsolute, resolve as resolvePath } from "node:path"; +import { getOpenRigHome } from "../../openrig-compat.js"; +import { constantTimeEqual } from "../../middleware/auth-bearer-token.js"; +import { + loadHumanRegistry, + registeredPeople, + type HumanFragment, +} from "./human-registry.js"; +import { + nodeSecretFs, + parseEnvFile, + type SecretFsOps, +} from "./slack/secrets.js"; + +export const RELAY_ENV_KEYS = { + url: "OPENRIG_RELAY_URL", + outboundToken: "OPENRIG_RELAY_OUTBOUND_TOKEN", + inboundToken: "OPENRIG_RELAY_INBOUND_TOKEN", +} as const; + +export interface RelayCredentials { + readonly url: string; + readonly outboundToken: string; + readonly inboundToken: string; +} + +export interface RelayProfileReadiness { + readonly connectorRef: string; + readonly state: "ready" | "not-ready"; + readonly configured: boolean; + readonly ready: boolean; + readonly reason: string; + readonly nextAction: string | null; + readonly warnings: readonly string[]; +} + +export interface RelayBoundHuman { + readonly entityId: string; + readonly address: string; + readonly role: "primary" | "secondary"; + readonly secretsRef: string; + readonly handle?: string; +} + +export interface RelayProfile extends RelayProfileReadiness { + readonly secretsRef: string | null; + readonly envFile: string | null; + readonly endpointUrl: string | null; + readonly readiness: RelayProfileReadiness; + readonly humans: readonly RelayBoundHuman[]; + /** Private credentials for outbound/inbound transport; null when not ready; non-enumerable and never serialized. */ + readonly credentials: RelayCredentials | null; +} + +export interface LoadRelayProfilesOptions { + readonly entities?: readonly HumanFragment[]; + readonly fsops?: SecretFsOps; +} + +export interface LoadRelayProfilesResult { + readonly ok: boolean; + readonly registryError: string | null; + readonly profiles: ReadonlyMap; + get(connectorRef: string): RelayProfile | undefined; +} + +/** Parse a relay binding's `secretsRef` (`file:`). + * Rejects bare paths, relative paths, empty pointers, and `file://` authority URLs. */ +export function parseRelaySecretsRef( + secretsRef: string, +): { ok: true; envFile: string } | { ok: false; reason: string } { + const trimmed = secretsRef.trim(); + if (!trimmed.startsWith("file:") || trimmed.includes("\0")) { + return { ok: false, reason: "relay secretsRef must use file: syntax" }; + } + const rawPath = trimmed.slice("file:".length); + if (!rawPath || rawPath.startsWith("//") || !isAbsolute(rawPath)) { + return { ok: false, reason: "relay secretsRef must point to an absolute file path" }; + } + return { ok: true, envFile: resolvePath(rawPath) }; +} + +const IPV4_LOOPBACK_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +function extractRawAuthorityHost(rawUrl: string): string | null { + const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]+)/.exec(rawUrl); + if (!match) return null; + const authority = match[1]!; + if (authority.startsWith("[")) { + const end = authority.indexOf("]"); + return end === -1 ? null : authority.slice(1, end).toLowerCase(); + } + const colon = authority.indexOf(":"); + return (colon === -1 ? authority : authority.slice(0, colon)).toLowerCase(); +} + +function isStrictLoopbackHost(hostname: string, rawHost: string): boolean { + const norm = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (norm === "localhost" && rawHost === "localhost") return true; + if ((norm === "::1" || norm === "0:0:0:0:0:0:0:1") && (rawHost === "::1" || rawHost === "0:0:0:0:0:0:0:1")) { + return true; + } + return IPV4_LOOPBACK_RE.test(norm) && IPV4_LOOPBACK_RE.test(rawHost); +} + +/** Validate `OPENRIG_RELAY_URL`: must be `http:`, loopback host (`127.x.y.z`, `::1`, or `localhost`), + * with no embedded credentials and no fragment. Never echoes raw URL or credentials in reasons. */ +export function validateRelayUrl( + rawUrl: string, +): { ok: true; url: string } | { ok: false; reason: string } { + const trimmed = rawUrl.trim(); + if (!trimmed || /[\r\n\0]/.test(trimmed)) { + return { ok: false, reason: "OPENRIG_RELAY_URL is empty or malformed" }; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { ok: false, reason: "OPENRIG_RELAY_URL is not a valid URL" }; + } + if (parsed.protocol !== "http:") { + return { ok: false, reason: "OPENRIG_RELAY_URL must use http: scheme" }; + } + if (parsed.username !== "" || parsed.password !== "") { + return { ok: false, reason: "OPENRIG_RELAY_URL must not contain embedded credentials" }; + } + if (parsed.hash !== "") { + return { ok: false, reason: "OPENRIG_RELAY_URL must not contain a URL fragment" }; + } + const rawHost = extractRawAuthorityHost(trimmed); + if (!rawHost || !isStrictLoopbackHost(parsed.hostname, rawHost)) { + return { + ok: false, + reason: "OPENRIG_RELAY_URL host must be loopback (numeric 127.0.0.1/8, ::1, or localhost)", + }; + } + return { ok: true, url: parsed.toString() }; +} + +function createPrivateCredentials(url: string, outboundToken: string, inboundToken: string): RelayCredentials { + const creds = { url, outboundToken, inboundToken }; + Object.defineProperty(creds, "toJSON", { value: () => "[REDACTED]", enumerable: false }); + Object.defineProperty(creds, Symbol.for("nodejs.util.inspect.custom"), { + value: () => "[RelayCredentials REDACTED]", + enumerable: false, + }); + return Object.freeze(creds); +} + +/** Check permissions on a relay secret file. Like Slack secret file checks, unsafe group/other + * permission bits (`(mode & 0o077) !== 0`) produce a warning rather than making the profile unready. + * Owner-only modes such as `0600` or `0400` produce no warning (`null`). */ +function checkRelaySecretFilePermissions( + envFile: string, + fsops: SecretFsOps, + connectorRef: string, +): string | null { + const mode = fsops.statMode(envFile); + if (mode === null) return null; + if ((mode & 0o077) !== 0) { + return `relay secret file for '${connectorRef}' has insecure permissions (mode 0${mode.toString(8).padStart(3, "0")}; group/other access bits are set — restrict so group/other have no access, e.g. 0600 or 0400)`; + } + return null; +} + +interface CandidateProfile { + connectorRef: string; + secretsRef: string | null; + envFile: string | null; + endpointUrl: string | null; + humans: RelayBoundHuman[]; + nextAction: string | null; + warnings: string[]; + unreadyReason?: string; + url?: string; + outboundToken?: string; + inboundToken?: string; +} + +function evaluateRelayCandidate( + connectorRef: string, + humans: RelayBoundHuman[], + fsops: SecretFsOps, +): CandidateProfile { + const firstHuman = humans[0]!; + const defaultNextAction = `rig gateway human show ${firstHuman.entityId} --json`; + const warnings: string[] = []; + const fail = ( + unreadyReason: string, + secretsRef: string | null = firstHuman.secretsRef, + envFile: string | null = null, + nextAction: string = defaultNextAction, + ): CandidateProfile => ({ + connectorRef, + secretsRef, + envFile, + endpointUrl: null, + humans, + nextAction, + warnings, + unreadyReason, + }); + + const envFiles = new Set(); + for (const h of humans) { + const parsed = parseRelaySecretsRef(h.secretsRef); + if (!parsed.ok) { + return fail( + `relay profile '${connectorRef}' has invalid secretsRef: ${parsed.reason}`, + h.secretsRef, + null, + `rig gateway human show ${h.entityId} --json`, + ); + } + envFiles.add(parsed.envFile); + } + if (envFiles.size > 1) { + return fail( + `relay profile '${connectorRef}' has inconsistent provisioning across human bindings (conflicting secretsRef file paths)`, + null, + null, + "rig gateway human list --json", + ); + } + + const [envFile] = envFiles as Set & [string]; + const secretsRef = firstHuman.secretsRef; + const mode = fsops.statMode(envFile!); + if (mode === null) { + return fail(`relay secret file for '${connectorRef}' is missing or unreadable`, secretsRef, envFile!); + } + const permWarning = checkRelaySecretFilePermissions(envFile!, fsops, connectorRef); + if (permWarning) { + warnings.push(permWarning); + } + + let rawText: string; + try { + rawText = fsops.readFileSync(envFile!); + } catch { + return fail(`relay secret file for '${connectorRef}' is missing or unreadable`, secretsRef, envFile!); + } + + const parsedEnv = parseEnvFile(rawText); + const rawUrl = (parsedEnv[RELAY_ENV_KEYS.url] ?? "").trim(); + const outboundToken = (parsedEnv[RELAY_ENV_KEYS.outboundToken] ?? "").trim(); + const inboundToken = (parsedEnv[RELAY_ENV_KEYS.inboundToken] ?? "").trim(); + + if (!rawUrl) { + return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.url}`, secretsRef, envFile!); + } + if (!outboundToken || /[\r\n\0]/.test(outboundToken)) { + return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.outboundToken}`, secretsRef, envFile!); + } + if (!inboundToken || /[\r\n\0]/.test(inboundToken)) { + return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.inboundToken}`, secretsRef, envFile!); + } + if (constantTimeEqual(outboundToken, inboundToken)) { + warnings.push( + `relay profile '${connectorRef}' uses the same token for ${RELAY_ENV_KEYS.outboundToken} and ${RELAY_ENV_KEYS.inboundToken}; separate tokens are recommended so outbound bridge capture cannot authenticate inbound replies`, + ); + } + + const urlVerdict = validateRelayUrl(rawUrl); + if (!urlVerdict.ok) { + return fail( + `relay profile '${connectorRef}' has invalid ${RELAY_ENV_KEYS.url}: ${urlVerdict.reason}`, + secretsRef, + envFile!, + ); + } + + return { + connectorRef, + secretsRef, + envFile: envFile!, + endpointUrl: urlVerdict.url, + humans, + nextAction: defaultNextAction, + warnings, + url: urlVerdict.url, + outboundToken, + inboundToken, + }; +} + +function buildRelayProfile(c: CandidateProfile): RelayProfile { + const ready = !c.unreadyReason; + const frozenWarnings = Object.freeze([...c.warnings]); + const baseVerifiedReason = "relay profile verified (loopback HTTP endpoint and directional tokens configured)"; + const readyReason = + frozenWarnings.length > 0 + ? `${baseVerifiedReason}; warning: ${frozenWarnings.join("; ")}` + : baseVerifiedReason; + const readiness: RelayProfileReadiness = Object.freeze({ + connectorRef: c.connectorRef, + state: ready ? "ready" : "not-ready", + configured: ready, + ready, + reason: c.unreadyReason ?? readyReason, + nextAction: ready ? null : c.nextAction, + warnings: frozenWarnings, + }); + const credentials = ready ? createPrivateCredentials(c.url!, c.outboundToken!, c.inboundToken!) : null; + const profile = { + ...readiness, + secretsRef: c.secretsRef, + envFile: c.envFile, + endpointUrl: c.endpointUrl, + readiness, + humans: Object.freeze([...c.humans]), + } as unknown as RelayProfile; + + Object.defineProperty(profile, "credentials", { + value: credentials, + enumerable: false, + writable: false, + configurable: false, + }); + + return Object.freeze(profile); +} + +/** Load and validate all relay profiles referenced by registered humans' `kind: "relay"` bindings. + * Performs zero network I/O and never reads from `process.env`. */ +export function loadRelayProfiles( + home: string = getOpenRigHome(), + opts: LoadRelayProfilesOptions = {}, +): LoadRelayProfilesResult { + const fsops = opts.fsops ?? nodeSecretFs; + let entities: readonly HumanFragment[] = []; + let registryError: string | null = null; + let ok = true; + + if (opts.entities !== undefined) { + entities = opts.entities; + } else { + const loaded = loadHumanRegistry(home, { readOnly: true }); + const people = registeredPeople(loaded); + if (people === null) { + ok = false; + registryError = loaded.ok ? null : loaded.error; + } else { + entities = people; + } + } + + const grouped = new Map(); + for (const entity of entities) { + for (const binding of entity.connectorBindings) { + if (binding.kind !== "relay") continue; + const list = grouped.get(binding.connectorRef) ?? []; + list.push({ + entityId: entity.entityId, + address: entity.address, + role: binding.role, + secretsRef: binding.secretsRef, + ...(binding.handle !== undefined ? { handle: binding.handle } : {}), + }); + grouped.set(binding.connectorRef, list); + } + } + + const candidates = [...grouped.keys()] + .sort() + .map((connectorRef) => evaluateRelayCandidate(connectorRef, grouped.get(connectorRef)!, fsops)); + + const validCandidates = candidates.filter((c) => !c.unreadyReason && c.inboundToken !== undefined); + for (const current of validCandidates) { + const collidingRefs = validCandidates + .filter((other) => other.connectorRef !== current.connectorRef && constantTimeEqual(current.inboundToken!, other.inboundToken!)) + .map((other) => other.connectorRef); + if (collidingRefs.length > 0) { + current.unreadyReason = + `relay profile '${current.connectorRef}' reuses an ambiguous ${RELAY_ENV_KEYS.inboundToken} shared with relay profile(s): ${collidingRefs.join(", ")}`; + } + } + + const profiles = new Map(); + for (const c of candidates) { + profiles.set(c.connectorRef, buildRelayProfile(c)); + } + + return Object.freeze({ + ok, + registryError, + profiles, + get(connectorRef: string): RelayProfile | undefined { + return profiles.get(connectorRef); + }, + }); +} + +export type RelayInboundAuthResult = + | { ok: true; connectorRef: string; profile: RelayProfile } + | { ok: false; reason: string }; + +/** Derive authenticated relay identity from a bearer token by comparing against all ready + * profiles' `inboundToken` using `constantTimeEqual` without short-circuiting. */ +export function authenticateRelayInboundToken( + loaded: LoadRelayProfilesResult, + bearerToken: string | null | undefined, +): RelayInboundAuthResult { + if (typeof bearerToken !== "string" || bearerToken.length === 0) { + return { ok: false, reason: "missing relay bearer token" }; + } + const matches: RelayProfile[] = []; + for (const profile of loaded.profiles.values()) { + if (profile.ready && profile.credentials && constantTimeEqual(bearerToken, profile.credentials.inboundToken)) { + matches.push(profile); + } + } + if (matches.length === 1) { + return { ok: true, connectorRef: matches[0]!.connectorRef, profile: matches[0]! }; + } + return { + ok: false, + reason: matches.length > 1 ? "ambiguous relay bearer token across profiles" : "invalid relay bearer token", + }; +} diff --git a/packages/daemon/src/domain/gateway/relay-inbound.ts b/packages/daemon/src/domain/gateway/relay-inbound.ts new file mode 100644 index 000000000..9e5d3bd6d --- /dev/null +++ b/packages/daemon/src/domain/gateway/relay-inbound.ts @@ -0,0 +1,897 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { + HANDLE_PATTERN, + loadHumanRegistry, + registeredPeople, + resolveRelayHandle, + type HumanFragment, +} from "./human-registry.js"; +import { + authenticateRelayInboundToken, + loadRelayProfiles, +} from "./relay-config.js"; +import { + RELAY_PROTOCOL_VERSION, + decodeRelayReplyRef, + encodeRelayReplyRef, + findPendingRelayReplyRef, + hashRelayConnectorRef, + type RelayReplyRefPayload, + type RelayReplyRefToken, +} from "./relay-outbound.js"; +import { SeenStore, nodeStateFs, type StateFsOps } from "./slack/state-store.js"; +import type { RelayOwnerNotificationReceipt } from "../queue-transition-log.js"; +import type { QueueRepository } from "../queue-repository.js"; +import { + MissionControlWriteContractError, + type MissionControlActionInput, + type MissionControlWriteContract, +} from "../mission-control/mission-control-write-contract.js"; +import type { MissionControlActionLog } from "../mission-control/mission-control-action-log.js"; + +export const RELAY_INBOUND_SEEN_FILE = "relay-inbound-seen.jsonl" as const; +export const RELAY_INBOUND_MAX_BODY_BYTES = 64 * 1024; + +const EVENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/; +const MAX_REPLY_REF_CHARS = 4096; +const MAX_REPLY_TEXT_CHARS = 32_768; +const VALID_SEEN_STATUS_PATTERN = /^(received|processed):([0-9a-f]{64})$/; +const SUSPECT_SEEN_STATUS = "suspect"; + +export type RelayInboundOrdering = "after-question" | "uncertain"; + +export interface RelayInboundEnvelope { + eventId: string; + sender: { handle: string }; + replyRef: string; + text: string; + ordering: RelayInboundOrdering; +} + +export interface RelayInboundRequestInput { + authorizationHeader: string | undefined; + rawBody: string; +} + +export interface RelayInboundResponse { + status: number; + body: Record; +} + +export interface RelayInboundHandlerDeps { + home: string; + queueRepo: QueueRepository; + missionControlWriteContract: MissionControlWriteContract; + missionControlActionLog?: MissionControlActionLog; + stateFs?: StateFsOps; + loadEntities?: () => + | { ok: true; entities: HumanFragment[] } + | { ok: false; error: string; noHumans?: true }; +} + +export interface RelayInboundHandler { + handle(input: RelayInboundRequestInput): Promise; +} + +/** + * Verify that a TCP socket remoteAddress is a direct local loopback peer. + * Never trusts HTTP Host or X-Forwarded-* headers. + * Rejects missing/empty remoteAddress (e.g. in-memory app.request without a socket). + */ +export function isDirectLoopbackPeer(remoteAddress: string | undefined | null): boolean { + if (typeof remoteAddress !== "string") return false; + const trimmed = remoteAddress.trim().toLowerCase(); + if (!trimmed) return false; + if (trimmed === "::1") return true; + const stripped = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed; + const ipv4Match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(stripped); + if (!ipv4Match) return false; + return [ipv4Match[1], ipv4Match[2], ipv4Match[3]].every((octet) => { + const num = Number(octet); + return Number.isInteger(num) && num >= 0 && num <= 255; + }); +} + +/** + * Deterministic SeenStore key for a relay inbound event, scoped globally across + * the authenticated relay profile's questions so reusing an eventId on a + * different question is detected as equivocation. + */ +export function computeRelayEventKey(connectorRef: string, eventId: string): string { + return createHash("sha256") + .update(JSON.stringify([connectorRef, eventId])) + .digest("hex"); +} + +/** + * Deterministic metadata-only fingerprint of the canonical inbound envelope and + * resolved responder identity. Never stores plaintext reply text. + */ +export function computeRelayEventFingerprint(input: { + connectorRef: string; + eventId: string; + senderHandle: string; + actorSession: string; + replyRef: string; + qitemId: string; + episodeKey: string; + decisionRevision: string; + mode: "ask" | "park"; + ordering: RelayInboundOrdering; + text: string; +}): string { + const textHash = createHash("sha256").update(input.text).digest("hex"); + return createHash("sha256") + .update( + JSON.stringify({ + v: 1, + connectorRef: input.connectorRef, + eventId: input.eventId, + senderHandle: input.senderHandle, + actorSession: input.actorSession, + replyRef: input.replyRef, + qitemId: input.qitemId, + episodeKey: input.episodeKey, + decisionRevision: input.decisionRevision, + mode: input.mode, + ordering: input.ordering, + textHash, + }), + ) + .digest("hex"); +} + +function extractBearerToken(header: string | undefined): string | null { + if (typeof header !== "string") return null; + const match = /^Bearer\s+(\S+)$/.exec(header.trim()); + if (!match || !match[1]) return null; + return match[1]; +} + +function parseInboundEnvelope( + rawBody: string, +): { ok: true; envelope: RelayInboundEnvelope } | { ok: false; status: number; code: string; message: string } { + if (Buffer.byteLength(rawBody, "utf8") > RELAY_INBOUND_MAX_BODY_BYTES) { + return { + ok: false, + status: 413, + code: "payload_too_large", + message: `relay inbound payload exceeds ${RELAY_INBOUND_MAX_BODY_BYTES} bytes`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return { + ok: false, + status: 400, + code: "invalid_json", + message: "request body must be valid JSON", + }; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "relay inbound payload must be a JSON object", + }; + } + + const obj = parsed as Record; + const { eventId, sender, replyRef, text, ordering } = obj; + if (typeof eventId !== "string" || !EVENT_ID_PATTERN.test(eventId)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "eventId must be a non-empty identifier (1..256 chars, [A-Za-z0-9._:-])", + }; + } + + if (!sender || typeof sender !== "object" || Array.isArray(sender)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "sender must be an object with a handle field", + }; + } + const senderObj = sender as Record; + if (typeof senderObj.handle !== "string" || !HANDLE_PATTERN.test(senderObj.handle)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "sender.handle must be a valid non-empty handle", + }; + } + + if (typeof replyRef !== "string" || replyRef.length === 0 || replyRef.length > MAX_REPLY_REF_CHARS) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "replyRef must be a non-empty string", + }; + } + + if ( + typeof text !== "string" || + text.includes("\0") || + text.length > MAX_REPLY_TEXT_CHARS || + text.trim().length === 0 + ) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "text must be a non-empty string without NUL bytes", + }; + } + + let normalizedOrdering: RelayInboundOrdering = "uncertain"; + if (ordering !== undefined) { + if (ordering !== "after-question" && ordering !== "uncertain") { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "ordering must be 'after-question' or 'uncertain' when provided", + }; + } + normalizedOrdering = ordering; + } + + return { + ok: true, + envelope: { + eventId, + sender: { handle: senderObj.handle }, + replyRef, + text: text.trim(), + ordering: normalizedOrdering, + }, + }; +} + +function reconstructPayloadFromReceipt( + receipt: RelayOwnerNotificationReceipt & { qitemId: string }, + replyRef: string, + tokenRef: RelayReplyRefToken, +): { ok: true; payload: RelayReplyRefPayload; receiptKind: string | null } | { ok: false } { + const episodeKey = receipt.episodeKey ?? receipt.notificationKey; + const decisionRevision = receipt.revision; + const decisionId = receipt.deliveryId; + const connectorRef = receipt.connectorRef; + if ( + typeof receipt.qitemId !== "string" || + receipt.qitemId.length === 0 || + typeof episodeKey !== "string" || + episodeKey.length === 0 || + typeof decisionRevision !== "string" || + decisionRevision.length === 0 || + typeof decisionId !== "string" || + decisionId.length === 0 || + typeof connectorRef !== "string" || + connectorRef.length === 0 + ) { + return { ok: false }; + } + const candidate: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: receipt.qitemId, + episodeKey, + decisionRevision, + decisionId, + connectorRef, + mode: tokenRef.mode, + ...(tokenRef.digest === true ? { digest: true } : {}), + }; + if (encodeRelayReplyRef(candidate) !== replyRef) { + return { ok: false }; + } + return { ok: true, payload: candidate, receiptKind: receipt.kind ?? null }; +} + +function resolveOfferedReplyRefContext( + home: string, + queueRepo: QueueRepository, + replyRef: string, + tokenRef: RelayReplyRefToken, + authenticatedConnectorRef: string, +): + | { ok: true; ref: RelayReplyRefPayload; receiptKind: string | null } + | { ok: false; status: 403 | 409 | 503; code: string; message: string } { + let resolvedRef: RelayReplyRefPayload | null = null; + let receiptKind: string | null = null; + + // 1. Check durable accepted receipts FIRST (active queue_transitions + queue_transitions_archive) + let receipt: (RelayOwnerNotificationReceipt & { qitemId: string }) | null = null; + try { + if (typeof queueRepo.findRelayOwnerNotificationReceiptByReplyRef === "function") { + receipt = queueRepo.findRelayOwnerNotificationReceiptByReplyRef(replyRef); + } + } catch { + return { + ok: false, + status: 503, + code: "queue_store_unavailable", + message: "Relay inbound receipt lookup failed because queue transition store is unavailable", + }; + } + + if (receipt) { + const reconstructed = reconstructPayloadFromReceipt(receipt, replyRef, tokenRef); + if (reconstructed.ok) { + resolvedRef = reconstructed.payload; + receiptKind = reconstructed.receiptKind; + } else if (receipt.kind === "human-update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `qitem ${receipt.qitemId} is an informational update and cannot be answered`, + }; + } + } + + // 2. Fallback to pending DispatchBuffer ONLY when no durable accepted receipt matched + if (!resolvedRef) { + try { + const pending = findPendingRelayReplyRef(home, replyRef); + if ( + pending.found && + pending.qitemId && + pending.episodeKey && + pending.decisionRevision && + pending.decisionId && + pending.connectorRef && + pending.mode + ) { + const candidate: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: pending.qitemId, + episodeKey: pending.episodeKey, + decisionRevision: pending.decisionRevision, + decisionId: pending.decisionId, + connectorRef: pending.connectorRef, + mode: pending.mode, + ...(pending.digest === true ? { digest: true } : {}), + }; + if (encodeRelayReplyRef(candidate) === replyRef) { + resolvedRef = candidate; + } + } + } catch { + return { + ok: false, + status: 503, + code: "dispatch_buffer_unavailable", + message: + "Relay outbound dispatch buffer is unreadable; refusing to reject or reserve un-receipted replyRef", + }; + } + } + + if (!resolvedRef) { + return { + ok: false, + status: 409, + code: "unrecognized_reply_ref", + message: "replyRef does not match any active or accepted outbound relay decision", + }; + } + + if (resolvedRef.connectorRef !== authenticatedConnectorRef) { + return { + ok: false, + status: 403, + code: "connector_ref_mismatch", + message: "replyRef connectorRef does not match the authenticated relay profile", + }; + } + + return { ok: true, ref: resolvedRef, receiptKind }; +} + +function verifyOfferedReplyTarget( + queueRepo: QueueRepository, + resolvedRef: RelayReplyRefPayload, + receiptKind: string | null, +): { ok: true } | { ok: false; status: 404 | 409 | 503; code: string; message: string } { + if (receiptKind === "human-update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `qitem ${resolvedRef.qitemId} is an informational update and cannot be answered`, + }; + } + + let item: ReturnType; + let transitions: ReturnType; + try { + item = queueRepo.getById(resolvedRef.qitemId); + transitions = item ? queueRepo.listTransitions(resolvedRef.qitemId) : []; + } catch { + return { + ok: false, + status: 503, + code: "queue_store_unavailable", + message: "Relay inbound target verification failed because queue store is unavailable", + }; + } + + if (!item) { + return { + ok: false, + status: 404, + code: "qitem_not_found", + message: `qitem ${resolvedRef.qitemId} not found`, + }; + } + + if (item.humanIntent === "update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `qitem ${resolvedRef.qitemId} is an informational update and cannot be answered`, + }; + } + + const episodeIdPart = Number(resolvedRef.episodeKey.slice(`${resolvedRef.qitemId}:`.length)); + const episodeTransition = transitions.find((t) => t.transitionId === episodeIdPart); + if (episodeTransition?.ownerNotificationKind === "human-update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `episode ${resolvedRef.episodeKey} is an informational update and cannot be answered`, + }; + } + + return { ok: true }; +} + +function formatDecisionText(text: string, ordering: RelayInboundOrdering): string { + if (ordering === "after-question") return text; + return `${text} [relay ordering=uncertain: timing relative to question was not verified by relay]`; +} + +/** + * Wrap StateFsOps so SeenStore.statuses() fails closed (503) when history is + * unreadable or contains unparseable/unkeyable lines, while scoping keyed + * suspect records (missing/invalid status or conflicting fingerprints) to only + * their own eventKey so unrelated keys remain available and unvouched history + * never counts as unseen. Diagnostic fields such as `ts` or extra metadata are + * ignored when `id` and `status` vouch for the event. + */ +function strictRelayStateFs(base: StateFsOps = nodeStateFs): StateFsOps { + return { + ...base, + readFileSync(p: string): string { + const raw = base.readFileSync(p); + const suspectIds = new Set(); + const seenFingerprints = new Map(); + const normalizedLines: string[] = []; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error("corrupt relay inbound seen-store JSONL line"); + } + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + typeof (parsed as { id?: unknown }).id !== "string" || + !(parsed as { id: string }).id.trim() + ) { + throw new Error("unkeyable relay inbound seen-store record"); + } + const rec = parsed as { id: string; status?: unknown }; + const id = rec.id; + const statusStr = typeof rec.status === "string" ? rec.status : ""; + const match = VALID_SEEN_STATUS_PATTERN.exec(statusStr); + const priorFp = seenFingerprints.get(id); + if ( + !match || + suspectIds.has(id) || + (priorFp !== undefined && priorFp !== match[2]) + ) { + suspectIds.add(id); + normalizedLines.push(JSON.stringify({ id, status: SUSPECT_SEEN_STATUS })); + } else { + seenFingerprints.set(id, match[2]!); + normalizedLines.push(trimmed); + } + } + return normalizedLines.join("\n"); + }, + }; +} + +export function createRelayInboundHandler(deps: RelayInboundHandlerDeps): RelayInboundHandler { + const seenPath = join(deps.home, "state", RELAY_INBOUND_SEEN_FILE); + const seenStore = new SeenStore(seenPath, strictRelayStateFs(deps.stateFs ?? nodeStateFs)); + const inFlightByKey = new Map>(); + + const loadEntities = + deps.loadEntities ?? + (() => loadHumanRegistry(deps.home, { readOnly: true })); + + const runSerialized = async ( + eventKey: string, + fn: () => Promise, + ): Promise => { + const prior = inFlightByKey.get(eventKey) ?? Promise.resolve({ status: 200, body: {} }); + const next = prior.then( + () => fn(), + () => fn(), + ); + inFlightByKey.set(eventKey, next); + try { + return await next; + } finally { + if (inFlightByKey.get(eventKey) === next) { + inFlightByKey.delete(eventKey); + } + } + }; + + return { + async handle(input: RelayInboundRequestInput): Promise { + // 1. Require explicit Bearer token; missing/unready never bypasses authentication + const bearerToken = extractBearerToken(input.authorizationHeader); + if (!bearerToken) { + return { + status: 401, + body: { + error: "unauthorized", + message: "Missing or malformed Authorization: Bearer token for relay inbound", + }, + }; + } + + let entities: HumanFragment[] | null; + try { + entities = registeredPeople(loadEntities()); + } catch { + entities = null; + } + if (entities === null) { + return { + status: 503, + body: { + error: "human_registry_unavailable", + message: "Relay inbound authentication is unavailable because human registry cannot be read", + }, + }; + } + + const profiles = loadRelayProfiles(deps.home, { entities }); + const auth = authenticateRelayInboundToken(profiles, bearerToken); + if (!auth.ok) { + return { + status: 401, + body: { + error: "unauthorized", + reason: auth.reason, + message: "Invalid or unready relay inbound bearer token", + }, + }; + } + + const authenticatedConnectorRef = auth.profile.connectorRef; + + // 2. Parse and validate closed JSON envelope + const parsedEnv = parseInboundEnvelope(input.rawBody); + if (!parsedEnv.ok) { + return { + status: parsedEnv.status, + body: { + error: parsedEnv.code, + message: parsedEnv.message, + }, + }; + } + const { envelope } = parsedEnv; + + // 3. Decode replyRef token and enforce profile match + sender handle resolution + const decodedRef = decodeRelayReplyRef(envelope.replyRef); + if (!decodedRef.ok) { + return { + status: 400, + body: { + error: "invalid_reply_ref", + message: decodedRef.error, + }, + }; + } + const tokenRef = decodedRef.ref; + + if (tokenRef.connectorHash !== hashRelayConnectorRef(authenticatedConnectorRef)) { + return { + status: 403, + body: { + error: "connector_ref_mismatch", + message: "replyRef connectorRef does not match the authenticated relay profile", + }, + }; + } + + const senderResolution = resolveRelayHandle( + authenticatedConnectorRef, + envelope.sender.handle, + entities, + ); + if (senderResolution.kind !== "registered") { + return { + status: 403, + body: { + error: "sender_not_bound", + message: senderResolution.error, + }, + }; + } + + const actorSession = senderResolution.address; + const eventKey = computeRelayEventKey(authenticatedConnectorRef, envelope.eventId); + + return runSerialized(eventKey, async () => { + // 4. Strict SeenStore read path: fail closed (503) if corrupt/unreadable or keyed suspect + let statuses: Map; + try { + statuses = seenStore.statuses(); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Relay inbound seen-store is unreadable (${seenPath}); refusing to process or acknowledge`, + }, + }; + } + + const existingStatus = statuses.get(eventKey); + let existingFp: string | undefined; + if (existingStatus !== undefined) { + const match = VALID_SEEN_STATUS_PATTERN.exec(existingStatus); + if (!match) { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Relay inbound seen-store record for this eventId is corrupt or unvouched (${seenPath}); refusing to process or acknowledge`, + }, + }; + } + existingFp = match[2]; + } + + // 5. Resolve offered replyRef context (durable accepted receipt first, then pending DispatchBuffer) + const resolved = resolveOfferedReplyRefContext( + deps.home, + deps.queueRepo, + envelope.replyRef, + tokenRef, + authenticatedConnectorRef, + ); + if (!resolved.ok) { + return { + status: resolved.status, + body: { + error: resolved.code, + message: resolved.message, + }, + }; + } + + const ref = resolved.ref; + const fingerprint = computeRelayEventFingerprint({ + connectorRef: authenticatedConnectorRef, + eventId: envelope.eventId, + senderHandle: envelope.sender.handle, + actorSession, + replyRef: envelope.replyRef, + qitemId: ref.qitemId, + episodeKey: ref.episodeKey, + decisionRevision: ref.decisionRevision, + mode: ref.mode, + ordering: envelope.ordering, + text: envelope.text, + }); + + const receivedTag = `received:${fingerprint}`; + const processedTag = `processed:${fingerprint}`; + const verb = ref.mode === "ask" ? ("answer" as const) : ("resolve" as const); + + // 6. Check existing seen-store status BEFORE reading current queue target so processed replays never depend on current getById/listTransitions + if (existingStatus !== undefined) { + if (existingFp !== fingerprint) { + return { + status: 409, + body: { + error: "event_conflict", + message: "eventId was already used with a different payload, sender, or target", + }, + }; + } + if (existingStatus === processedTag) { + return { + status: 200, + body: { + ok: true, + deduplicated: true, + qitemId: ref.qitemId, + verb, + actorSession, + }, + }; + } + } else { + // 7. Verify current target & non-update guards only for new actions before first reservation + const targetVerified = verifyOfferedReplyTarget( + deps.queueRepo, + ref, + resolved.receiptKind, + ); + if (!targetVerified.ok) { + return { + status: targetVerified.status, + body: { + error: targetVerified.code, + message: targetVerified.message, + }, + }; + } + + try { + seenStore.mark(eventKey, receivedTag); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Failed to reserve relay inbound event in seen-store (${seenPath})`, + }, + }; + } + } + + const operationId = `relay:${eventKey}:${fingerprint}`; + const decision = formatDecisionText(envelope.text, envelope.ordering); + const actionInput: MissionControlActionInput = { + verb, + qitemId: ref.qitemId, + actorSession, + identityProvenance: "transport:v1", + decision, + ...(verb === "resolve" && envelope.ordering === "after-question" + ? { answerText: envelope.text } + : {}), + expectedRevision: ref.decisionRevision, + auditNotes: { + operationId, + connectorRef: authenticatedConnectorRef, + eventId: envelope.eventId, + senderHandle: envelope.sender.handle, + episodeKey: ref.episodeKey, + ordering: envelope.ordering, + }, + }; + + try { + const result = await deps.missionControlWriteContract.act(actionInput); + try { + seenStore.mark(eventKey, processedTag); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Action committed but seen-store processed mark failed (${seenPath}); retry safely with same eventId`, + }, + }; + } + + return { + status: 200, + body: { + ok: true, + deduplicated: Boolean(result.reconciled), + reconciled: Boolean(result.reconciled), + qitemId: ref.qitemId, + verb, + actorSession, + actionId: result.actionId, + }, + }; + } catch (err) { + if (err instanceof MissionControlWriteContractError) { + // If this event was already reserved as `received:` and its own + // committed action changed the item's revision before `processed:` + // was marked (e.g. a resolve that committed and was later re-parked), check + // whether the audit log already holds this exact operationId and reconcile + // without any new mutation. + if ( + existingStatus === receivedTag && + err.code === "decision_revision_changed" && + deps.missionControlActionLog + ) { + const priorAudit = deps.missionControlActionLog + .listForQitem(ref.qitemId, 50) + .find((entry) => entry.auditNotes?.operationId === operationId); + if (priorAudit) { + try { + seenStore.mark(eventKey, processedTag); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Action reconciled but seen-store processed mark failed (${seenPath})`, + }, + }; + } + return { + status: 200, + body: { + ok: true, + deduplicated: true, + reconciled: true, + qitemId: ref.qitemId, + verb, + actorSession, + actionId: priorAudit.actionId, + }, + }; + } + } + + const status = + err.code === "qitem_not_found" + ? 404 + : err.code === "decision_revision_changed" || + err.code === "qitem_already_terminal" || + err.code === "qitem_not_leg1_parked" || + err.code === "answer_not_recorded" || + err.code === "answer_not_closed" + ? 409 + : 400; + return { + status, + body: { + error: err.code, + message: err.message, + }, + }; + } + + return { + status: 500, + body: { + error: "internal_error", + message: "Relay inbound action failed before completion", + }, + }; + } + }); + }, + }; +} diff --git a/packages/daemon/src/domain/gateway/relay-outbound.ts b/packages/daemon/src/domain/gateway/relay-outbound.ts new file mode 100644 index 000000000..7a0693ad6 --- /dev/null +++ b/packages/daemon/src/domain/gateway/relay-outbound.ts @@ -0,0 +1,939 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { DispatchBuffer } from "./dispatch-buffer.js"; +import type { SubsystemDeliverFn, SubsystemDeliveryOutcome } from "./gateway-subsystem.js"; +import { HANDLE_PATTERN, type HumanFragment, type LoadResult } from "./human-registry.js"; +import type { OutboundDecision } from "./protocol.js"; +import { validateRelayUrl } from "./relay-config.js"; +import { redactSecrets } from "./slack/message.js"; +import { OUTBOUND_OP, type OutboundPostPayload } from "./slack/outbound-driver.js"; +import type { FetchImpl } from "./slack/slack-api.js"; +import { SeenStore } from "./slack/state-store.js"; +import type { QueueRepository } from "../queue-repository.js"; + +export const RELAY_PROTOCOL_VERSION = 1 as const; +export const RELAY_ATTEMPTED_DECISIONS_FILE = "relay-attempted-decisions.jsonl"; +export const RELAY_DELIVERED_DECISIONS_FILE = "relay-delivered-decisions.jsonl"; + +export const RELAY_SUMMARY_MAX_CHARS = 1000; +export const RELAY_DEFAULT_TIMEOUT_MS = 15_000; + +export type RelayQuestionMode = "ask" | "park"; +export type RelayIntent = "decision" | "update"; +export type RelayDeliveryOutcome = "interrupt" | "notify" | "digest"; + +export interface RelayReplyRefPayload { + v: 1; + qitemId: string; + episodeKey: string; + decisionRevision: string; + decisionId: string; + connectorRef: string; + mode: RelayQuestionMode; + digest?: boolean; +} + +export interface RelayReplyRefToken { + v: 1; + payloadHash: string; + connectorHash: string; + mode: RelayQuestionMode; + digest?: boolean; +} + +export interface RelayDigestItem { + qitemId: string; + notificationKey: string; + summary: string; + body?: string; + replyRef: string | null; +} + +export interface RelayOutboundEnvelope { + v: 1; + deliveryId: string; + recipient: { + handle: string; + }; + intent: RelayIntent; + outcome: RelayDeliveryOutcome; + summary: string; + body: string; + replyRef: string | null; + items?: RelayDigestItem[]; +} + +export interface RelayPayloadCarry extends OutboundPostPayload { + relayConnectorRef?: string; + relayEnvelope?: RelayOutboundEnvelope; + relayRevision?: string | null; + relayReplyRefs?: Record; + deliveryDigestPost?: boolean; + digestId?: string; + memberReceipts?: Array<{ + qitemId: string; + notificationKey: string; + level: string; + kind: string; + summary?: string; + body?: string; + }>; + quietRelease?: boolean; + deliveryDeferralFire?: boolean; +} + +function asRelayPayloadCarry(v: unknown): RelayPayloadCarry | null { + if (!isObj(v)) return null; + return v as unknown as RelayPayloadCarry; +} + +const ALLOWED_REPLY_REF_TOKEN_KEYS = new Set([ + "v", + "payloadHash", + "connectorHash", + "mode", + "digest", +]); + +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; + +const ALLOWED_ENVELOPE_KEYS = new Set([ + "v", + "deliveryId", + "recipient", + "intent", + "outcome", + "summary", + "body", + "replyRef", + "items", +]); + +const ALLOWED_RECIPIENT_KEYS = new Set(["handle"]); +const ALLOWED_DIGEST_ITEM_KEYS = new Set(["qitemId", "notificationKey", "summary", "body", "replyRef"]); + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function nonEmptyStr(v: unknown): v is string { + return typeof v === "string" && v.trim().length > 0; +} + +function unknownKey(obj: Record, allowed: Set): string | undefined { + for (const k of Object.keys(obj)) { + if (!allowed.has(k)) return k; + } + return undefined; +} + +function canonicalizeRelayReplyRefPayload(payload: RelayReplyRefPayload): RelayReplyRefPayload { + return { + v: RELAY_PROTOCOL_VERSION, + qitemId: payload.qitemId, + episodeKey: payload.episodeKey, + decisionRevision: payload.decisionRevision, + decisionId: payload.decisionId, + connectorRef: payload.connectorRef, + mode: payload.mode, + ...(payload.digest === true ? { digest: true } : {}), + }; +} + +/** Deterministic SHA-256 hex digest of the canonical frozen RelayReplyRefPayload. */ +export function hashRelayReplyRefPayload(payload: RelayReplyRefPayload): string { + return createHash("sha256") + .update(JSON.stringify(canonicalizeRelayReplyRefPayload(payload)), "utf8") + .digest("hex"); +} + +/** Deterministic SHA-256 hex digest of a relay connectorRef for bounded token binding. */ +export function hashRelayConnectorRef(connectorRef: string): string { + return createHash("sha256").update(connectorRef, "utf8").digest("hex"); +} + +/** Encode structured correlation fields into a fixed-size opaque base64url replyRef token. */ +export function encodeRelayReplyRef(payload: RelayReplyRefPayload): string { + const canonical = canonicalizeRelayReplyRefPayload(payload); + const token: RelayReplyRefToken = { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(canonical), + connectorHash: hashRelayConnectorRef(canonical.connectorRef), + mode: canonical.mode, + ...(canonical.digest === true ? { digest: true } : {}), + }; + return Buffer.from(JSON.stringify(token), "utf8").toString("base64url"); +} + +/** Decode and strictly validate a fixed-size opaque base64url replyRef token. */ +export function decodeRelayReplyRef( + raw: unknown, +): { ok: true; ref: RelayReplyRefToken } | { ok: false; error: string } { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(raw)) { + return { ok: false, error: "replyRef must be a non-empty base64url string" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); + } catch { + return { ok: false, error: "replyRef is not valid base64url JSON" }; + } + if (!isObj(parsed)) return { ok: false, error: "replyRef payload must be a JSON object" }; + const uk = unknownKey(parsed, ALLOWED_REPLY_REF_TOKEN_KEYS); + if (uk) return { ok: false, error: `replyRef has unknown key "${uk}"` }; + if (parsed.v !== RELAY_PROTOCOL_VERSION) return { ok: false, error: `replyRef.v must be ${RELAY_PROTOCOL_VERSION}` }; + if (typeof parsed.payloadHash !== "string" || !SHA256_HEX_PATTERN.test(parsed.payloadHash)) { + return { ok: false, error: "replyRef.payloadHash must be a 64-char lowercase hex sha256" }; + } + if (typeof parsed.connectorHash !== "string" || !SHA256_HEX_PATTERN.test(parsed.connectorHash)) { + return { ok: false, error: "replyRef.connectorHash must be a 64-char lowercase hex sha256" }; + } + if (parsed.mode !== "ask" && parsed.mode !== "park") { + return { ok: false, error: "replyRef.mode must be ask|park" }; + } + if (parsed.digest !== undefined && typeof parsed.digest !== "boolean") { + return { ok: false, error: "replyRef.digest must be a boolean when present" }; + } + return { + ok: true, + ref: { + v: RELAY_PROTOCOL_VERSION, + payloadHash: parsed.payloadHash, + connectorHash: parsed.connectorHash, + mode: parsed.mode, + ...(parsed.digest === true ? { digest: true } : {}), + }, + }; +} + +/** Deterministic SHA-256 hex digest of an offered replyRef for transition-note evidence. */ +export function hashRelayReplyRef(replyRef: string): string { + return createHash("sha256").update(replyRef, "utf8").digest("hex"); +} + +/** Validate a closed protocol v1 RelayOutboundEnvelope. */ +export function validateRelayOutboundEnvelope( + raw: unknown, +): { ok: true; envelope: RelayOutboundEnvelope } | { ok: false; error: string } { + if (!isObj(raw)) return { ok: false, error: "relay envelope must be an object" }; + const uk = unknownKey(raw, ALLOWED_ENVELOPE_KEYS); + if (uk) return { ok: false, error: `relay envelope has unknown key "${uk}"` }; + if (raw.v !== RELAY_PROTOCOL_VERSION) return { ok: false, error: `relay envelope.v must be ${RELAY_PROTOCOL_VERSION}` }; + if (!nonEmptyStr(raw.deliveryId)) return { ok: false, error: "relay envelope.deliveryId must be a non-empty string" }; + if (!isObj(raw.recipient)) return { ok: false, error: "relay envelope.recipient must be an object" }; + const ruk = unknownKey(raw.recipient, ALLOWED_RECIPIENT_KEYS); + if (ruk) return { ok: false, error: `relay envelope.recipient has unknown key "${ruk}"` }; + if (!nonEmptyStr(raw.recipient.handle) || !HANDLE_PATTERN.test(raw.recipient.handle)) { + return { ok: false, error: "relay envelope.recipient.handle must match HANDLE_PATTERN" }; + } + if (raw.intent !== "decision" && raw.intent !== "update") { + return { ok: false, error: "relay envelope.intent must be decision|update" }; + } + if (raw.outcome !== "interrupt" && raw.outcome !== "notify" && raw.outcome !== "digest") { + return { ok: false, error: "relay envelope.outcome must be interrupt|notify|digest" }; + } + if (typeof raw.summary !== "string") return { ok: false, error: "relay envelope.summary must be a string" }; + if (typeof raw.body !== "string") return { ok: false, error: "relay envelope.body must be a string" }; + if (raw.replyRef !== null && typeof raw.replyRef !== "string") { + return { ok: false, error: "relay envelope.replyRef must be string or null" }; + } + if (raw.intent === "update" && raw.replyRef !== null) { + return { ok: false, error: "relay envelope.replyRef must be null when intent is update" }; + } + if (raw.replyRef !== null) { + const decoded = decodeRelayReplyRef(raw.replyRef); + if (!decoded.ok) return { ok: false, error: `relay envelope.replyRef invalid: ${decoded.error}` }; + } + let items: RelayDigestItem[] | undefined; + if (raw.items !== undefined) { + if (!Array.isArray(raw.items)) return { ok: false, error: "relay envelope.items must be an array when present" }; + items = []; + for (const [i, item] of raw.items.entries()) { + if (!isObj(item)) return { ok: false, error: `relay envelope.items[${i}] must be an object` }; + const iuk = unknownKey(item, ALLOWED_DIGEST_ITEM_KEYS); + if (iuk) return { ok: false, error: `relay envelope.items[${i}] has unknown key "${iuk}"` }; + if (!nonEmptyStr(item.qitemId) || !nonEmptyStr(item.notificationKey) || typeof item.summary !== "string") { + return { ok: false, error: `relay envelope.items[${i}] missing required fields` }; + } + if (item.body !== undefined && typeof item.body !== "string") { + return { ok: false, error: `relay envelope.items[${i}].body must be a string when present` }; + } + if (item.replyRef !== null && typeof item.replyRef !== "string") { + return { ok: false, error: `relay envelope.items[${i}].replyRef must be string or null` }; + } + if (typeof item.replyRef === "string") { + const dec = decodeRelayReplyRef(item.replyRef); + if (!dec.ok) return { ok: false, error: `relay envelope.items[${i}].replyRef invalid: ${dec.error}` }; + } + items.push({ + qitemId: item.qitemId, + notificationKey: item.notificationKey, + summary: item.summary, + ...(typeof item.body === "string" ? { body: item.body } : {}), + replyRef: item.replyRef, + }); + } + } + return { + ok: true, + envelope: { + v: RELAY_PROTOCOL_VERSION, + deliveryId: raw.deliveryId, + recipient: { handle: raw.recipient.handle }, + intent: raw.intent, + outcome: raw.outcome, + summary: raw.summary, + body: raw.body, + replyRef: raw.replyRef, + ...(items !== undefined ? { items } : {}), + }, + }; +} + +/** Snap a UTF-16 index so it never splits a surrogate pair (`0xD800..0xDBFF` followed by `0xDC00..0xDFFF`). */ +function snapCodePointIndex(str: string, idx: number): number { + if (idx <= 0) return 0; + if (idx >= str.length) return str.length; + const prev = str.charCodeAt(idx - 1); + const curr = str.charCodeAt(idx); + if (prev >= 0xd800 && prev <= 0xdbff && curr >= 0xdc00 && curr <= 0xdfff) { + return idx - 1; + } + return idx; +} + +export interface RelayQuestionState { + qitemId: string; + mode: RelayQuestionMode; + episodeKey: string; + decisionRevision: string; + humanIntent?: "decision" | "update" | null; + summary?: string | null; + body?: string | null; +} + +export interface PrepareRelayDecisionOpts { + loadHumanRegistry: () => LoadResult | { ok: boolean; entities?: readonly HumanFragment[] }; + queueRepo?: Pick; + lookupQuestionState?: (qitemId: string, fallbackKey?: string | null) => RelayQuestionState | null; + resolveOutcome?: (payload: OutboundPostPayload) => RelayDeliveryOutcome; +} + +/** Look up actual current question state from QueueRepository. Never mints synthetic state when row or current episode is missing. */ +function defaultLookupQuestionState( + queueRepo: Pick | undefined, + qitemId: string, + expectedEpisodeKey?: string | null, +): RelayQuestionState | null { + if (!queueRepo) return null; + const row = queueRepo.getById(qitemId); + if (!row || (row.state !== "pending" && row.state !== "blocked")) return null; + const ep = queueRepo.currentHumanRequiredEpisode(qitemId); + if (!ep) return null; + if (expectedEpisodeKey && expectedEpisodeKey !== ep.key) return null; + const decisionRevision = queueRepo.decisionRevision(row); + const mode: RelayQuestionMode = row.state === "blocked" ? "park" : "ask"; + return { + qitemId, + mode, + episodeKey: ep.key, + decisionRevision, + humanIntent: row.humanIntent ?? null, + summary: row.summary ?? null, + }; +} + +function renderPlainTextBody(p: OutboundPostPayload): string { + const sections: string[] = []; + const cleanedSummary = redactSecrets(String(p.summary ?? "")).replace(/\s+/g, " ").trim(); + const mainBody = redactSecrets(String(p.body ?? "")).trim(); + if (cleanedSummary.length > RELAY_SUMMARY_MAX_CHARS && !mainBody.includes(cleanedSummary)) { + sections.push(cleanedSummary); + } + if (mainBody) sections.push(mainBody); + const detail = redactSecrets(String(p.humanDetail ?? "")).trim(); + if (detail) sections.push(detail); + if (Array.isArray(p.humanQuestions) && p.humanQuestions.length > 0) { + const qLines: string[] = []; + for (const q of p.humanQuestions) { + const header = redactSecrets(String((q as { header?: string }).header ?? "")).trim(); + if (header) qLines.push(`[${header}]`); + qLines.push(redactSecrets(String(q.question ?? "")).trim()); + for (const opt of q.options ?? []) { + const desc = redactSecrets(String((opt as { description?: string }).description ?? "")).trim(); + const descSuffix = desc ? ` — ${desc}` : ""; + qLines.push(`- ${redactSecrets(String(opt.label ?? "")).trim()}${opt.recommended ? " (recommended)" : ""}${descSuffix}`); + } + } + if (qLines.length > 0) sections.push(qLines.join("\n")); + } + return sections.join("\n\n"); +} + +function renderPlainTextSummary(summary: string | null | undefined, fallback: string): string { + const cleaned = redactSecrets(String(summary ?? fallback)).replace(/\s+/g, " ").trim() || fallback; + return cleaned.length > RELAY_SUMMARY_MAX_CHARS + ? cleaned.slice(0, snapCodePointIndex(cleaned, RELAY_SUMMARY_MAX_CHARS)) + : cleaned; +} + +/** Resolve the recipient human's primary binding when it is a `relay` binding. + * Requires exact registered human address or exact bare entityId (no broad localpart/domain splitting). */ +export function resolvePrimaryRelayBinding( + destinationOrRef: string | null | undefined, + reg: LoadResult | { ok: boolean; entities?: readonly HumanFragment[] }, +): { entity: HumanFragment; connectorRef: string; handle: string; secretsRef: string } | null { + if (!reg.ok || !reg.entities) return null; + const raw = String(destinationOrRef ?? "").trim(); + if (!raw) return null; + const human = reg.entities.find((e) => e.address === raw || e.entityId === raw); + if (!human) return null; + const primary = human.connectorBindings.find((b) => b.role === "primary"); + if (!primary || (primary.kind as string) !== "relay") return null; + return { + entity: human, + connectorRef: primary.connectorRef, + handle: primary.handle ?? human.entityId, + secretsRef: primary.secretsRef, + }; +} + +/** Synchronous pre-enqueue preparation hook: freezes the protocol v1 relay envelope and replyRef(s) + * onto the decision payload when the recipient's primary binding is `relay`. */ +export function prepareRelayDecision(decision: OutboundDecision, opts: PrepareRelayDecisionOpts): OutboundDecision { + if (decision.op !== OUTBOUND_OP) return decision; + const p = asRelayPayloadCarry(decision.payload); + if (!p) return decision; + // Already frozen on an earlier pass: never recompute refs. + if (p.relayEnvelope && p.relayConnectorRef) return decision; + + const reg = opts.loadHumanRegistry(); + const binding = resolvePrimaryRelayBinding(p.destinationSession ?? decision.entityBindingRef, reg); + if (!binding) return decision; + + const outcome: RelayDeliveryOutcome = p.deliveryDigestPost + ? "digest" + : p.quietRelease + ? "notify" + : p.deliveryDeferralFire + ? "interrupt" + : opts.resolveOutcome?.(p) ?? "notify"; + + const lookup = (qitemId: string, fallbackKey?: string | null): RelayQuestionState | null => + opts.lookupQuestionState + ? opts.lookupQuestionState(qitemId, fallbackKey) + : defaultLookupQuestionState(opts.queueRepo, qitemId, fallbackKey); + + const memberReceipts = Array.isArray(p.memberReceipts) ? p.memberReceipts : undefined; + let intent: RelayIntent; + let replyRef: string | null = null; + let items: RelayDigestItem[] | undefined; + let relayRevision: string | null = null; + const relayReplyRefs: Record = {}; + + if (memberReceipts && memberReceipts.length > 0) { + items = memberReceipts.map((m) => { + const qState = lookup(m.qitemId, m.notificationKey); + const isDecisionMember = m.kind === "human-required" && qState !== null && qState.humanIntent !== "update"; + let memberRef: string | null = null; + if (isDecisionMember) { + const memberPayload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: m.qitemId, + episodeKey: qState.episodeKey, + decisionRevision: qState.decisionRevision, + decisionId: decision.decisionId, + connectorRef: binding.connectorRef, + mode: qState.mode, + digest: true, + }; + memberRef = encodeRelayReplyRef(memberPayload); + relayReplyRefs[memberRef] = memberPayload; + } + const rawItemSummary = qState?.summary ?? m.summary; + const rawItemBody = qState?.body ?? m.body; + const cleanedItemSummary = + typeof rawItemSummary === "string" ? redactSecrets(rawItemSummary).replace(/\s+/g, " ").trim() : ""; + let cleanedItemBody = typeof rawItemBody === "string" ? redactSecrets(rawItemBody).trim() : ""; + if (cleanedItemSummary.length > RELAY_SUMMARY_MAX_CHARS && !cleanedItemBody.includes(cleanedItemSummary)) { + cleanedItemBody = cleanedItemBody ? `${cleanedItemSummary}\n\n${cleanedItemBody}` : cleanedItemSummary; + } + return { + qitemId: m.qitemId, + notificationKey: m.notificationKey, + summary: renderPlainTextSummary(rawItemSummary, m.qitemId), + ...(cleanedItemBody ? { body: cleanedItemBody } : {}), + replyRef: memberRef, + }; + }); + const anyDecision = items.some((it) => it.replyRef !== null); + intent = anyDecision ? "decision" : "update"; + // Multi-item digests must have top-level replyRef = null so one reply cannot resolve several questions. + replyRef = items.length === 1 ? items[0]!.replyRef : null; + } else { + const isDecision = + p.humanIntent !== "update" && + (p.ownerNotificationKind === "human-required" || + (p.humanIntent === "decision" && p.ownerNotificationKind !== "human-decision-resolved")); + intent = isDecision ? "decision" : "update"; + if (isDecision && p.qitemId) { + const qState = lookup(p.qitemId, p.notificationKey); + if (qState) { + relayRevision = qState.decisionRevision; + const askPayload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: p.qitemId, + episodeKey: qState.episodeKey, + decisionRevision: qState.decisionRevision, + decisionId: decision.decisionId, + connectorRef: binding.connectorRef, + mode: qState.mode, + ...(outcome === "digest" ? { digest: true } : {}), + }; + replyRef = encodeRelayReplyRef(askPayload); + relayReplyRefs[replyRef] = askPayload; + } + } + } + + const envelope: RelayOutboundEnvelope = { + v: RELAY_PROTOCOL_VERSION, + deliveryId: decision.decisionId, + recipient: { handle: binding.handle }, + intent, + outcome, + summary: renderPlainTextSummary(p.summary, p.qitemId || decision.decisionId), + body: renderPlainTextBody(p), + replyRef, + ...(items ? { items } : {}), + }; + + return { + ...decision, + payload: { + ...p, + relayConnectorRef: binding.connectorRef, + relayEnvelope: envelope, + ...(relayRevision ? { relayRevision } : {}), + ...(Object.keys(relayReplyRefs).length > 0 ? { relayReplyRefs } : {}), + } satisfies RelayPayloadCarry, + }; +} + +export interface PendingRelayReplyRefMatch { + found: boolean; + decisionId?: string; + connectorRef?: string; + qitemId?: string; + episodeKey?: string; + decisionRevision?: string; + mode?: RelayQuestionMode; + digest?: boolean; +} + +/** Check the durable DispatchBuffer for an un-Acked decision that offered the exact `replyRef`. */ +export function findPendingRelayReplyRef(home: string, replyRef: string): PendingRelayReplyRefMatch { + const decoded = decodeRelayReplyRef(replyRef); + if (!decoded.ok) return { found: false }; + const pending = new DispatchBuffer(home).pendingStrict(); + for (const decision of pending) { + const p = asRelayPayloadCarry(decision.payload); + if (!p) continue; + const env = p.relayEnvelope; + if (!env) continue; + const matchedTop = env.replyRef === replyRef; + const matchedItem = env.items?.some((it) => it.replyRef === replyRef) ?? false; + if (!matchedTop && !matchedItem) continue; + const frozen = p.relayReplyRefs?.[replyRef]; + if ( + !isObj(frozen) || + frozen.v !== RELAY_PROTOCOL_VERSION || + !nonEmptyStr(frozen.qitemId) || + !nonEmptyStr(frozen.episodeKey) || + !nonEmptyStr(frozen.decisionRevision) || + !nonEmptyStr(frozen.decisionId) || + !nonEmptyStr(frozen.connectorRef) || + (frozen.mode !== "ask" && frozen.mode !== "park") || + (frozen.digest !== undefined && typeof frozen.digest !== "boolean") + ) { + continue; + } + const candidate: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: frozen.qitemId, + episodeKey: frozen.episodeKey, + decisionRevision: frozen.decisionRevision, + decisionId: frozen.decisionId, + connectorRef: frozen.connectorRef, + mode: frozen.mode, + ...(frozen.digest === true ? { digest: true } : {}), + }; + if (encodeRelayReplyRef(candidate) !== replyRef) continue; + if (decision.decisionId !== candidate.decisionId) continue; + if (p.relayConnectorRef && p.relayConnectorRef !== candidate.connectorRef) continue; + return { + found: true, + decisionId: decision.decisionId, + connectorRef: candidate.connectorRef, + qitemId: candidate.qitemId, + episodeKey: candidate.episodeKey, + decisionRevision: candidate.decisionRevision, + mode: candidate.mode, + ...(candidate.digest ? { digest: true } : {}), + }; + } + return { found: false }; +} + +export interface PostRelayEnvelopeInput { + url: string; + outboundToken: string; + envelope: RelayOutboundEnvelope; + fetchImpl?: FetchImpl; + timeoutMs?: number; + stopSignal?: AbortSignal; +} + +export type RelayHttpResult = + | { ok: true; status: number } + | { ok: false; status: number; class: string; detail: string }; + +/** Loopback HTTP POST to the operator's relay bridge. + * - Validates loopback HTTP URL via `validateRelayUrl`. + * - Rejects redirects (`redirect: "error"`). + * - Does not buffer or log arbitrary bridge response bodies (status is sufficient for custody). */ +export async function postRelayEnvelope(input: PostRelayEnvelopeInput): Promise { + const urlCheck = validateRelayUrl(input.url); + if (!urlCheck.ok) { + return { + ok: false, + status: 0, + class: "invalid-endpoint", + detail: urlCheck.reason, + }; + } + const token = input.outboundToken.trim(); + if (!token) { + return { + ok: false, + status: 0, + class: "missing-credential", + detail: "relay outbound token is empty", + }; + } + const valid = validateRelayOutboundEnvelope(input.envelope); + if (!valid.ok) { + return { + ok: false, + status: 0, + class: "invalid-envelope", + detail: valid.error, + }; + } + const bodyJson = JSON.stringify(valid.envelope); + + if (input.stopSignal?.aborted) { + return { + ok: false, + status: 0, + class: "stopped", + detail: "relay transport stopped", + }; + } + + const timeoutMs = input.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); + const signal = input.stopSignal ? AbortSignal.any([ctrl.signal, input.stopSignal]) : ctrl.signal; + + try { + const fetchFn = input.fetchImpl ?? fetch; + const res = await fetchFn(urlCheck.url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json; charset=utf-8", + }, + body: bodyJson, + redirect: "error", + signal, + }); + void res.body?.cancel?.().catch(() => {}); + if (res.status >= 300 && res.status < 400) { + return { + ok: false, + status: res.status, + class: "redirect-refused", + detail: `relay redirect (${res.status}) is refused`, + }; + } + if (!res.ok) { + return { + ok: false, + status: res.status, + class: `http-${res.status}`, + detail: `http ${res.status}`, + }; + } + return { ok: true, status: res.status }; + } catch (error) { + if (input.stopSignal?.aborted) { + return { + ok: false, + status: 0, + class: "stopped", + detail: "relay transport stopped", + }; + } + const rawMsg = (error as Error)?.message || ""; + const isRedirect = /redirect/i.test(rawMsg); + const isTimeout = ctrl.signal.aborted || /timeout|abort/i.test(rawMsg); + return { + ok: false, + status: 0, + class: isRedirect ? "redirect-refused" : isTimeout ? "timeout" : "transport", + detail: isRedirect + ? "relay redirect is refused" + : isTimeout + ? `relay request timed out after ${timeoutMs}ms` + : "relay transport request failed", + }; + } finally { + clearTimeout(timer); + } +} + +export interface RelayAcceptedReceiptInfo { + qitemId: string; + notificationKey: string; + connectorRef: string; + deliveryId: string; + replyRef: string | null; + refHash: string | null; + revision: string | null; + episodeKey: string | null; + level: string; + kind: string; + humanIntent?: "decision" | "update" | null; + digestId?: string | null; +} + +export type ResolvedRelayOutboundProfile = + | { ok: true; connectorRef: string; url: string; outboundToken: string } + | { ok: false; reason: string }; + +export interface RelayOutboundTransportOpts { + attempted: SeenStore; + delivered: SeenStore; + outboundSeen: SeenStore; + resolveProfile: (connectorRef: string) => ResolvedRelayOutboundProfile; + onAccepted: (info: RelayAcceptedReceiptInfo) => void; + hasAcceptedReceipt?: (info: { qitemId: string; notificationKey: string }) => boolean; + onTransportFailed?: (payload: OutboundPostPayload, failureClass: string, detail: string) => void; + release?: (notificationKey: string) => void; + fetchImpl?: FetchImpl; + timeoutMs?: number; + stopSignal?: AbortSignal; + log?: (msg: string) => void; +} + +export interface RelayOutboundTransport { + deliver: SubsystemDeliverFn; + wasAttempted: (decision: OutboundDecision) => boolean; +} + +function buildExpectedReceipts(decision: OutboundDecision, p: RelayPayloadCarry, connectorRef: string, envelope: RelayOutboundEnvelope): RelayAcceptedReceiptInfo[] { + if (p.deliveryDigestPost && Array.isArray(p.memberReceipts) && p.memberReceipts.length > 0) { + return p.memberReceipts.map((m) => { + const itemEntry = envelope.items?.find((it) => it.qitemId === m.qitemId && it.notificationKey === m.notificationKey); + const itemRef = itemEntry?.replyRef ?? (p.memberReceipts!.length === 1 ? envelope.replyRef : null); + const frozenItemRef = itemRef ? p.relayReplyRefs?.[itemRef] : undefined; + return { + qitemId: m.qitemId, + notificationKey: m.notificationKey, + connectorRef, + deliveryId: decision.decisionId, + replyRef: itemRef, + refHash: itemRef ? hashRelayReplyRef(itemRef) : null, + revision: frozenItemRef?.decisionRevision ?? null, + episodeKey: frozenItemRef?.episodeKey ?? m.notificationKey, + level: m.level, + kind: m.kind, + humanIntent: m.kind === "human-required" ? "decision" : "update", + digestId: p.digestId ?? "unknown", + }; + }); + } + if (p.qitemId) { + const key = p.notificationKey ?? p.qitemId; + const frozenRef = envelope.replyRef ? p.relayReplyRefs?.[envelope.replyRef] : undefined; + return [ + { + qitemId: p.qitemId, + notificationKey: key, + connectorRef, + deliveryId: decision.decisionId, + replyRef: envelope.replyRef, + refHash: envelope.replyRef ? hashRelayReplyRef(envelope.replyRef) : null, + revision: frozenRef?.decisionRevision ?? (p.relayRevision ?? null), + episodeKey: frozenRef?.episodeKey ?? key, + level: p.ownerNotificationLevel ?? "RECORD", + kind: p.ownerNotificationKind ?? "unclassified", + humanIntent: p.humanIntent ?? null, + }, + ]; + } + return []; +} + +/** SeenStore-backed outbound relay transport. + * - Strict SeenStore reads (`statuses()`): unreadable state retains the decision in DispatchBuffer. + * - Persists attempt metadata BEFORE the first HTTP request (no secrets/answers in metadata). + * - Replays the exact frozen `relayEnvelope` with the same `deliveryId`. + * - Marks `outboundSeen` and releases poll guard BEFORE marking `delivered`; reconciles from durable receipt on retry without reposting. */ +export function createRelayOutboundTransport(opts: RelayOutboundTransportOpts): RelayOutboundTransport { + const log = opts.log ?? (() => {}); + + const wasAttempted = (decision: OutboundDecision): boolean => { + const map = opts.attempted.statuses(); + return map.has(decision.decisionId); + }; + + const completeLocalGuards = ( + decision: OutboundDecision, + p: RelayPayloadCarry, + outboundSeenMap: Map, + deliveredMap: Map, + ): SubsystemDeliveryOutcome => { + try { + if (p.qitemId && !p.deliveryDigestPost) { + const key = p.notificationKey ?? p.qitemId; + if (!outboundSeenMap.has(key)) { + opts.outboundSeen.mark(key, "relay-accepted"); + } + opts.release?.(key); + } + if (!deliveredMap.has(decision.decisionId)) { + opts.delivered.mark(decision.decisionId, "delivered"); + } + return { ok: true }; + } catch (error) { + const detail = (error as Error).message || "relay state write failed"; + log(`relay local state mark FAILED for ${decision.decisionId}: ${detail} — retained for replay`); + return { ok: false, class: "relay-state-write-failed", detail }; + } + }; + + const deliver: SubsystemDeliverFn = async (decision: OutboundDecision): Promise => { + let deliveredMap: Map; + let attemptedMap: Map; + let outboundSeenMap: Map; + try { + deliveredMap = opts.delivered.statuses(); + attemptedMap = opts.attempted.statuses(); + outboundSeenMap = opts.outboundSeen.statuses(); + } catch (error) { + const detail = (error as Error).message || "relay SeenStore unreadable"; + log(`relay state unreadable for ${decision.decisionId}: ${detail} — retained`); + return { ok: false, class: "relay-state-unreadable", detail }; + } + + const p = (decision.payload ?? {}) as RelayPayloadCarry; + const connectorRef = p.relayConnectorRef; + const envelope = p.relayEnvelope; + if (!connectorRef || !envelope) { + const detail = "relay decision is missing frozen relayEnvelope or relayConnectorRef"; + log(`relay delivery ${decision.decisionId} refused: ${detail}`); + return { ok: false, class: "relay-envelope-missing", detail }; + } + + const expectedReceipts = buildExpectedReceipts(decision, p, connectorRef, envelope); + const alreadyReceipted = + expectedReceipts.length > 0 && + opts.hasAcceptedReceipt !== undefined && + expectedReceipts.every((r) => opts.hasAcceptedReceipt!({ qitemId: r.qitemId, notificationKey: r.notificationKey })); + + if (deliveredMap.has(decision.decisionId) || alreadyReceipted) { + log(`relay delivery: decision ${decision.decisionId} already accepted — reconciling local guards without repost`); + return completeLocalGuards(decision, p, outboundSeenMap, deliveredMap); + } + + const profile = opts.resolveProfile(connectorRef); + if (!profile.ok) { + log(`relay profile "${connectorRef}" unready for ${decision.decisionId}: ${profile.reason} — retained`); + try { + opts.onTransportFailed?.(p, "relay-profile-unready", profile.reason); + } catch (e) { + log(`relay transport-failed receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${(e as Error).message}`); + } + return { ok: false, class: "relay-profile-unready", detail: profile.reason }; + } + + if (opts.stopSignal?.aborted) { + return { ok: false, class: "stopped", detail: "relay transport stopped" }; + } + + if (!attemptedMap.has(decision.decisionId)) { + try { + opts.attempted.mark(decision.decisionId, `attempted:${connectorRef}:${envelope.outcome}`); + } catch (error) { + const detail = (error as Error).message || "failed to record relay attempt"; + log(`relay attempt record FAILED for ${decision.decisionId}: ${detail} — retained`); + return { ok: false, class: "relay-attempt-unrecorded", detail }; + } + } + + const res = await postRelayEnvelope({ + url: profile.url, + outboundToken: profile.outboundToken, + envelope, + fetchImpl: opts.fetchImpl, + timeoutMs: opts.timeoutMs, + stopSignal: opts.stopSignal, + }); + + if (!res.ok) { + if (res.class !== "stopped") { + try { + opts.onTransportFailed?.(p, res.class, res.detail); + } catch (e) { + log(`relay transport-failed receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${(e as Error).message}`); + return { ok: false, class: "receipt-failed", detail: (e as Error).message }; + } + } + return { ok: false, class: res.class, detail: res.detail }; + } + + try { + for (const receipt of expectedReceipts) { + if (opts.hasAcceptedReceipt?.({ qitemId: receipt.qitemId, notificationKey: receipt.notificationKey })) { + continue; + } + opts.onAccepted(receipt); + } + } catch (error) { + const detail = (error as Error).message || "relay acceptance receipt write failed"; + log(`relay acceptance receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${detail} — retained for replay`); + return { ok: false, class: "receipt-failed", detail }; + } + + const finalized = completeLocalGuards(decision, p, outboundSeenMap, deliveredMap); + if (!finalized.ok) return finalized; + + log(`relay delivered ${decision.decisionId}${p.qitemId ? ` (qitem ${p.qitemId})` : ""} via ${connectorRef}`); + return { ok: true }; + }; + + return { + deliver, + wasAttempted, + }; +} + +export function relayStatePaths(home: string): { attemptedPath: string; deliveredPath: string } { + const dir = path.join(home, "state"); + return { + attemptedPath: path.join(dir, RELAY_ATTEMPTED_DECISIONS_FILE), + deliveredPath: path.join(dir, RELAY_DELIVERED_DECISIONS_FILE), + }; +} diff --git a/packages/daemon/src/domain/gateway/slack/open-asks.ts b/packages/daemon/src/domain/gateway/slack/open-asks.ts index cbd75bade..94c5fd33d 100644 --- a/packages/daemon/src/domain/gateway/slack/open-asks.ts +++ b/packages/daemon/src/domain/gateway/slack/open-asks.ts @@ -19,6 +19,7 @@ import type { QueueRepository } from "../../queue-repository.js"; import type { DispatchResult } from "../dispatcher.js"; import { DispatchBuffer } from "../dispatch-buffer.js"; import type { LoadResult } from "../human-registry.js"; +import { sharedChannelAddresses } from "../participants.js"; import { makeQueuePorts } from "./queue-access.js"; import { SeenStore } from "./state-store.js"; import { OUTBOUND_OP, toPayload, type OutboundPostPayload } from "./outbound-driver.js"; @@ -120,7 +121,20 @@ export interface PostOpenAsksInput { /** Show the open asks again: reconcile each, offer the ones enable seeded, then report every ask's outcome. */ export async function postOpenAsks(input: PostOpenAsksInput): Promise { const { queueRepo } = input; - const ports = makeQueuePorts(queueRepo, { loadHumanRegistry: input.loadHumanRegistry }); + const loadSlackHumanRegistry = (): LoadResult => { + const loaded = input.loadHumanRegistry(); + if (!loaded.ok) return loaded; + return { + ...loaded, + entities: loaded.entities.filter((entity) => + entity.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"), + ), + }; + }; + const ports = makeQueuePorts(queueRepo, { + loadHumanRegistry: loadSlackHumanRegistry, + sharedChannels: () => sharedChannelAddresses(input.home), + }); const episodes = await ports.listOpenAsks({ minimumLevel: input.minimumLevelThatPosts }); const seen = new SeenStore(input.seenPath ?? path.join(input.home, "state", "slack-outbound-seen.jsonl")); const attempted = new SeenStore(path.join(input.home, "state", "slack-attempted-decisions.jsonl")); diff --git a/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts b/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts index bb3777dc7..ec2acb894 100644 --- a/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts +++ b/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts @@ -46,7 +46,10 @@ import { humanAnswerText } from "../../human-questions.js"; import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js"; import { parseSessionName } from "../../session-name.js"; import type { FetchImpl } from "./slack-api.js"; -import { ownerNotificationLevelAtLeast } from "../../queue-transition-log.js"; +import { + ownerNotificationLevelAtLeast, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, +} from "../../queue-transition-log.js"; // OPR.0.5.6.1 — the delivery rules engine: ONE decision per message replaces // the two dial-reads (the S14 default-decision stub). The legacy dial path // survives ONLY as the stated degrade when the destination is not a @@ -64,6 +67,14 @@ import { confirmReleaseAtPost, currentAskVerdict, holdForQuiet } from "../../pol import { noteRePresentNotPosted, RE_PRESENT_DECISION_PREFIX } from "./open-asks.js"; import { quietHoldFor } from "../human-quiet-windows.js"; import { WatchdogJobsRepository } from "../../watchdog-jobs-repository.js"; +import { loadRelayProfiles } from "../relay-config.js"; +import { + createRelayOutboundTransport, + prepareRelayDecision, + relayStatePaths, + resolvePrimaryRelayBinding, + type RelayPayloadCarry, +} from "../relay-outbound.js"; const SECRET_BOT = "SLACK_BOT_TOKEN"; const SECRET_APP = "SLACK_APP_TOKEN"; @@ -306,7 +317,34 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { const deadForwards = new DeadLetterStore<{ host: string; envelope: SocketEnvelope }>( path.join(stateDir(opts.home), "slack-inbound-forward-deadletter.jsonl")); - if (!outboundReady && !inboundReady) { + const registrySurface: RegistrySurface = opts.registry ?? { loadHumanRegistry, resolveSlackHandle }; + const hasRelayPrimaryBinding = (): boolean => { + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return ( + reg.ok && + reg.entities.some((e) => + e.connectorBindings.some((b) => b.role === "primary" && (b.kind as string) === "relay"), + ) + ); + } catch { + return false; + } + }; + const hasPendingRelayDecisions = (): boolean => { + try { + return new DispatchBuffer(opts.home).pending().some((d) => { + const p = (d.payload ?? {}) as RelayPayloadCarry; + return Boolean(p.relayConnectorRef || p.relayEnvelope); + }); + } catch { + return false; + } + }; + const relayConfigured = hasRelayPrimaryBinding() || hasPendingRelayDecisions(); + const anyOutboundEnabled = outboundReady || relayConfigured; + + if (!outboundReady && !inboundReady && !relayConfigured) { const missing = !cfg.enabled ? "connector disabled (rig slack enable)" : !bot ? "SLACK_BOT_TOKEN unresolved" : "channel unconfigured"; log(`slack delivery not configured (${missing}) — wire is inert; dispatches would be refused honestly`); const inert = buildInProcessWire({ @@ -318,7 +356,6 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { return { ...inert, status: () => ({ platform: "slack", configurationDigest: channelStateDigest(cfg), outboundReady: false, inboundReady: false, inboundRouting, recovery: { state: "unavailable", reason: "inbound-not-configured" }, inbound: { state: "not-configured" }, ...deadLetterBacklogFields(dead, deadActions, deadForwards) }) }; } - const registrySurface: RegistrySurface = opts.registry ?? { loadHumanRegistry, resolveSlackHandle }; const ports = makeQueuePorts(opts.queueRepo, { loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home), sharedChannels: () => sharedChannelAddresses(opts.home), @@ -401,19 +438,53 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { }).catch((e) => log(`undeliverable notice FAILED for ${p.qitemId}: ${(e as Error).message}`)); }; - const recordTransportFailed = (p: OutboundPostPayload, failureClass: string, detail: string, partlyPosted?: boolean): void => { + const resolveRelayConnectorRef = (p: OutboundPostPayload): string | null => { + const carry = p as RelayPayloadCarry; + if (typeof carry.relayConnectorRef === "string" && carry.relayConnectorRef.trim().length > 0) { + return carry.relayConnectorRef.trim(); + } + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return resolvePrimaryRelayBinding(p.destinationSession, reg)?.connectorRef ?? null; + } catch { + return null; + } + }; + + const recordTransportFailed = ( + p: OutboundPostPayload, + failureClass: string, + detail: string, + partlyPosted?: boolean, + relay = false, + ): void => { if (!p.qitemId) return; - if (failureClass === "human-message-unrenderable" || failureClass === "slack-invalid-blocks") { + const carry = p as RelayPayloadCarry; + const isRelay = relay || Boolean(carry.relayConnectorRef || carry.relayEnvelope); + if (!isRelay && (failureClass === "human-message-unrenderable" || failureClass === "slack-invalid-blocks")) { notifySourceUndeliverable(p, detail, partlyPosted, failureClass === "slack-invalid-blocks"); } const key = p.notificationKey ?? p.qitemId; + if (isRelay) { + opts.queueRepo.recordRelayOwnerNotificationTransportFailed({ + qitemId: p.qitemId, + notificationKey: key, + connectorRef: resolveRelayConnectorRef(p), + failureClass, + detail, + }); + return; + } const previousFailure = opts.queueRepo.transitionLog.listForQitem(p.qitemId).filter((transition) => - transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") + (transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") + || transition.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) && transition.transitionNote.split(/\s+/).includes(`notification_key=${key}`)).at(-1); // A corrected attempt can fail transiently after a permanent rejection, and a // transient failure can be followed by a definite rejection. Preserve both changes. - const alreadyRecorded = previousFailure && - previousFailure.transitionNote!.includes("class=slack-invalid-blocks ") === (failureClass === "slack-invalid-blocks"); + const alreadyRecorded = Boolean( + previousFailure?.transitionNote?.startsWith("slack-owner-notification-transport-failed ") && + previousFailure.transitionNote.includes("class=slack-invalid-blocks ") === (failureClass === "slack-invalid-blocks"), + ); if (alreadyRecorded) return; opts.queueRepo.update({ qitemId: p.qitemId, @@ -694,6 +765,98 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { }) : async () => ({ ok: false as const, class: "slack-outbound-not-configured", detail: "bot token or channel missing" }); + const { attemptedPath: relayAttemptedPath, deliveredPath: relayDeliveredPath } = relayStatePaths(opts.home); + const relayAttempted = new SeenStore(relayAttemptedPath); + const relayDelivered = new SeenStore(relayDeliveredPath); + const relayTransport = createRelayOutboundTransport({ + attempted: relayAttempted, + delivered: relayDelivered, + outboundSeen, + fetchImpl: opts.fetchImpl, + stopSignal: runCtl.signal, + log, + resolveProfile: (connectorRef) => { + const reg = registrySurface.loadHumanRegistry(opts.home); + const loaded = loadRelayProfiles(opts.home, reg.ok ? { entities: reg.entities } : undefined); + if (!loaded.ok && loaded.registryError) { + return { ok: false, reason: loaded.registryError }; + } + const profile = loaded.get(connectorRef); + if (!profile) { + return { ok: false, reason: `relay profile "${connectorRef}" is not registered` }; + } + if (!profile.ready || !profile.credentials) { + return { ok: false, reason: profile.reason }; + } + return { + ok: true, + connectorRef: profile.connectorRef, + url: profile.credentials.url, + outboundToken: profile.credentials.outboundToken, + }; + }, + hasAcceptedReceipt: ({ qitemId, notificationKey }) => + opts.queueRepo.hasRelayOwnerNotificationReceipt(qitemId, notificationKey), + onAccepted: (info) => { + opts.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: info.qitemId, + notificationKey: info.notificationKey, + deliveryId: info.deliveryId, + connectorRef: info.connectorRef, + replyRef: info.replyRef, + replyRefHash: info.refHash, + revision: info.revision, + episodeKey: info.episodeKey, + level: info.level, + kind: info.kind, + humanIntent: info.humanIntent, + closeUpdate: !info.digestId, + ...(info.digestId ? { digest: info.digestId } : {}), + }); + }, + onTransportFailed: (p, failureClass, detail) => recordTransportFailed(p, failureClass, detail, false, true), + release: (q) => releaseRef(q), + }); + + const prepareDecision = (decision: Parameters[0]) => + prepareRelayDecision(decision, { + loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home), + queueRepo: opts.queueRepo, + resolveOutcome: (p) => { + let ruled: DeliveryDecision | null = null; + try { ruled = p.qitemId ? decideForPayload(p) : null; } catch { ruled = null; } + if (ruled) { + if (ruled.outcome === "interrupt" && ruled.deferMinutes === undefined) return "interrupt"; + if (ruled.outcome === "digest") return "digest"; + return "notify"; + } + const interrupt = Boolean( + p.ownerNotificationLevel && ownerNotificationLevelAtLeast(p.ownerNotificationLevel, cfg.minimumLevelThatInterrupts), + ); + return interrupt ? "interrupt" : "notify"; + }, + }); + + const isRelayDecision = (decision: Parameters[0], p: OutboundPostPayload): boolean => { + const carry = p as RelayPayloadCarry; + if (carry.relayConnectorRef || carry.relayEnvelope) return true; + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return resolvePrimaryRelayBinding(p.destinationSession ?? decision.entityBindingRef, reg) !== null; + } catch { + return false; + } + }; + + const targetDeliverFor = (decision: Parameters[0], p: OutboundPostPayload): SubsystemDeliverFn => { + if (!isRelayDecision(decision, p)) return deliver; + return (d) => { + const carry = (d.payload ?? {}) as RelayPayloadCarry; + const readyDecision = carry.relayConnectorRef && carry.relayEnvelope ? d : prepareDecision(d); + return relayTransport.deliver(readyDecision); + }; + }; + // OPR.0.5.6.1 — decision containment ahead of the transport: log-class and // digest-class produce ZERO post attempts (AM-F5 tooth); an away-escalation // deferral arms the watchdog substrate and posts nothing now (AM-F1); an @@ -753,8 +916,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // contents, and receipts bind to exactly the members posted under it. An earlier attempt of the original that may // have landed is resolved by the reconciler first. If the quiet state can't be read, nothing posts (see the catch). const deliverDigestPost = async (decision: Parameters[0], p: OutboundPostPayload) => { - const settled = await resolveEarlierAttempt(decision); - if (settled) return settled; + const relay = isRelayDecision(decision, p); + const targetDeliver = targetDeliverFor(decision, p); + if (!relay) { + const settled = await resolveEarlierAttempt(decision); + if (settled) return settled; + } const recipient = (p.destinationSession ?? "").split("@")[0] ?? ""; let quiet: ReturnType; try { @@ -763,7 +930,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // An attempted digest (it may be in Slack) is retained: acked, the next flush would mint the ask again from the // row as it is now. A never-attempted one is acked: its members keep their digest notes, so the next flush // selects them again. A store check only: no Slack read on a failure path. - if ("attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision)) { + const wasAttempted = relay + ? relayTransport.wasAttempted(decision) + : "attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision); + if (wasAttempted) { log(`digest post ${decision.decisionId} retained: quiet hours couldn't be read: ${(error as Error).message}`); return { ok: false as const, class: "quiet-unreadable", detail: (error as Error).message }; } @@ -788,7 +958,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { holdForQuiet({ queueRepo: opts.queueRepo, jobsRepo, qitemId: m.qitemId, entityId: recipient, hold: { ...quiet!, notificationKey: m.notificationKey } }); return false; }); - if (kept.length === members.length) return deliver(decision); // unchanged: the original, as it is + if (kept.length === members.length) return targetDeliver(decision); // unchanged: the original, as it is if (kept.length === 0 || !aggregate) return { ok: true as const }; // nothing left to post under it const keptIds = new Set(kept.map((m) => m.qitemId)); const body = String(p.body ?? "").split("\n").filter((line) => [...keptIds].some((id) => line.endsWith(`[${id}]`))).join("\n"); @@ -796,12 +966,26 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { const keptId = `${decision.decisionId}:kept:${createHash("sha256").update(kept.map((m) => m.notificationKey).sort().join(",")).digest("hex").slice(0, 16)}`; // Durable first: the new decision is in the buffer before the original is acked (returning ok). A crash between the // two replays the original, which computes the same kept set and id; the dispatcher accepts the pending id once. - const res = selfDispatch(keptId, { ...p, memberReceipts: kept, body, summary }); + const { relayConnectorRef: _rc, relayEnvelope: _re, relayRevision: _rr, ...restPayload } = p as RelayPayloadCarry; + const res = selfDispatch(keptId, { ...restPayload, memberReceipts: kept, body, summary }); return res.ok ? { ok: true as const } : { ok: false as const, class: "kept-dispatch-refused", detail: res.error }; }; const engineDeliver: SubsystemDeliverFn = async (decision) => { const p = ((decision as { payload?: unknown }).payload ?? {}) as OutboundPostPayload & { deliveryDeferralFire?: boolean }; + const relay = isRelayDecision(decision, p); + const targetDeliver = targetDeliverFor(decision, p); + if (relay) { + let alreadyAttempted = false; + try { + alreadyAttempted = relayTransport.wasAttempted(decision); + } catch { + return targetDeliver(decision); + } + if (alreadyAttempted) { + return targetDeliver(decision); + } + } // The decision-resolved notice is written at closure, so a closed row is its normal case. if (replayedAtStart.has(decision.decisionId) && p.qitemId && p.ownerNotificationKind !== "human-decision-resolved" && !(p as { deliveryDigestPost?: boolean }).deliveryDigestPost) { @@ -827,8 +1011,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { releaseRef(p.qitemId); return { ok: true as const }; } - const settled = await resolveEarlierAttempt(decision); - if (settled) return settled; + if (!relay) { + const settled = await resolveEarlierAttempt(decision); + if (settled) return settled; + } const verdict = !p.qitemId ? "post" : await confirmReleaseAtPost({ queueRepo: opts.queueRepo, jobsRepo: new WatchdogJobsRepository(opts.queueRepo.db), @@ -838,7 +1024,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { qitemId: p.qitemId, notificationKey: key, }); - if (verdict === "post") return deliver(decision); + if (verdict === "post") return targetDeliver(decision); // Declined (the row says why) or couldn't be checked (nothing written): either way nothing posts now. Acking is // safe because the window's release job owns redelivery until the ask has a receipt or a disposition; keeping it // buffered would wait for a restart and block the job's own re-dispatch of the same decision id. @@ -859,21 +1045,27 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // leniently. A log that exists but can't be read is unknown, not "never attempted": the decision stays in the buffer, // with no post (delivery's reconcile reads the same log) and no not-posted record, until it reads. try { - attempted.statuses(); + (relay ? relayAttempted : attempted).statuses(); } catch (error) { log(`re-shown ${decision.decisionId} retained: the attempt log couldn't be read (${(error as Error).message})`); return { ok: false as const, class: "re-present-attempt-log-unreadable", detail: (error as Error).message }; } // An earlier attempt of this decision found in Slack goes to delivery, which records it. - const landing = "landing" in deliver ? await (deliver as { landing: (d: typeof decision) => Promise }).landing(decision) : "unattempted"; - if (landing === "found" || landing === "partial") return deliver(decision); + const landing = !relay && "landing" in deliver ? await (deliver as { landing: (d: typeof decision) => Promise }).landing(decision) : "unattempted"; + if (landing === "found" || landing === "partial") return targetDeliver(decision); + const regForVerdict = registrySurface.loadHumanRegistry(opts.home); + const alertsForVerdict = + regForVerdict.ok || regForVerdict.noHumans + ? await ports.listHumanAlerts({ minimumLevel: cfg.minimumLevelThatPosts }) + : undefined; const verdict = await currentAskVerdict({ queueRepo: opts.queueRepo, home: opts.home, - registry: { loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home) }, + registry: { loadHumanRegistry: () => regForVerdict }, entityId: (p.destinationSession ?? "").split("@")[0] ?? "", qitemId: p.qitemId, notificationKey: key, + ...(alertsForVerdict ? { alerts: alertsForVerdict } : {}), }); if (verdict.kind !== "post") { // One whose earlier attempt Slack couldn't rule out may already be posted: it's never recorded as not posted, and @@ -894,12 +1086,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { releaseRef(p.qitemId); return { ok: true as const }; } - return deliver(decision); + return targetDeliver(decision); } // A post confirmed partly in Slack finishes without consulting quiet hours, so it is resolved before the decision // (which reads them). Only a started multipart post is checked here, from the stores first, so an ordinary // retry isn't scanned twice; any other attempted decision is resolved at the gate below. - const started = "unfinished" in deliver && (deliver as { unfinished: (d: typeof decision) => boolean }).unfinished(decision); + const started = !relay && "unfinished" in deliver && (deliver as { unfinished: (d: typeof decision) => boolean }).unfinished(decision); if (started) { const settled = await resolveEarlierAttempt(decision); if (settled) return settled; @@ -911,7 +1103,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // An attempted decision (it may be partly or wholly in Slack) is retained and nothing posts: acking it would drop // the post's missing parts and let the sweep offer the ask again under a new id, posting it from the start. Its // replay decides again once the quiet state reads. A store check only: no Slack read on a failure path. - if ("attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision)) { + const wasAttempted = relay + ? relayTransport.wasAttempted(decision) + : "attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision); + if (wasAttempted) { log(`delivery decision for attempted ${decision.decisionId} couldn't be made; retained: ${(error as Error).message}`); return { ok: false as const, class: "quiet-unreadable", detail: (error as Error).message }; } @@ -927,7 +1122,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { releaseRef(p.notificationKey ?? p.qitemId!); return { ok: true as const }; } - if (!started && ruled && (ruled.outcome === "log" || (ruled.outcome === "hold" && ruled.quietHold) + if (!relay && !started && ruled && (ruled.outcome === "log" || (ruled.outcome === "hold" && ruled.quietHold) || ruled.outcome === "digest" || ruled.deferMinutes !== undefined)) { const settled = await resolveEarlierAttempt(decision); if (settled) return settled; @@ -983,20 +1178,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { }); } catch (e) { log(`deferral arm failed for ${p.qitemId}: ${(e as Error).message} — falling through to immediate notify-class delivery (fail-loud beats silent loss)`); - return deliver(decision); + return targetDeliver(decision); } outboundSeen.mark(episodeKey, "interrupt-deferred"); releaseRef(p.qitemId); return { ok: true as const }; } } - return deliver(decision); + return targetDeliver(decision); }; const wire = buildInProcessWire({ home: opts.home, - ops: outboundReady ? [OUTBOUND_OP] : [], - deliver: outboundReady ? engineDeliver : deliver, + ops: anyOutboundEnabled ? [OUTBOUND_OP] : [], + ...(anyOutboundEnabled ? { prepare: prepareDecision } : {}), + deliver: anyOutboundEnabled ? engineDeliver : deliver, log, }); selfDispatch = (decisionId, payload) => @@ -1026,10 +1222,23 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { ...(relayed ? { relayed: true } : {}), }); - if (outboundReady) { + if (anyOutboundEnabled) { + const driverQueue = outboundReady + ? ports + : { + listHumanAlerts: async (filter: Parameters[0]) => { + const alerts = await ports.listHumanAlerts(filter); + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return alerts.filter((alert) => resolvePrimaryRelayBinding(alert.destinationSession, reg) !== null); + } catch { + return []; + } + }, + }; const driver = new SlackOutboundDriver({ home: opts.home, - queue: ports, + queue: driverQueue, seen: outboundSeen, filter: { minimumLevel: cfg.minimumLevelThatPosts }, dispatch: (op, ref, payload) => wire.dispatcher.dispatch(op, ref, payload), diff --git a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts index 56090f8a2..a7459b7e4 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts @@ -42,7 +42,7 @@ import { createHash } from "node:crypto"; import type Database from "better-sqlite3"; import type { EventBus } from "../event-bus.js"; -import { isTypedGateBlocker, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js"; +import { isTypedGateBlocker, mcOperationTag, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js"; import type { PersistedEvent } from "../types.js"; import { MissionControlActionLog, @@ -178,7 +178,13 @@ export class MissionControlWriteContract { return { actionId: last.actionId, verb: input.verb, qitemId: input.qitemId, closedQitem: item, createdQitemId: null, notifyAttempted: false, notifyResult: null, auditedAt: last.actedAt, reconciled: true, decisionRevision: current! }; } - throw this.revisionChanged(item, current!); + const withoutOwnReply = + operation && input.verb === "answer" + ? this.queueRepo.decisionRevision(item, { excludeOperationReply: operation }) + : current; + if (withoutOwnReply !== input.expectedRevision) { + throw this.revisionChanged(item, current!); + } } const result = await this.actOn(operation ? { ...input, auditNotes: { ...input.auditNotes, operation } } : input); const saved = this.queueRepo.getById(input.qitemId); @@ -596,11 +602,29 @@ export class MissionControlWriteContract { if (existing && (ask.state === "pending" || ask.state === "done")) { const decision = questions.length && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0 ? formatHumanAnswers(questions, ask.humanAnswers ?? {}).join("; ") : humanAnswerText(existing.body); - if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })) { - throw new MissionControlWriteContractError("answer_not_closed", `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, { qitemId: input.qitemId }); - } - return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [], - message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.` }, existing.qitemId); + return this.recordAnswer( + input, + before, + decision, + { + outcome: "answered", + unanswered: [], + message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.`, + }, + existing.qitemId, + ask.state === "pending" + ? { + primaryUser, + allowAlreadyDone: false, + onUnclosed: () => + new MissionControlWriteContractError( + "answer_not_closed", + `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, + { qitemId: input.qitemId }, + ), + } + : undefined, + ); } // One answer per ask: another person's committed return is the answer. Before anything of this person's is recorded, // finish settling the ask with it and say who answered; nothing of theirs is recorded or sent. @@ -650,13 +674,21 @@ export class MissionControlWriteContract { const answeredBy = !forAsked ? `Answered by ${input.actorSession}` : unattributed ? `Answered at the terminal by someone OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${forAsked}'s ask` : `Answered by ${input.actorSession}, ${primaryUser ? "the primary user at the terminal" : "a registered person"}, on ${forAsked}'s ask`; + const operation = + typeof input.auditNotes?.operation === "string" && input.auditNotes.operation.length > 0 + ? input.auditNotes.operation + : null; let returned: QueueItem; try { returned = await this.queueRepo.createHumanAnswer({ sourceSession: input.actorSession, destinationSession: ask.sourceSession, body: `${reply}\n${answeredBy} in OpenRig.`, summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`, - tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))], + tags: [ + "human-answer", + ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t)), + ...(operation ? [mcOperationTag(operation)] : []), + ], inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}), }); } catch (error) { @@ -681,13 +713,23 @@ export class MissionControlWriteContract { const revision = now ? this.queueRepo.decisionRevision(now) : null; if (now && revision !== input.expectedRevision) throw this.revisionChanged(now, revision!); } - if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser }) - && this.queueRepo.getById(input.qitemId)?.state !== "done") { - throw new MissionControlWriteContractError("answer_not_closed", - `The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`, - { qitemId: input.qitemId, replyQitemId: replyId }); - } - return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [] }, replyId); + return this.recordAnswer( + input, + before, + decision, + { outcome: "answered", unanswered: [] }, + replyId, + { + primaryUser, + allowAlreadyDone: true, + onUnclosed: () => + new MissionControlWriteContractError( + "answer_not_closed", + `The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`, + { qitemId: input.qitemId, replyQitemId: replyId }, + ), + }, + ); } /** Another person's committed return already answers the ask: settle the ask with it if it's still pending (the @@ -700,23 +742,74 @@ export class MissionControlWriteContract { { qitemId: input.qitemId, answeredBy: other.sourceSession, replyQitemId: other.qitemId }); } - /** The audit row and event for one answer (its queue writes are the repository's own). */ - private recordAnswer(input: MissionControlActionInput, before: Record, reason: string, - answer: NonNullable, createdQitemId: string | null = null): MissionControlActionResult { + /** Close a pending ask (when requested) and record its audit row and event in one atomic transaction. */ + private recordAnswer( + input: MissionControlActionInput, + before: Record, + reason: string, + answer: NonNullable, + createdQitemId: string | null = null, + closePending?: { + primaryUser: boolean; + allowAlreadyDone: boolean; + onUnclosed: () => MissionControlWriteContractError; + }, + ): MissionControlActionResult { const evaluatedAt = this.now().toISOString(); - const after = this.queueRepo.getById(input.qitemId); + let after: QueueItem | null = null; let actionEntry: ReturnType | null = null; - const persistedEvents: PersistedEvent[] = []; - this.db.transaction(() => { - actionEntry = this.actionLog.record({ actionVerb: "answer", qitemId: input.qitemId, actorSession: input.actorSession, - actedAt: evaluatedAt, beforeState: before, afterState: after ? snapshotQitem(after) : null, reason, - auditNotes: input.auditNotes ?? null, identityProvenance: input.identityProvenance ?? null }); - persistedEvents.push(this.eventBus.persistWithinTransaction({ type: "mission_control.action_executed", - actionId: actionEntry.actionId, actionVerb: "answer", qitemId: input.qitemId, actorSession: input.actorSession })); - })(); - for (const e of persistedEvents) this.eventBus.notifySubscribers(e); - return { actionId: actionEntry!.actionId, verb: "answer", qitemId: input.qitemId, closedQitem: after, createdQitemId, - notifyAttempted: false, notifyResult: null, auditedAt: evaluatedAt, answer }; + try { + this.eventBus.withNotifyEnvelope((register) => { + if (closePending) { + const closed = this.queueRepo.resolveDirectHumanReply({ + qitemId: input.qitemId, + actorSession: input.actorSession, + decision: reason, + primaryUser: closePending.primaryUser, + }); + if (!closed && (!closePending.allowAlreadyDone || this.queueRepo.getById(input.qitemId)?.state !== "done")) { + throw closePending.onUnclosed(); + } + } + after = this.queueRepo.getById(input.qitemId); + actionEntry = this.actionLog.record({ + actionVerb: "answer", + qitemId: input.qitemId, + actorSession: input.actorSession, + actedAt: evaluatedAt, + beforeState: before, + afterState: after ? snapshotQitem(after) : null, + reason, + auditNotes: input.auditNotes ?? null, + identityProvenance: input.identityProvenance ?? null, + }); + register( + this.eventBus.persistWithinTransaction({ + type: "mission_control.action_executed", + actionId: actionEntry.actionId, + actionVerb: "answer", + qitemId: input.qitemId, + actorSession: input.actorSession, + }), + ); + }); + } catch (err) { + if (err instanceof MissionControlActionLogError) { + throw new MissionControlWriteContractError(err.code, err.message, err.details); + } + throw err; + } + return { + actionId: actionEntry!.actionId, + verb: "answer", + qitemId: input.qitemId, + closedQitem: after, + createdQitemId, + notifyAttempted: false, + notifyResult: null, + auditedAt: evaluatedAt, + answer, + }; } /** diff --git a/packages/daemon/src/domain/policies/quiet-release.ts b/packages/daemon/src/domain/policies/quiet-release.ts index 96215b752..cb1f63774 100644 --- a/packages/daemon/src/domain/policies/quiet-release.ts +++ b/packages/daemon/src/domain/policies/quiet-release.ts @@ -256,7 +256,7 @@ export async function runQuietRelease(input: RunQuietReleaseInput): Promise<{ st return { state: "pending", released, dispositions }; } if (open === 0) { - input.jobsRepo.markTerminal(input.jobId, "quiet release complete: every held ask posted or given a disposition"); + input.jobsRepo.markTerminal(input.jobId, "quiet release complete: every held ask posted, accepted by its bridge, or given a disposition"); return { state: "released", released, dispositions }; } return { state: "pending", released, dispositions }; @@ -274,10 +274,13 @@ function heldKeys(queueRepo: QueueRepository, windowId: string): Array<[string, return out; } -/** Posted, or given a disposition: nothing more to release for this key. */ +/** Posted, accepted by a relay bridge (which ends local quiet-release custody without claiming downstream person delivery), + * or given a disposition: nothing more to release for this key. */ function resolved(queueRepo: QueueRepository, qitemId: string, key: string, notes?: string[]): boolean { const all = notes ?? queueRepo.listTransitions(qitemId).map((t) => t.transitionNote ?? ""); - return all.some((n) => hasToken(n, "slack-owner-notification-posted", key) || hasToken(n, DISPOSITION_PREFIX, key)); + return all.some((n) => hasToken(n, "slack-owner-notification-posted", key) + || hasToken(n, "relay-owner-notification-accepted", key) + || hasToken(n, DISPOSITION_PREFIX, key)); } /** Watchdog-engine policy wrapper: the scheduler's cadence drives evaluation; the current quiet state owns the timing. */ diff --git a/packages/daemon/src/domain/queue-repository.ts b/packages/daemon/src/domain/queue-repository.ts index bc937926d..93fc92a10 100644 --- a/packages/daemon/src/domain/queue-repository.ts +++ b/packages/daemon/src/domain/queue-repository.ts @@ -8,7 +8,59 @@ import { loadHumanRegistry, registeredPeople, resolveRegisteredHumanAddress, sla import { sharedChannelAddresses } from "./gateway/participants.js"; import { resolveExternal } from "./gateway/external-admission.js"; import type { AgentActivity, PersistedEvent } from "./types.js"; -import { QueueTransitionLog, type OwnerNotificationLevel, type QueueTransition, type RecentQueueTransitionScope } from "./queue-transition-log.js"; +import { + QueueTransitionLog, + RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, + formatRelayOwnerNotificationAccepted, + formatRelayOwnerNotificationTransportFailed, + parseRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationTransportFailed, + type FormatRelayOwnerNotificationAcceptedInput, + type FormatRelayOwnerNotificationTransportFailedInput, + type OwnerNotificationLevel, + type QueueTransition, + type RecentQueueTransitionScope, + type RelayOwnerNotificationReceipt, + type RelayOwnerNotificationTransportFailure, +} from "./queue-transition-log.js"; + +export { + RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, + formatRelayOwnerNotificationAccepted, + formatRelayOwnerNotificationTransportFailed, + parseRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationTransportFailed, + type FormatRelayOwnerNotificationAcceptedInput, + type FormatRelayOwnerNotificationTransportFailedInput, + type RelayOwnerNotificationReceipt, + type RelayOwnerNotificationTransportFailure, +}; + +export type HumanIntent = "decision" | "update"; + +export const MC_OPERATION_TAG_PREFIX = "mc-operation:" as const; + +export function mcOperationTag(operation: string): string { + return `${MC_OPERATION_TAG_PREFIX}${operation}`; +} + +export interface DecisionRevisionOptions { + excludeOperationReply?: string | null; +} + +export interface RecordRelayOwnerNotificationAcceptedInput extends FormatRelayOwnerNotificationAcceptedInput { + qitemId: string; + actorSession?: string; + closeUpdate?: boolean; + humanIntent?: HumanIntent | null; +} + +export interface RecordRelayOwnerNotificationTransportFailedInput extends FormatRelayOwnerNotificationTransportFailedInput { + qitemId: string; + actorSession?: string; +} import { WAKE_INTENT_PREFIX, type OutboxHandler, type OutboxEntry } from "./outbox-handler.js"; import { physicalSessionName } from "./session-registry.js"; import type { QueueWakeFacts } from "./draft-aware-delivery.js"; @@ -210,10 +262,10 @@ export interface QueueItem { priority: QueuePriority; tier: string | null; /** OPR.0.5.6.14 — the delivery LEDGER verdict for gateway-routed rows - * (posted / transport-failed / never-posted), derived from the row's own + * (posted / accepted / transport-failed / never-posted), derived from the row's own * transitions. Pane-bound rows carry null — the field never lies about a * class it does not govern. Populated on getById and findUndelivered. */ - deliveryOutcome?: "posted" | "transport-failed" | "never-posted" | null; + deliveryOutcome?: "posted" | "accepted" | "transport-failed" | "never-posted" | null; /** The undelivered surface's class for ledger-derived entries (the route * prefers this over the nudge-literal regex when present). */ deliveryFailureClass?: string; @@ -1206,10 +1258,15 @@ export class QueueRepository { // A person's Slack message routed to a person is already in Slack: as it arrived, it's on Steering and the TUI and // isn't echoed back. A later park of it on a person is a new ask, classified (and posted) on that update. if (input.gatewayInbound) return { kind: "human-required", level: "RECORD" }; - // The person's Slack subscription decides: `every-ask` (the default) posts every ask (ALERT); `urgent-only` posts only - // one marked --alert, and a plain ask lands on Steering and the TUI (RECORD: below the posting dial, so no post is owed). + // Slack subscription filters only apply when Slack is the primary connector. When a person's primary connector + // is relay (or by default when no Slack subscription restricts it), plain asks post by default (`every-ask`), + // so ordinary relay asks post (ALERT) independently of any retained Slack-specific subscription. const person = people.find((entity) => entity.address === destinationHuman); - const posts = input.humanAlert === true || slackSubscriptionFor(person).preset === "every-ask"; + const primaryBinding = person?.connectorBindings.find((b) => b.role === "primary"); + const posts = input.humanAlert === true + || (primaryBinding?.kind === "relay" + ? true + : slackSubscriptionFor(person).preset === "every-ask"); return input.humanIntent === "update" ? { kind: "human-update", level: "NOTICE" } : { kind: "human-required", level: posts ? "ALERT" : "RECORD" }; @@ -3908,7 +3965,9 @@ export class QueueRepository { return this.closeDirectAsk(item, input.actorSession, input.decision, input.primaryUser === true); })(); if (!result) return false; - for (const event of result.persistedEvents) this.eventBus.notifySubscribers(event); + for (const event of result.persistedEvents) { + if (!this.eventBus.registerPersistedWithinActiveEnvelope(event)) this.eventBus.notifySubscribers(event); + } return mine; } @@ -4031,10 +4090,24 @@ export class QueueRepository { /** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state * (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park, * and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */ - decisionRevision(item: QueueItem): string { + decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string { const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state; - const replies = this.hasReplyToColumn ? (this.db.prepare("SELECT qitem_id FROM queue_items WHERE reply_to = ? ORDER BY ts_created, rowid") - .all(item.qitemId) as Array<{ qitem_id: string }>).map((row) => row.qitem_id) : []; + const excludeOp = + typeof opts?.excludeOperationReply === "string" && opts.excludeOperationReply.length > 0 + ? mcOperationTag(opts.excludeOperationReply) + : null; + const replies = this.hasReplyToColumn + ? (excludeOp + ? (this.db + .prepare( + "SELECT qitem_id FROM queue_items WHERE reply_to = ? AND NOT (tags IS NOT NULL AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)) ORDER BY ts_created, rowid", + ) + .all(item.qitemId, excludeOp) as Array<{ qitem_id: string }>) + : (this.db + .prepare("SELECT qitem_id FROM queue_items WHERE reply_to = ? ORDER BY ts_created, rowid") + .all(item.qitemId) as Array<{ qitem_id: string }>) + ).map((row) => row.qitem_id) + : []; // The park this is: the transition into blocked from another state (a later blocked-to-blocked note stays this park). const transitions = item.state === "blocked" ? this.transitionLog.listForQitem(item.qitemId) : []; let parkEntry: number | null = null; @@ -4046,6 +4119,92 @@ export class QueueRepository { return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`; } + listRelayOwnerNotificationReceipts(qitemId: string): RelayOwnerNotificationReceipt[] { + return this.transitionLog.listRelayOwnerNotificationReceipts(qitemId); + } + + getRelayOwnerNotificationReceipt(qitemId: string, notificationKey?: string): RelayOwnerNotificationReceipt | null { + return this.transitionLog.getRelayOwnerNotificationReceipt(qitemId, notificationKey); + } + + hasRelayOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean { + return this.transitionLog.hasRelayOwnerNotificationReceipt(qitemId, notificationKey); + } + + findRelayOwnerNotificationReceiptByReplyRef( + replyRef: string, + ): (RelayOwnerNotificationReceipt & { qitemId: string }) | null { + return this.transitionLog.findRelayOwnerNotificationReceiptByReplyRef(replyRef); + } + + recordRelayOwnerNotificationTransportFailed(input: RecordRelayOwnerNotificationTransportFailedInput): QueueItem { + const current = this.getByIdOrThrow(input.qitemId); + const key = typeof input.notificationKey === "string" && input.notificationKey.trim().length > 0 + ? input.notificationKey.trim() + : input.qitemId; + const normalizedConnectorRef = + typeof input.connectorRef === "string" && input.connectorRef.trim().length > 0 + ? input.connectorRef.trim() + : null; + const previousFailure = this.transitionLog + .listForQitem(input.qitemId) + .filter( + (transition) => + (transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") || + transition.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) && + transition.transitionNote.split(/\s+/).includes(`notification_key=${key}`), + ) + .at(-1); + if (previousFailure?.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) { + const parsedPrevious = parseRelayOwnerNotificationTransportFailed(previousFailure.transitionNote); + if (parsedPrevious && parsedPrevious.connectorRef === normalizedConnectorRef) { + return current; + } + } + const note = formatRelayOwnerNotificationTransportFailed({ + notificationKey: key, + connectorRef: normalizedConnectorRef, + failureClass: input.failureClass, + detail: input.detail, + }); + return this.update({ + qitemId: input.qitemId, + actorSession: input.actorSession ?? "daemon@kernel", + transitionNote: note, + }); + } + + recordRelayOwnerNotificationAccepted(input: RecordRelayOwnerNotificationAcceptedInput): QueueItem { + const current = this.getByIdOrThrow(input.qitemId); + const isUpdate = + current.humanIntent === "update" && + current.state !== "blocked" && + !current.blockedOn && + this.isPersonAddress(current.destinationSession); + const shouldCloseUpdate = isUpdate && input.closeUpdate !== false && isBlockerLive(current.state); + if (this.transitionLog.hasRelayOwnerNotificationReceipt(input.qitemId, input.notificationKey)) { + if (shouldCloseUpdate) { + return this.update({ + qitemId: input.qitemId, + actorSession: input.actorSession ?? "daemon@kernel", + state: "done", + closureReason: "no-follow-on", + }); + } + return current; + } + const note = formatRelayOwnerNotificationAccepted({ + ...input, + revision: input.revision ?? this.decisionRevision(current), + }); + return this.update({ + qitemId: input.qitemId, + actorSession: input.actorSession ?? "daemon@kernel", + ...(shouldCloseUpdate ? { state: "done", closureReason: "no-follow-on" } : {}), + transitionNote: note, + }); + } + currentHumanRequiredEpisode(qitemId: string): { key: string; receipt: ReturnType } | null { const episode = this.transitionLog.listForQitem(qitemId).filter((transition) => transition.ownerNotificationKind === "human-required").at(-1); if (!episode) return null; @@ -4160,7 +4319,7 @@ export class QueueRepository { return { ...item, deliveryOutcome: ledger?.outcome ?? null, - ...(ledger && ledger.outcome !== "posted" ? { deliveryFailureDetail: ledger.detail } : {}), + ...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}), ...(item.replyTo ? { replyToFallback: this.replyToFallbackFor(item.qitemId) } : {}), }; } @@ -4313,7 +4472,7 @@ export class QueueRepository { return { ...item, deliveryOutcome: ledger?.outcome ?? null, - ...(ledger && ledger.outcome !== "posted" ? { deliveryFailureDetail: ledger.detail } : {}), + ...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}), }; }); return opts?.compact ? items.map(compactRow) : items; @@ -4350,7 +4509,7 @@ export class QueueRepository { */ /** Delivered informational records remain queryable after closure. Receipt filtering * happens before LIMIT; no prose/tier classifier and no second event store. */ - listDeliveredHumanUpdates(opts: { limit?: number } = {}): Array { + listDeliveredHumanUpdates(opts: { limit?: number } = {}): Array { if (!this.hasHumanIntentColumn) return []; const limit = Number.isFinite(opts.limit) ? Math.max(1, Math.min(101, Math.floor(opts.limit!))) : 20; const receipts = `SELECT qitem_id, ts, transition_note FROM queue_transitions` + @@ -4359,10 +4518,20 @@ export class QueueRepository { SELECT q.*, r.ts AS delivered_at, r.transition_note AS delivery_receipt FROM queue_items q JOIN (${receipts}) r ON r.qitem_id = q.qitem_id WHERE q.human_intent = 'update' AND is_human_seat_session(q.destination_session) = 1 - AND r.transition_note LIKE 'slack-owner-notification-posted %' + AND (r.transition_note LIKE 'slack-owner-notification-posted %' OR r.transition_note LIKE 'relay-owner-notification-accepted %') ORDER BY r.ts DESC, q.qitem_id DESC LIMIT ? `).all(limit) as Array; - return rows.map((row) => ({ ...this.rowToItem(row), deliveredAt: row.delivered_at, deliveryReceipt: row.delivery_receipt })); + return rows.map((row) => { + const deliveryOutcome: "posted" | "accepted" = row.delivery_receipt.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `) + ? "accepted" + : "posted"; + return { + ...this.rowToItem(row), + deliveryOutcome, + deliveredAt: row.delivered_at, + deliveryReceipt: row.delivery_receipt, + }; + }); } /** Current explicit human FYIs; delivery receipts are not required for local visibility. @@ -4578,7 +4747,9 @@ export class QueueRepository { OR EXISTS (SELECT 1 FROM queue_transitions receipt WHERE receipt.qitem_id = queue_items.qitem_id AND (receipt.transition_note LIKE 'slack-owner-notification-posted %' - OR receipt.transition_note LIKE 'slack-owner-notification-transport-failed %')))`, + OR receipt.transition_note LIKE 'relay-owner-notification-accepted %' + OR receipt.transition_note LIKE 'slack-owner-notification-transport-failed %' + OR receipt.transition_note LIKE 'relay-owner-notification-transport-failed %')))`, ]; const params: unknown[] = []; if (opts?.rig) { @@ -4593,7 +4764,7 @@ export class QueueRepository { for (const r of rows) { const item = this.rowToItem(r, !opts?.compact); const ledger = this.deliveryOutcomeFor(item.qitemId); - if (ledger?.outcome === "posted") continue; // the receipt wins, always + if (ledger?.outcome === "posted" || ledger?.outcome === "accepted") continue; // the receipt wins, always if (ledger?.outcome === "transport-failed") { out.push({ ...item, @@ -4761,23 +4932,34 @@ export class QueueRepository { * transitions are same-row but keyed by qitemId:OWNER-transitionId; an old * posted receipt cannot mask a later human park. Legacy pre-OWNER or literal * external rows retain their row-scoped fallback. */ - deliveryOutcomeFor(qitemId: string): { outcome: "posted" | "transport-failed" | "never-posted"; detail: string } | null { + deliveryOutcomeFor(qitemId: string): { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string } | null { const ledger = this.deliveryLedger(qitemId); return ledger ? { outcome: ledger.outcome, detail: ledger.detail } : null; } - /** #514 — true only when this row's CURRENT human-notification episode (OWNER history addressed - * to a registered human, aliases included) has a POSTED receipt. Legacy rows with no OWNER + /** OPR.0.5.6.14 + #514 — true when the row's CURRENT ask episode (create-time pending ask + * to a registered human, aliases included) has a POSTED or ACCEPTED receipt. Legacy rows with no OWNER * history, inactive episodes, never-posted and transport-failed all read false. Same derivation * as deliveryOutcomeFor; it reads row fields, never the waiting view. */ humanNotificationPostedThisEpisode(qitemId: string, listed?: ListedLedger): boolean { - if (!this.hasQueueTransitionsTable) return false; + return this.humanNotificationDeliveryThisEpisode(qitemId, listed) !== null; + } + + private humanNotificationDeliveryThisEpisode(qitemId: string, listed?: ListedLedger): "posted" | "accepted" | null { + if (!this.hasQueueTransitionsTable) return null; const owners = listed?.owners ?? this.transitionLog.ownerNotificationsForQitems([qitemId]).get(qitemId) ?? []; const owner = this.currentAskNotification(qitemId, owners); - if (!owner) return false; + if (!owner) return null; // The ledger's episode exists only when the destination resolves to a registered human (aliases included). const ledger = listed ? listed.ledger : this.deliveryLedger(qitemId, owners); - return ledger !== null && ledger.outcome === "posted" && ledger.episode?.notificationKey === `${qitemId}:${owner.transitionId}`; + if ( + ledger !== null && + (ledger.outcome === "posted" || ledger.outcome === "accepted") && + ledger.episode?.notificationKey === `${qitemId}:${owner.transitionId}` + ) { + return ledger.outcome; + } + return null; } /** The ask's own notification while it still binds its recipient. Only that notification does: create writes it in @@ -4860,7 +5042,9 @@ export class QueueRepository { this.receiptNotesStatement ??= this.db.prepare( `SELECT qitem_id, transition_note FROM queue_transitions WHERE qitem_id IN (SELECT value FROM json_each(?)) AND (transition_note LIKE 'slack-owner-notification-posted %' - OR transition_note LIKE 'slack-owner-notification-transport-failed %') + OR transition_note LIKE 'relay-owner-notification-accepted %' + OR transition_note LIKE 'slack-owner-notification-transport-failed %' + OR transition_note LIKE 'relay-owner-notification-transport-failed %') ORDER BY transition_id DESC`, ); const notes = new Map(); @@ -4879,7 +5063,13 @@ export class QueueRepository { : notes; const posted = currentNotes.find((note) => note.startsWith("slack-owner-notification-posted ")); if (posted) return { outcome: "posted", detail: posted, episode }; - const failed = currentNotes.find((note) => note.startsWith("slack-owner-notification-transport-failed ")); + const accepted = currentNotes.find((note) => note.startsWith("relay-owner-notification-accepted ")); + if (accepted) return { outcome: "accepted", detail: accepted, episode }; + const failed = currentNotes.find( + (note) => + note.startsWith("slack-owner-notification-transport-failed ") || + note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `), + ); if (failed) return { outcome: "transport-failed", detail: failed, episode }; if (episode?.quiet) return null; const startedAt = episode?.startedAt ?? item.tsCreated; @@ -5177,7 +5367,8 @@ export class QueueRepository { if (view && ["pending", "in-progress"].includes(view.state)) { // #514 — the unclaimed sweep skips a pending ask already posted to its human in the current // episode (same predicate as runStuckSweep), so the view must not promise that sweep. - const posted = view.state === "pending" && this.humanNotificationPostedThisEpisode(qitemId, listed); + const episodeDelivery = view.state === "pending" ? this.humanNotificationDeliveryThisEpisode(qitemId, listed) : null; + const posted = episodeDelivery !== null; const onSteering = !posted && view.state === "pending" && this.humanAskWithoutAlert(qitemId, listed); const row = view.state === "pending" && !onSteering ? this.db.prepare(`SELECT state, ${this.hasHumanIntentColumn ? "human_intent" : "NULL AS human_intent"}, destination_session, last_nudge_result FROM queue_items WHERE qitem_id = ?`).get(qitemId) as { state: QueueItem["state"]; human_intent: QueueItem["humanIntent"]; destination_session: string; last_nudge_result: string | null } | undefined @@ -5188,13 +5379,14 @@ export class QueueRepository { && this.isLocalHumanDecisionWithoutGateway({ state: row.state, humanIntent: row.human_intent, destinationSession: row.destination_session, lastNudgeResult: row.last_nudge_result }); const awaitingHuman = posted || onSteering || localOnly; if (awaitingHuman || humanUpdate) { + const deliveryPhrase = episodeDelivery === "accepted" ? "accepted by the bridge" : "posted to the human"; view.nextBackstop = { owner: view.owner, mechanism: localOnly ? "local attention feed (no registered human gateway)" : onSteering ? "none (on Steering for the human, with no Slack alert; awaiting their decision)" : humanUpdate ? posted - ? "none (posted to the human; informational correspondence requires no claim)" + ? `none (${deliveryPhrase}; informational correspondence requires no claim)` : "none (informational correspondence to the human; no claim required)" - : "none (posted to the human; awaiting their decision)", dueAt: null, intervalSeconds: null }; + : `none (${deliveryPhrase}; awaiting their decision)`, dueAt: null, intervalSeconds: null }; } // Quiet hours: a held ask is owned by its window's release job until it posts. const quiet = awaitingHuman ? null : quietCustodyBackstop(view.owner, quietHoldCustody(this.db, qitemId)); @@ -5317,7 +5509,7 @@ export class QueueRepository { /** The row fields a delivery ledger reads, as rowToItem presents them. */ type LedgerItem = Pick; -type DeliveryLedger = { outcome: "posted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null }; +type DeliveryLedger = { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null }; /** A listed row's owner notifications (newest first) and its delivery ledger, read once for the whole listing. */ type ListedLedger = { owners: QueueTransition[]; ledger: DeliveryLedger | null }; diff --git a/packages/daemon/src/domain/queue-transition-log.ts b/packages/daemon/src/domain/queue-transition-log.ts index a1a254511..76f1c2f70 100644 --- a/packages/daemon/src/domain/queue-transition-log.ts +++ b/packages/daemon/src/domain/queue-transition-log.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type Database from "better-sqlite3"; import type { ClosureReason } from "./hot-potato-enforcer.js"; import { archiveWhereClause } from "./rig-repository.js"; @@ -6,6 +7,198 @@ import { isHumanSeatSessionRef, parseSessionName } from "./session-name.js"; export const OWNER_NOTIFICATION_LEVELS = ["RECORD", "NOTICE", "ALERT"] as const; export type OwnerNotificationLevel = (typeof OWNER_NOTIFICATION_LEVELS)[number]; +export const RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX = "relay-owner-notification-accepted" as const; +export const RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX = "relay-owner-notification-transport-failed" as const; + +export interface RelayOwnerNotificationReceipt { + notificationKey: string; + deliveryId: string | null; + connectorRef: string | null; + replyRef: string | null; + replyRefHash: string | null; + revision: string | null; + episodeKey: string | null; + level: OwnerNotificationLevel | null; + kind: string | null; + digest: string | null; +} + +export interface FormatRelayOwnerNotificationAcceptedInput { + notificationKey: string; + deliveryId?: string | null; + connectorRef?: string | null; + replyRef?: string | null; + replyRefHash?: string | null; + revision?: string | null; + episodeKey?: string | null; + level?: OwnerNotificationLevel | string | null; + kind?: string | null; + digest?: string | null; +} + +export interface RelayOwnerNotificationTransportFailure { + notificationKey: string; + connectorRef: string | null; + failureClass: string; + error: string; +} + +export interface FormatRelayOwnerNotificationTransportFailedInput { + notificationKey: string; + connectorRef?: string | null; + failureClass: string; + detail: string; +} + +function encodeReceiptToken(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + return trimmed.replace(/%|[\s=]/g, (ch) => encodeURIComponent(ch)); +} + +function encodeConnectorRefToken(value: string | null | undefined): string | null { + if (typeof value !== "string" || value.length === 0) return null; + return encodeURIComponent(value); +} + +function decodeReceiptToken(value: string | null | undefined): string | null { + if (typeof value !== "string" || value.length === 0) return null; + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +export function formatRelayOwnerNotificationAccepted(input: FormatRelayOwnerNotificationAcceptedInput): string { + const notificationKey = typeof input.notificationKey === "string" ? input.notificationKey.trim() : ""; + if (!notificationKey || /\s/.test(notificationKey)) { + throw new Error("formatRelayOwnerNotificationAccepted requires a non-empty notificationKey"); + } + const deliveryId = encodeReceiptToken(input.deliveryId); + const connectorRef = encodeConnectorRefToken(input.connectorRef ?? null); + const rawReplyRef = typeof input.replyRef === "string" && input.replyRef.trim().length > 0 ? input.replyRef.trim() : null; + const replyRef = encodeReceiptToken(rawReplyRef); + const replyRefHash = + encodeReceiptToken(input.replyRefHash) ?? + (rawReplyRef ? createHash("sha256").update(rawReplyRef).digest("hex") : null); + const revision = encodeReceiptToken(input.revision); + const episodeKey = encodeReceiptToken(input.episodeKey); + const level = encodeReceiptToken(input.level); + const kind = encodeReceiptToken(input.kind); + const digest = encodeReceiptToken(input.digest); + + const tokens = [`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX}`, `notification_key=${notificationKey}`]; + if (deliveryId) tokens.push(`delivery_id=${deliveryId}`); + if (connectorRef) tokens.push(`connector_ref=${connectorRef}`); + if (replyRef) tokens.push(`reply_ref=${replyRef}`); + if (replyRefHash) tokens.push(`reply_ref_hash=${replyRefHash}`); + if (revision) tokens.push(`revision=${revision}`); + if (episodeKey) tokens.push(`episode_key=${episodeKey}`); + if (level) tokens.push(`level=${level}`); + if (kind) tokens.push(`kind=${kind}`); + if (digest) tokens.push(`digest=${digest}`); + return tokens.join(" "); +} + +export function parseRelayOwnerNotificationAccepted( + note: string | null | undefined, +): RelayOwnerNotificationReceipt | null { + if (!note || !note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)) return null; + const fields = note.trim().split(/\s+/); + const rawValue = (...names: string[]): string | null => { + for (const name of names) { + const prefix = `${name}=`; + const found = fields.find((field) => field.startsWith(prefix)); + if (found) { + const sliced = found.slice(prefix.length); + if (sliced.length > 0) return sliced; + } + } + return null; + }; + const decodedValue = (...names: string[]): string | null => decodeReceiptToken(rawValue(...names)); + const notificationKey = rawValue("notification_key"); + if (!notificationKey) return null; + const replyRef = decodedValue("reply_ref"); + const replyRefHash = + decodedValue("reply_ref_hash") ?? + (replyRef ? createHash("sha256").update(replyRef).digest("hex") : null); + const rawLevel = decodedValue("level"); + const level = + rawLevel && OWNER_NOTIFICATION_LEVELS.includes(rawLevel as OwnerNotificationLevel) + ? (rawLevel as OwnerNotificationLevel) + : null; + return { + notificationKey, + deliveryId: decodedValue("delivery_id"), + connectorRef: decodedValue("connector_ref"), + replyRef, + replyRefHash, + revision: decodedValue("revision"), + episodeKey: decodedValue("episode_key"), + level, + kind: decodedValue("kind"), + digest: decodedValue("digest"), + }; +} + +export function formatRelayOwnerNotificationTransportFailed( + input: FormatRelayOwnerNotificationTransportFailedInput, +): string { + const notificationKey = typeof input.notificationKey === "string" ? input.notificationKey.trim() : ""; + if (!notificationKey || /\s/.test(notificationKey)) { + throw new Error("formatRelayOwnerNotificationTransportFailed requires a non-empty notificationKey"); + } + const connectorRef = encodeConnectorRefToken(input.connectorRef ?? null); + const failureClass = encodeReceiptToken(input.failureClass); + if (!failureClass) { + throw new Error("formatRelayOwnerNotificationTransportFailed requires a non-empty failureClass"); + } + const detail = typeof input.detail === "string" ? input.detail.replace(/[\r\n]+/g, " ").trim() : ""; + const tokens = [ + `${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX}`, + `notification_key=${notificationKey}`, + ]; + if (connectorRef) tokens.push(`connector_ref=${connectorRef}`); + tokens.push(`class=${failureClass}`); + tokens.push(`error=${detail}`); + return tokens.join(" "); +} + +export function parseRelayOwnerNotificationTransportFailed( + note: string | null | undefined, +): RelayOwnerNotificationTransportFailure | null { + if (!note || !note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) return null; + const trimmed = note.trim(); + const fields = trimmed.split(/\s+/); + const rawValue = (...names: string[]): string | null => { + for (const name of names) { + const prefix = `${name}=`; + const found = fields.find((field) => field.startsWith(prefix)); + if (found) { + const sliced = found.slice(prefix.length); + if (sliced.length > 0) return sliced; + } + } + return null; + }; + const notificationKey = rawValue("notification_key"); + if (!notificationKey) return null; + const connectorRef = decodeReceiptToken(rawValue("connector_ref")); + const failureClass = decodeReceiptToken(rawValue("class")); + if (!failureClass) return null; + const errorIdx = trimmed.indexOf(" error="); + const error = errorIdx >= 0 ? trimmed.slice(errorIdx + " error=".length) : (rawValue("error") ?? ""); + return { + notificationKey, + connectorRef, + failureClass, + error, + }; +} + export function ownerNotificationLevelAtLeast( level: OwnerNotificationLevel, minimum: OwnerNotificationLevel, @@ -464,7 +657,14 @@ export class QueueTransitionLog { } hasOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean { - return this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null; + return ( + this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null || + this.getRelayOwnerNotificationReceipt(qitemId, notificationKey) !== null + ); + } + + hasRelayOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean { + return this.getRelayOwnerNotificationReceipt(qitemId, notificationKey) !== null; } /** The latest owner-notification episode per ACTIVE queue row, in queue order. One query @@ -476,7 +676,7 @@ export class QueueTransitionLog { * * The eligible set is restricted to active queue items before any ranking happens, so a * large notification archive never adds scan or ranking work to a sweep with no active item. */ - activeOwnerNotificationEpisodes(includePosted: boolean): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> { + activeOwnerNotificationEpisodes(includePosted = false): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> { if (!this.hasOwnerNotificationColumns) return []; // Exact token match with no wildcard risk: pad both ends so a key at the note's // start or end still matches, and instr() compares literally (qitem ids may @@ -485,7 +685,7 @@ export class QueueTransitionLog { `EXISTS ( SELECT 1 FROM ${this.historySource} r WHERE r.qitem_id = ${qitemIdExpr} - AND r.transition_note LIKE 'slack-owner-notification-posted %' + AND (r.transition_note LIKE 'slack-owner-notification-posted %' OR r.transition_note LIKE 'relay-owner-notification-accepted %') AND instr(' ' || r.transition_note || ' ', ' notification_key=' || ${qitemIdExpr} || ':' || ${transitionIdExpr} || ' ') > 0 )`; const postedFilter = includePosted ? "" : `AND NOT ${receipt("latest.qitem_id", "latest.transition_id")}`; @@ -540,6 +740,64 @@ export class QueueTransitionLog { return null; } + listRelayOwnerNotificationReceipts(qitemId: string): RelayOwnerNotificationReceipt[] { + const rows = this.db + .prepare( + `SELECT transition_id, transition_note FROM ${this.historySource} + WHERE qitem_id = ? AND transition_note LIKE 'relay-owner-notification-accepted %' + ORDER BY transition_id DESC`, + ) + .all(qitemId) as Array<{ transition_id: number; transition_note: string }>; + const receipts: RelayOwnerNotificationReceipt[] = []; + for (const row of rows) { + const parsed = parseRelayOwnerNotificationAccepted(row.transition_note); + if (parsed) receipts.push(parsed); + } + return receipts; + } + + getRelayOwnerNotificationReceipt(qitemId: string, notificationKey?: string): RelayOwnerNotificationReceipt | null { + for (const parsed of this.listRelayOwnerNotificationReceipts(qitemId)) { + if (notificationKey === undefined || parsed.notificationKey === notificationKey) { + return parsed; + } + } + return null; + } + + findRelayOwnerNotificationReceiptByReplyRef( + replyRef: string, + ): (RelayOwnerNotificationReceipt & { qitemId: string }) | null { + const trimmed = replyRef.trim(); + if (!trimmed) return null; + const fullHash = createHash("sha256").update(trimmed, "utf8").digest("hex"); + const rows = this.db + .prepare( + `SELECT qitem_id, transition_note FROM ${this.historySource} + WHERE transition_note LIKE 'relay-owner-notification-accepted %' + AND ( + instr(' ' || transition_note || ' ', ' reply_ref_hash=' || ? || ' ') > 0 + OR instr(' ' || transition_note || ' ', ' reply_ref=' || ? || ' ') > 0 + ) + ORDER BY transition_id DESC`, + ) + .all(fullHash, trimmed) as Array<{ qitem_id: string; transition_note: string }>; + for (const row of rows) { + const parsed = parseRelayOwnerNotificationAccepted(row.transition_note); + if (!parsed) continue; + if (parsed.replyRef !== null) { + if (parsed.replyRef === trimmed && parsed.replyRefHash === fullHash) { + return { qitemId: row.qitem_id, ...parsed }; + } + continue; + } + if (parsed.replyRefHash === fullHash) { + return { qitemId: row.qitem_id, ...parsed }; + } + } + return null; + } + private rowToTransition(row: QueueTransitionRow): QueueTransition { return { transitionId: row.transition_id, diff --git a/packages/daemon/src/domain/queue-wake-ladder.ts b/packages/daemon/src/domain/queue-wake-ladder.ts index e084ea7ce..d7ac8cf69 100644 --- a/packages/daemon/src/domain/queue-wake-ladder.ts +++ b/packages/daemon/src/domain/queue-wake-ladder.ts @@ -72,6 +72,10 @@ import { resolveOperatorSeat, resolveSessionNodeId, } from "./queue-stuck-sweep.js"; +import { + RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, +} from "./queue-transition-log.js"; export const WAKE_RETRY_INTERVAL_KEY = "queue.wake_retry_interval_seconds"; export const DEFAULT_WAKE_RETRY_INTERVAL_SECONDS = 300; @@ -308,12 +312,16 @@ function readLadder(db: Database.Database, qitemId: string, after = lastMeaningf view.opOutcomeResolved = false; } // Outcome resolution (AM-F3, R1 B-3, R2 pre-marker discriminator): the S14 - // posted receipt or the termination record closes the episode ONLY when it + // posted receipt, relay-owner-notification-accepted receipt, or the termination record closes the episode ONLY when it // (a) follows the dispatch marker chronologically (this loop resets the // flag at each dispatch, so pre-marker notes never survive) and (b) carries // the dispatched key when the marker is keyed. An unkeyed dispatch // (injected legacy ports) keeps the any-following-note shape. - if (note.startsWith("slack-owner-notification-posted ") || note.startsWith("delivery-termination:")) { + if ( + note.startsWith("slack-owner-notification-posted ") || + note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `) || + note.startsWith("delivery-termination:") + ) { if (view.opEngineKey === null || note.split(/\s+/).includes(`notification_key=${view.opEngineKey}`)) { view.opOutcomeResolved = true; } @@ -357,6 +365,8 @@ function hasPickupEvidence(db: Database.Database, row: Pick h?.notificationKey === key); if (!hold) return null; const windowId = hold.windowId; - if (notes.some((n) => hasToken(n, "slack-owner-notification-posted", key) || hasToken(n, "quiet-release-disposition", key))) return null; + if (notes.some((n) => hasToken(n, "slack-owner-notification-posted", key) + || hasToken(n, "relay-owner-notification-accepted", key) + || hasToken(n, "quiet-release-disposition", key))) return null; const live = db.prepare("SELECT 1 FROM watchdog_jobs WHERE policy = 'quiet-release' AND state = 'active' AND spec_yaml LIKE ? LIMIT 1") .get(`%${releaseJobWindowSpec(windowId)}%`); // Without a live job nothing redrives a release or releases a hold, whichever step it reached: orphaned. @@ -55,11 +58,13 @@ export function quietHoldCustody(db: Database.Database, qitemId: string, now: Da if (hasToken(notes[i]!, "quiet-release-dispatched", key) || hasToken(notes[i]!, "quiet-release-returned", key)) { step = i; break; } } if (step >= 0 && notes[step]!.startsWith("quiet-release-dispatched ")) { - // A post failure for this notification with no receipt (a receipt or disposition ended custody above) is still + // A post or relay transport failure for this notification with no receipt (a Slack post receipt, relay bridge + // acceptance, or disposition ended custody above) is still // unresolved, even one recorded before this dispatch: the gateway records a failure once per notification, so a // failed re-dispatch adds none. That is the ledger's rule too: failed until a receipt. A release still in flight // after an earlier failure therefore reads as failed until its post lands. - const failed = notes.find((n) => hasToken(n, "slack-owner-notification-transport-failed", key)); + const failed = notes.find((n) => hasToken(n, "slack-owner-notification-transport-failed", key) + || hasToken(n, "relay-owner-notification-transport-failed", key)); if (failed) { const failure = failed.split(/\s+/).find((t) => t.startsWith("class="))?.slice("class=".length) ?? "unknown"; return { state: "release-failed", windowId, notificationKey: key, nextBoundary: null, failure }; diff --git a/packages/daemon/src/routes/gateway.ts b/packages/daemon/src/routes/gateway.ts index c9217cb76..7b73f26c0 100644 --- a/packages/daemon/src/routes/gateway.ts +++ b/packages/daemon/src/routes/gateway.ts @@ -10,6 +10,7 @@ import { connectionsProjection } from "../domain/gateway/connections-projection.js"; import type { SettingsStore } from "../domain/user-settings/settings-store.js"; import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; import { HTTPException } from "hono/http-exception"; import path from "node:path"; import type { QueueRepository } from "../domain/queue-repository.js"; @@ -18,11 +19,17 @@ import { SeenStore } from "../domain/gateway/slack/state-store.js"; import { makeQueuePorts, seedBacklogAsHistory } from "../domain/gateway/slack/queue-access.js"; import { postOpenAsks } from "../domain/gateway/slack/open-asks.js"; import { DispatchBuffer } from "../domain/gateway/dispatch-buffer.js"; -import { OPENRIG_HOME } from "../openrig-compat.js"; +import { OPENRIG_HOME, getOpenRigHome } from "../openrig-compat.js"; import { loadHumanRegistry } from "../domain/gateway/human-registry.js"; -import { participantsJsonView } from "../domain/gateway/participants.js"; +import { participantsJsonView, sharedChannelAddresses } from "../domain/gateway/participants.js"; +import { resolvePrimaryRelayBinding, type RelayPayloadCarry } from "../domain/gateway/relay-outbound.js"; import { resolveSecret } from "../domain/gateway/slack/secrets.js"; import { resolveHumanDeliveryReadiness, type HumanDeliveryReadiness } from "../domain/gateway/human-readiness.js"; +import { + RELAY_INBOUND_MAX_BODY_BYTES, + isDirectLoopbackPeer, + type RelayInboundHandler, +} from "../domain/gateway/relay-inbound.js"; import { requireSenderIdentity } from "./require-sender-identity.js"; import { runChannelOperation } from "../domain/gateway/channel-operations.js"; import { buildSlackAppManifest } from "../domain/gateway/slack/manifest.js"; @@ -49,6 +56,8 @@ export function gatewayRoutes(opts: { /** The daemon's auth bearer (OPENRIG_AUTH_BEARER_TOKEN, the one `rig host pair` issues); a relay from a * shared app's hub must carry it. Null: no bearer configured, so the route trusts the bind (loopback or tailnet). */ relayBearerToken?: string | null; + /** Concrete local relay inbound handler (`POST /api/gateway/relay/inbound`). */ + relayInboundHandler?: RelayInboundHandler; /** The clock quiet hours are resolved against (tests inject one). */ now?: () => Date; /** The Slack directory reads' fetch and secret environment (tests inject both). */ @@ -56,6 +65,58 @@ export function gatewayRoutes(opts: { } = {}): Hono { const app = new Hono(); + app.post( + "/relay/inbound", + async (c, next) => { + const remoteAddress = ( + c.env as { incoming?: { socket?: { remoteAddress?: string | null } } } | undefined + )?.incoming?.socket?.remoteAddress; + if (!isDirectLoopbackPeer(remoteAddress)) { + return c.json( + { + error: "loopback_peer_required", + message: "Relay inbound requests must originate from a direct local loopback TCP peer.", + }, + 403, + ); + } + + return next(); + }, + bodyLimit({ + maxSize: RELAY_INBOUND_MAX_BODY_BYTES, + onError: (c) => + c.json( + { + error: "payload_too_large", + message: `relay inbound payload exceeds ${RELAY_INBOUND_MAX_BODY_BYTES} bytes`, + }, + 413, + ), + }), + async (c) => { + const handler = + opts.relayInboundHandler ?? + (c.get("relayInboundHandler" as never) as RelayInboundHandler | undefined); + if (!handler) { + return c.json( + { + error: "relay_inbound_unavailable", + message: "Relay inbound handler is not configured on this daemon.", + }, + 503, + ); + } + + const rawBody = await c.req.text(); + const result = await handler.handle({ + authorizationHeader: c.req.header("Authorization") ?? c.req.header("authorization"), + rawBody, + }); + return c.json(result.body, result.status as never); + }, + ); + // A shared Slack app across machines: the hub relays an event or click for a channel this machine // owns, and it runs through this machine's own inbound router. 503 keeps it in the hub's retry. // Registered before /slack/:operation, which would otherwise answer it with 404. @@ -74,12 +135,12 @@ export function gatewayRoutes(opts: { const subsystem = c.get("gatewaySubsystem" as never) as SubsystemHandle | undefined; let status: Record | null = null; try { status = subsystem?.status() ?? null; } catch { /* unavailable is preserved */ } - return c.json(connectionsProjection(opts.home ?? OPENRIG_HOME, status, + return c.json(connectionsProjection(opts.home ?? getOpenRigHome(), status, c.get("settingsStore" as never) as SettingsStore | undefined)); }); app.get("/participants", (c) => { - const result = participantsJsonView(opts.home ?? OPENRIG_HOME, c.req.query("organization")); + const result = participantsJsonView(opts.home ?? getOpenRigHome(), c.req.query("organization")); return result.ok ? c.json(result) : c.json({ error: "human_registry_unavailable", message: result.error }, 503); }); @@ -131,14 +192,17 @@ export function gatewayRoutes(opts: { const readiness = await opts.readiness(entityId, gatewayState); return readiness ? c.json({ ok: true, readiness }) : c.json({ error: "human_not_found", entityId }, 404); } - const home = opts.home ?? OPENRIG_HOME; + const home = opts.home ?? getOpenRigHome(); const registry = loadHumanRegistry(home); if (!registry.ok) return c.json({ error: "human_registry_unavailable", message: registry.error }, 503); const human = registry.entities.find((candidate) => candidate.entityId === entityId); if (!human) return c.json({ error: "human_not_found", entityId }, 404); const cfg = loadConfig(home); const botToken = resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined }); - const readiness = await resolveHumanDeliveryReadiness({ human, config: cfg, gatewayState, botToken }); + const readiness = await resolveHumanDeliveryReadiness( + { human, config: cfg, gatewayState, botToken, home }, + { entities: registry.entities }, + ); return c.json({ ok: true, readiness }); }); @@ -370,10 +434,28 @@ export function gatewayRoutes(opts: { "Register a human with rig gateway human add, or repair and re-project the existing human registry before enabling delivery.", }, 503) }); const seen = new SeenStore(path.join(home, "state", "slack-outbound-seen.jsonl")); + const slackOnlyRegistry = { + ...registry, + entities: registry.entities.filter((entity) => + entity.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"), + ), + }; + const dispatchBuffer = new DispatchBuffer(home); value = await seedBacklogAsHistory({ - queue: makeQueuePorts(queueRepo!, { loadHumanRegistry: () => registry }), seen, + queue: makeQueuePorts(queueRepo!, { + loadHumanRegistry: () => slackOnlyRegistry, + sharedChannels: () => sharedChannelAddresses(home), + }), + seen, filter: { minimumLevel: cfg.minimumLevelThatPosts }, - buffer: new DispatchBuffer(home), + buffer: { + pending: () => + dispatchBuffer.pending().filter((d) => { + const p = (d.payload ?? {}) as RelayPayloadCarry; + if (p.relayConnectorRef || p.relayEnvelope) return false; + return resolvePrimaryRelayBinding(p.destinationSession ?? d.entityBindingRef, registry) === null; + }), + }, }); } else value.onlineStatus = "slack connector disabled"; saveConfig({ ...cfg, enabled }, home); diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 5d5ed0111..7247d2312 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -248,6 +248,8 @@ export interface AppDeps { * Optional so test harnesses constructing AppDeps directly need not provide one; the health * route reports honestly when absent. */ gatewaySubsystem?: import("./domain/gateway/gateway-subsystem.js").GatewaySubsystem; + /** Concrete local relay inbound handler (`POST /api/gateway/relay/inbound`). */ + relayInboundHandler?: import("./domain/gateway/relay-inbound.js").RelayInboundHandler; periodicSnapshotScheduler?: import("./domain/periodic-snapshot-scheduler.js").PeriodicSnapshotScheduler; workflowRuntime?: WorkflowRuntime; /** @@ -577,6 +579,7 @@ export function createApp(deps: AppDeps): Hono { c.set("cmuxAdapter" as never, deps.cmuxAdapter); // S10 — the in-daemon gateway subsystem handle (health surface + dispatch seam). c.set("gatewaySubsystem" as never, deps.gatewaySubsystem); + c.set("relayInboundHandler" as never, deps.relayInboundHandler); // Slice 24 — per-rig CMUX workspace launcher wiring. c.set( "cmuxLayoutService" as never, @@ -901,7 +904,13 @@ export function createApp(deps: AppDeps): Hono { app.route("/api/health-diagnosis", healthDiagnosisRoutes()); // S10 — gateway subsystem admin (slack enable/disable with the seeding rule preserved). // A shared Slack app's relay carries the token `rig host pair` issued: this daemon's auth bearer. - app.route("/api/gateway", gatewayRoutes({ relayBearerToken: deps.missionControlBearerToken ?? null })); + app.route( + "/api/gateway", + gatewayRoutes({ + relayBearerToken: deps.missionControlBearerToken ?? null, + relayInboundHandler: deps.relayInboundHandler, + }), + ); app.route("/api/rigs/:rigId/env", envRoutes()); app.route("/api/restore-check", restoreCheckRoutes); // Slice 09 (OPR.0.3.2.9) — rig-policy bindings (operator context mode). diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index b64a5a099..fb2f68633 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -211,6 +211,7 @@ import { managedBlockRecords } from "./domain/managed-block-records.js"; import { getCompatibleOpenRigPath, getDefaultOpenRigPath, + getOpenRigHome, readOpenRigEnv, } from "./openrig-compat.js"; @@ -2764,18 +2765,28 @@ export async function createDaemon(opts?: DaemonOptions): Promise // no drivers) — `rig slack status` names what is missing. const { GatewaySubsystem } = await import("./domain/gateway/gateway-subsystem.js"); const { buildSlackGatewayWire, makeHumanReplyResolver } = await import("./domain/gateway/slack/slack-subsystem.js"); + const { createRelayInboundHandler } = await import("./domain/gateway/relay-inbound.js"); + const effectiveOpenRigHome = getOpenRigHome(); const gatewaySubsystem = new GatewaySubsystem({ - home: OPENRIG_HOME, + home: effectiveOpenRigHome, wire: () => buildSlackGatewayWire({ - home: OPENRIG_HOME, + home: effectiveOpenRigHome, queueRepo: queueRepoInstance, - resolveHumanReply: makeHumanReplyResolver(queueRepoInstance, deps.missionControlWriteContract, OPENRIG_HOME), + resolveHumanReply: makeHumanReplyResolver(queueRepoInstance, deps.missionControlWriteContract, effectiveOpenRigHome), log: (m) => console.log(`[gateway] ${m}`), }), log: (m) => console.log(`[gateway] ${m}`), }); gatewaySubsystem.start(); deps.gatewaySubsystem = gatewaySubsystem; + if (deps.missionControlWriteContract) { + deps.relayInboundHandler = createRelayInboundHandler({ + home: effectiveOpenRigHome, + queueRepo: queueRepoInstance, + missionControlWriteContract: deps.missionControlWriteContract, + missionControlActionLog: deps.missionControlActionLog, + }); + } // OPR.0.5.6.1 — bind the delivery policies' late gateway ref. lateGatewayDispatch.fn = (op, ref, payload, opts) => gatewaySubsystem.dispatch(op, ref, payload, opts); diff --git a/packages/daemon/test/archive-identity-provenance.test.ts b/packages/daemon/test/archive-identity-provenance.test.ts index b3a4f5920..dd28216e8 100644 --- a/packages/daemon/test/archive-identity-provenance.test.ts +++ b/packages/daemon/test/archive-identity-provenance.test.ts @@ -1,9 +1,15 @@ +import { createHash } from "node:crypto"; import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; import { createDb } from "../src/db/connection.js"; import { migrate } from "../src/db/migrate.js"; import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; -import { QueueTransitionLog } from "../src/domain/queue-transition-log.js"; +import { + QueueTransitionLog, + formatRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationTransportFailed, +} from "../src/domain/queue-transition-log.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { EventBus } from "../src/domain/event-bus.js"; import { queueRoutes } from "../src/routes/queue.js"; @@ -116,4 +122,226 @@ describe("archived audit identity — real migration, writer and history readers expect(db.prepare("SELECT * FROM queue_transitions").all()).toEqual(before); expect(db.prepare("SELECT * FROM queue_transitions_archive").all()).toEqual([]); }); + + it("records and reads relay owner notification acceptance separately from Slack posted receipts across live and archive", async () => { + const db = database(); + const repo = new QueueRepository(db, new EventBus(db), { + loadHumanRegistry: () => ({ + ok: true as const, + entities: [ + { + entityId: "human-founder", + class: "human" as const, + displayName: "Founder", + address: "human-founder@external", + connectorBindings: [ + { + kind: "slack" as const, + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary" as const, + handle: "UFOUNDER", + }, + ], + prefs: { deliveryClass: "A" as const }, + }, + ], + }), + } as never); + const log = new QueueTransitionLog(db); + const ask = await repo.create({ + sourceSession: "sender@rig-a", + destinationSession: "human-founder@external", + body: "Relay ask", + summary: "Ask?", + nudge: false, + }); + const qitemId = ask.qitemId; + const episode = log.latestOwnerNotificationForQitem(ask.qitemId)!; + const key = `${ask.qitemId}:${episode.transitionId}`; + + expect(log.activeOwnerNotificationEpisodes(false).some((e) => e.qitemId === qitemId)).toBe(true); + + repo.recordRelayOwnerNotificationAccepted({ + qitemId, + notificationKey: key, + deliveryId: "del-1", + connectorRef: "tg-main", + replyRef: "tg:msg:99", + revision: "dr1-abc", + episodeKey: key, + level: "ALERT", + kind: "human-required", + digest: "sha256:111", + }); + + // Verify full 64-hex sha256 derivation when replyRefHash is not explicitly passed + const rawReplyRef = "tg:msg:99"; + const expectedFullSha256 = createHash("sha256").update(rawReplyRef).digest("hex"); + expect(expectedFullSha256).toHaveLength(64); + + expect(log.getOwnerNotificationReceipt(qitemId, key)).toBeNull(); + const expectedReceipt = { + notificationKey: key, + deliveryId: "del-1", + connectorRef: "tg-main", + replyRef: rawReplyRef, + replyRefHash: expectedFullSha256, + revision: "dr1-abc", + episodeKey: key, + level: "ALERT", + kind: "human-required", + digest: "sha256:111", + }; + expect(log.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt); + expect(repo.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt); + expect(log.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(repo.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(log.findRelayOwnerNotificationReceiptByReplyRef("tg:msg:unknown")).toBeNull(); + expect(log.hasRelayOwnerNotificationReceipt(qitemId, key)).toBe(true); + expect(log.hasRelayOwnerNotificationReceipt(qitemId, "other:key")).toBe(false); + expect(log.hasOwnerNotificationReceipt(qitemId, key)).toBe(true); + expect(log.activeOwnerNotificationEpisodes(false).some((e) => e.qitemId === qitemId)).toBe(false); + expect(log.activeOwnerNotificationEpisodes(true).find((e) => e.qitemId === qitemId)).toMatchObject({ + qitemId, + posted: true, + }); + const outcome = repo.deliveryOutcomeFor(qitemId); + expect(outcome).toEqual({ + outcome: "accepted", + detail: expect.stringContaining("relay-owner-notification-accepted"), + }); + expect(outcome.outcome).not.toBe("posted"); + expect(outcome.outcome).not.toBe("never-posted"); + + // Archive and verify readers still work across archive + db.prepare("UPDATE queue_items SET state = 'done', ts_updated = ? WHERE qitem_id = ?").run(OLD, qitemId); + db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ?").run(OLD, qitemId); + expect(archiveAgedTerminalTransitions(db, { nowIso: NOW }).archivedQitems).toBe(1); + expect(log.getOwnerNotificationReceipt(qitemId, key)).toBeNull(); + expect(log.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt); + expect(log.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(repo.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(log.hasRelayOwnerNotificationReceipt(qitemId, key)).toBe(true); + expect(log.hasOwnerNotificationReceipt(qitemId, key)).toBe(true); + + // Lossless connectorRef round-trip across whitespace, underscore, and delimiter punctuation without altering literal notification_key + const distinctConnectorRefs = [ + "bridge one", + "bridge_one", + "bridge=one:two/three?four#five&six%25seven\teight", + ]; + for (const [idx, connectorRef] of distinctConnectorRefs.entries()) { + const distinctKey = `${qitemId}:distinct-${idx}`; + const distinctReplyRef = `replyRef_${idx}`; + const distinctFullHash = createHash("sha256").update(distinctReplyRef).digest("hex"); + const formatted = formatRelayOwnerNotificationAccepted({ + notificationKey: distinctKey, + deliveryId: `del-${idx}`, + connectorRef, + replyRef: distinctReplyRef, + revision: "dr1-abc", + episodeKey: distinctKey, + level: "ALERT", + kind: "human-required", + }); + // SQL activeOwnerNotificationEpisodes relies on literal ' notification_key=: ' + expect(` ${formatted} `).toContain(` notification_key=${distinctKey} `); + const parsed = parseRelayOwnerNotificationAccepted(formatted); + expect(parsed?.connectorRef).toBe(connectorRef); + expect(parsed?.notificationKey).toBe(distinctKey); + expect(parsed?.replyRef).toBe(distinctReplyRef); + expect(parsed?.replyRefHash).toBe(distinctFullHash); + expect(parsed?.replyRefHash).toHaveLength(64); + } + const noteWithSpaces = formatRelayOwnerNotificationAccepted({ + notificationKey: `${qitemId}:space`, + connectorRef: "bridge one", + }); + const noteWithUnderscore = formatRelayOwnerNotificationAccepted({ + notificationKey: `${qitemId}:space`, + connectorRef: "bridge_one", + }); + expect(noteWithSpaces).not.toBe(noteWithUnderscore); + expect(parseRelayOwnerNotificationAccepted(noteWithSpaces)?.connectorRef).toBe("bridge one"); + expect(parseRelayOwnerNotificationAccepted(noteWithUnderscore)?.connectorRef).toBe("bridge_one"); + + // Relay transport-failed note with connector_ref, dedupe, and queue-repository readers + const failedAsk = await repo.create({ + sourceSession: "sender@rig-a", + destinationSession: "human-founder@external", + body: "Relay ask that fails transport", + summary: "Fail ask?", + nudge: false, + }); + const failedEpisode = log.latestOwnerNotificationForQitem(failedAsk.qitemId)!; + const failedKey = `${failedAsk.qitemId}:${failedEpisode.transitionId}`; + repo.recordRelayOwnerNotificationTransportFailed({ + qitemId: failedAsk.qitemId, + notificationKey: failedKey, + connectorRef: "tg-main", + failureClass: "relay-http-503", + detail: "bridge unavailable", + }); + const countAfterFirstFailure = repo.listTransitions(failedAsk.qitemId).length; + // Duplicate failure for the same episode and same connector is deduplicated + repo.recordRelayOwnerNotificationTransportFailed({ + qitemId: failedAsk.qitemId, + notificationKey: failedKey, + connectorRef: "tg-main", + failureClass: "relay-http-503", + detail: "bridge unavailable", + }); + expect(repo.listTransitions(failedAsk.qitemId)).toHaveLength(countAfterFirstFailure); + expect(repo.deliveryOutcomeFor(failedAsk.qitemId)).toEqual({ + outcome: "transport-failed", + detail: `relay-owner-notification-transport-failed notification_key=${failedKey} connector_ref=tg-main class=relay-http-503 error=bridge unavailable`, + }); + expect(repo.getById(failedAsk.qitemId)).toMatchObject({ + deliveryOutcome: "transport-failed", + deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"), + }); + expect(repo.findUndelivered().find((q) => q.qitemId === failedAsk.qitemId)).toMatchObject({ + qitemId: failedAsk.qitemId, + deliveryOutcome: "transport-failed", + deliveryFailureClass: "transport-failed", + deliveryFailureDetail: expect.stringContaining("connector_ref=tg-main"), + }); + + // Failure under a changed relay connectorRef must record a new note naming the new connector rather than suppressing + repo.recordRelayOwnerNotificationTransportFailed({ + qitemId: failedAsk.qitemId, + notificationKey: failedKey, + connectorRef: "wa-backup", + failureClass: "relay-http-502", + detail: "backup bridge bad gateway", + }); + expect(repo.listTransitions(failedAsk.qitemId)).toHaveLength(countAfterFirstFailure + 1); + const parsedFailures = repo + .listTransitions(failedAsk.qitemId) + .map((t) => parseRelayOwnerNotificationTransportFailed(t.transitionNote)) + .filter((f): f is NonNullable => f !== null); + expect(parsedFailures.at(-1)).toMatchObject({ + notificationKey: failedKey, + connectorRef: "wa-backup", + failureClass: "relay-http-502", + error: "backup bridge bad gateway", + }); + expect(repo.deliveryOutcomeFor(failedAsk.qitemId)).toEqual({ + outcome: "transport-failed", + detail: `relay-owner-notification-transport-failed notification_key=${failedKey} connector_ref=wa-backup class=relay-http-502 error=backup bridge bad gateway`, + }); + }); }); diff --git a/packages/daemon/test/complete-human-notifications.test.ts b/packages/daemon/test/complete-human-notifications.test.ts index dd985c95f..eda0b233d 100644 --- a/packages/daemon/test/complete-human-notifications.test.ts +++ b/packages/daemon/test/complete-human-notifications.test.ts @@ -188,11 +188,29 @@ describe("complete human notifications", () => { expect(await (await app.request("/api/queue/list?attention=1")).json()).toEqual([]); }); - it("keeps failed FYIs pending and out of delivered history", async () => { + it("keeps failed FYIs pending and out of delivered history for both Slack and Relay failure notes", async () => { const item = await repo.create({ ...request, humanIntent: "update" }); expect(item.state).toBe("pending"); expect(repo.listDeliveredHumanUpdates()).toEqual([]); - repo.update({ qitemId: item.qitemId, actorSession: "daemon@kernel", transitionNote: "slack-owner-notification-transport-failed notification_key=x class=transport error=synthetic" }); - expect(repo.getById(item.qitemId)?.state).toBe("pending"); expect(repo.listDeliveredHumanUpdates()).toEqual([]); + const slackKey = `${item.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(item.qitemId)!.transitionId}`; + repo.update({ qitemId: item.qitemId, actorSession: "daemon@kernel", transitionNote: `slack-owner-notification-transport-failed notification_key=${slackKey} class=transport error=synthetic` }); + expect(repo.getById(item.qitemId)).toMatchObject({ state: "pending", deliveryOutcome: "transport-failed" }); + expect(repo.listDeliveredHumanUpdates()).toEqual([]); + + const relayItem = await repo.create({ ...request, humanIntent: "update", summary: "Relay FYI" }); + const relayKey = `${relayItem.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(relayItem.qitemId)!.transitionId}`; + repo.update({ + qitemId: relayItem.qitemId, + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-transport-failed notification_key=${relayKey} connector_ref=relay-1 class=relay-http-502 error=bad-gateway`, + }); + expect(repo.getById(relayItem.qitemId)).toMatchObject({ + state: "pending", + deliveryOutcome: "transport-failed", + deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"), + }); + expect(repo.getById(relayItem.qitemId)?.deliveryFailureDetail).toContain("connector_ref=relay-1"); + expect(repo.findUndelivered().map((q) => q.qitemId)).toContain(relayItem.qitemId); + expect(repo.listDeliveredHumanUpdates()).toEqual([]); }); it("keeps digest timing but dispatches each human request/update completely with stable identity", async () => { @@ -210,4 +228,120 @@ describe("complete human notifications", () => { await runDeliveryDigestFlush(input); expect((dispatch.mock.calls.slice(2) as unknown as typeof first).map((call) => call[3].decisionId)).toEqual(ids); }); + + it("closes informational updates on relay acceptance while keeping decision asks and blocked work open", async () => { + const update = await repo.create({ ...request, humanIntent: "update", body: "FYI update via relay" }); + const ask = await repo.create({ + ...request, + humanIntent: "decision", + body: "Decision ask via relay", + humanQuestions: [ + { + id: "q1", + question: "Approve phase 1?", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, + { + id: "q2", + question: "Approve phase 2?", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, + ], + }); + expect( + repo.recordHumanAnswer({ + qitemId: ask.qitemId, + actorSession: "human-founder@external", + questionId: "q1", + optionId: "yes", + }), + ).toMatchObject({ status: "recorded", complete: false }); + + const blockedWork = await repo.create({ + ...request, + destinationSession: "worker@rig", + body: "Agent work parked on human decision", + }); + repo.update({ + qitemId: blockedWork.qitemId, + actorSession: "worker@rig", + state: "blocked", + blockedOn: "human-founder@external", + summary: "Blocked on founder", + evidenceRef: "/private/retained-proof.md", + transitionNote: "parked on founder", + }); + + repo.recordRelayOwnerNotificationAccepted({ + qitemId: update.qitemId, + notificationKey: `${update.qitemId}:1`, + deliveryId: "d-update", + connectorRef: "relay-1", + level: "NOTICE", + kind: "human-update", + }); + const transitionsAfterFirstUpdateReceipt = repo.listTransitions(update.qitemId).length; + + // Idempotent duplicate receipt does not append another transition + repo.recordRelayOwnerNotificationAccepted({ + qitemId: update.qitemId, + notificationKey: `${update.qitemId}:1`, + deliveryId: "d-update", + connectorRef: "relay-1", + level: "NOTICE", + kind: "human-update", + }); + expect(repo.listTransitions(update.qitemId)).toHaveLength(transitionsAfterFirstUpdateReceipt); + + expect(repo.getById(update.qitemId)).toMatchObject({ + state: "done", + closureReason: "no-follow-on", + }); + const delivered = repo.listDeliveredHumanUpdates({ limit: 10 }); + const deliveredUpdate = delivered.find((item) => item.qitemId === update.qitemId); + expect(deliveredUpdate).toBeDefined(); + expect(deliveredUpdate?.deliveryReceipt.startsWith("relay-owner-notification-accepted ")).toBe(true); + expect(deliveredUpdate?.deliveryOutcome).toBe("accepted"); + + // Caller humanIntent override cannot close a decision ask or alter recorded answers + repo.recordRelayOwnerNotificationAccepted({ + qitemId: ask.qitemId, + notificationKey: `${ask.qitemId}:2`, + deliveryId: "d-ask", + connectorRef: "relay-1", + replyRef: "ref-ask", + level: "ALERT", + kind: "human-required", + humanIntent: "update", + }); + + expect(repo.getById(ask.qitemId)).toMatchObject({ + state: "pending", + humanAnswers: { q1: "yes" }, + }); + + // Notice acceptance with caller humanIntent="update" on blocked agent work must NEVER close or unpark it + repo.recordRelayOwnerNotificationAccepted({ + qitemId: blockedWork.qitemId, + notificationKey: `${blockedWork.qitemId}:3`, + deliveryId: "d-blocked-notice", + connectorRef: "relay-1", + level: "NOTICE", + kind: "human-update", + humanIntent: "update", + }); + + expect(repo.getById(blockedWork.qitemId)).toMatchObject({ + state: "blocked", + blockedOn: "human-founder@external", + humanAnswers: null, + }); + expect(repo.hasRelayOwnerNotificationReceipt(blockedWork.qitemId, `${blockedWork.qitemId}:3`)).toBe(true); + }); }); diff --git a/packages/daemon/test/decision-revision.test.ts b/packages/daemon/test/decision-revision.test.ts index 8c6d59fde..75825d6d8 100644 --- a/packages/daemon/test/decision-revision.test.ts +++ b/packages/daemon/test/decision-revision.test.ts @@ -64,15 +64,16 @@ function fixture() { }; const revision = async (id: string) => (await (await app.request(`/api/queue/${id}`)).json() as { decisionRevision?: unknown }).decisionRevision; const act = async (body: Record) => { + const actor = (body.actorSession as string | undefined) ?? PERSON; const response = await app.request("/api/mission-control/action", { method: "POST", - headers: { "Content-Type": "application/json", "X-OpenRig-Session": PERSON }, body: JSON.stringify({ actorSession: PERSON, ...body }) }); + headers: { "Content-Type": "application/json", "X-OpenRig-Session": actor }, body: JSON.stringify({ actorSession: actor, ...body }) }); return { status: response.status, body: await response.json() as Record }; }; const count = (sql: string, id: string) => (db.prepare(sql).get(id) as { n: number }).n; const written = (id: string) => ({ row: repo.getById(id), transitions: repo.listTransitions(id).length, replies: count("SELECT count(*) n FROM queue_items WHERE reply_to = ?", id), actions: count("SELECT count(*) n FROM mission_control_actions WHERE qitem_id = ?", id) }); - return { db, repo, ask, park, permissionAsk, revision, act, written }; + return { db, repo, contract, ask, park, permissionAsk, revision, act, written }; } describe("an opt-in decision revision on the settling verbs (#2446)", () => { @@ -199,6 +200,188 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => { expect(await f.revision(id)).toBe(read); expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } }); }); + + it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => { + const f = fixture(); + const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false }); + const id = row.qitemId; + const read = await f.revision(id); + + const original = f.repo.createHumanAnswer.bind(f.repo); + vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => { + await original(input); + throw new Error("simulated crash after answer row commit before audit"); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id, + actorSession: PERSON, + decision: "Ship it now", + expectedRevision: read as string, + auditNotes: { operationId: "relay-op-1" }, + }), + ).rejects.toThrow("simulated crash after answer row commit before audit"); + + expect(f.written(id)).toMatchObject({ replies: 1, actions: 0, row: { state: "pending" } }); + expect(await f.revision(id)).not.toBe(read); + + const retry = await f.act({ + verb: "answer", + qitemId: id, + decision: "Ship it now", + expectedRevision: read, + auditNotes: { operationId: "relay-op-1" }, + }); + expect(retry.status).toBe(200); + expect(f.written(id)).toMatchObject({ replies: 1, actions: 1, row: { state: "done" } }); + + const replyRow = f.db.prepare("SELECT tags FROM queue_items WHERE reply_to = ?").get(id) as { tags: string }; + const tags = JSON.parse(replyRow.tags) as string[]; + expect(tags.some(t => t.startsWith("mc-operation:"))).toBe(true); + + const third = await f.act({ + verb: "answer", + qitemId: id, + decision: "Ship it now", + expectedRevision: read, + auditNotes: { operationId: "relay-op-1" }, + }); + expect(third).toMatchObject({ status: 200, body: { reconciled: true } }); + expect(f.written(id)).toMatchObject({ replies: 1, actions: 1 }); + }); + + it("recovers from a crash between ask close and audit record with one answer, one close transition, and one audit row", async () => { + const f = fixture(); + const id = await f.ask(); + const read = await f.revision(id); + + // Fault injected at audit record time (after resolveDirectHumanReply inside the atomic close+audit transaction) + const actionLog = (f.contract as unknown as { actionLog: MissionControlActionLog }).actionLog; + const origRecord = actionLog.record.bind(actionLog); + let stateAtAuditCall: string | undefined; + vi.spyOn(actionLog, "record").mockImplementationOnce((entry) => { + stateAtAuditCall = f.repo.getById(id)?.state; + throw new Error("simulated crash after ask close before audit"); + return origRecord(entry); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id, + actorSession: PERSON, + decision: "Ship Option A", + expectedRevision: read as string, + auditNotes: { operationId: "relay-close-audit-op-1" }, + }), + ).rejects.toThrow("simulated crash after ask close before audit"); + + expect(stateAtAuditCall).toBe("done"); + // Atomic rollback of close+audit leaves the ask pending with only the 1 tagged reply row + expect(f.written(id)).toMatchObject({ replies: 1, actions: 0, row: { state: "pending" } }); + + // Conflicting payload or different operationId under the original revision still rejects (409) + expect( + await f.act({ + verb: "answer", + qitemId: id, + decision: "Different words", + expectedRevision: read, + auditNotes: { operationId: "relay-close-audit-op-1" }, + }), + ).toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + + const retry = await f.act({ + verb: "answer", + qitemId: id, + decision: "Ship Option A", + expectedRevision: read, + auditNotes: { operationId: "relay-close-audit-op-1" }, + }); + expect(retry.status).toBe(200); + expect(f.written(id)).toMatchObject({ replies: 1, actions: 1, row: { state: "done" } }); + const resolvedTransitions = f.repo + .listTransitions(id) + .filter((t) => t.ownerNotificationKind === "human-decision-resolved"); + expect(resolvedTransitions).toHaveLength(1); + }); + + it("enforces guarded retry non-bypass invariants across different payloads, stale edits, and cross-responder attempts", async () => { + const f = fixture(); + const row1 = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false }); + const id1 = row1.qitemId; + const read1 = await f.revision(id1); + + const original = f.repo.createHumanAnswer.bind(f.repo); + vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => { + await original(input); + throw new Error("simulated crash after answer row commit before audit"); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id1, + actorSession: PERSON, + decision: "Yes", + expectedRevision: read1 as string, + auditNotes: { operationId: "relay-op-2" }, + }), + ).rejects.toThrow("simulated crash after answer row commit before audit"); + + expect(await f.act({ verb: "answer", qitemId: id1, decision: "Yes", expectedRevision: read1, auditNotes: { operationId: "relay-op-other" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + expect(await f.act({ verb: "answer", qitemId: id1, decision: "No", expectedRevision: read1, auditNotes: { operationId: "relay-op-2" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + expect(await f.act({ verb: "answer", qitemId: id1, decision: "Yes", expectedRevision: read1 })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + expect(await f.act({ verb: "answer", qitemId: id1, actorSession: OTHER, decision: "Yes", expectedRevision: read1, auditNotes: { operationId: "relay-op-2" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + + // Stale edit during partial commit + const row2 = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Initial ask text", summary: "Ship?", humanIntent: "decision", nudge: false }); + const id2 = row2.qitemId; + const read2 = await f.revision(id2); + + vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => { + await original(input); + throw new Error("simulated crash after answer row commit before audit"); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id2, + actorSession: PERSON, + decision: "Proceed", + expectedRevision: read2 as string, + auditNotes: { operationId: "relay-op-3" }, + }), + ).rejects.toThrow("simulated crash after answer row commit before audit"); + + f.db.prepare("UPDATE queue_items SET body = 'Edited ask text' WHERE qitem_id = ?").run(id2); + + expect(await f.act({ verb: "answer", qitemId: id2, decision: "Proceed", expectedRevision: read2, auditNotes: { operationId: "relay-op-3" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + }); + + it("keeps resolve operation fingerprint retry intact and rejects a new park episode under an old revision", async () => { + const f = fixture(); + const id = await f.park(); + const read = await f.revision(id); + + const first = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-1" } }); + expect(first.status).toBe(200); + + const retry = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-1" } }); + expect(retry).toMatchObject({ status: 200, body: { reconciled: true } }); + + f.repo.update({ qitemId: id, actorSession: "owner@test-rig", state: "blocked", blockedOn: PERSON }); + const staleNewEpisode = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-2" } }); + expect(staleNewEpisode).toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + }); }); describe("the route carries a client's revision to the write that compares it (#2446)", () => { diff --git a/packages/daemon/test/delivery-rules-engine.test.ts b/packages/daemon/test/delivery-rules-engine.test.ts index e823866b9..9259d8143 100644 --- a/packages/daemon/test/delivery-rules-engine.test.ts +++ b/packages/daemon/test/delivery-rules-engine.test.ts @@ -10,7 +10,7 @@ // Every section below fails at base for exactly those reasons. import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import Database from "better-sqlite3"; @@ -1193,4 +1193,141 @@ describe("OPR.0.5.6.1 §8 — the production composition is live (R2 B-1/B-2/B-3 wire.stop(); } }); + + it("recognizes relay owner acceptance on operator escalations as outcome accepted (never posted) without closing underlying work", async () => { + const mod = await operatorEngineModule(); + expect(mod).not.toBeNull(); + + const src = await repo.create({ sourceSession: "sender@r", destinationSession: "relay@r", body: "obligation" }); + const { created } = await repo.handoff({ qitemId: src.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false }); + db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?") + .run(new Date(Date.now() - 10 * 60_000).toISOString(), "failed:tmux session not found", created.qitemId); + + const relayEnvPath = join(home, "relay-op.env"); + writeFileSync( + relayEnvPath, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:43117/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token", + ].join("\n") + "\n", + { mode: 0o600 }, + ); + + const relayRegistry = { + ok: true as const, + entities: [ + { + entityId: "human-founder", + class: "human" as const, + displayName: "Founder", + address: "human-founder@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-op", + secretsRef: `file:${relayEnvPath}`, + role: "primary" as const, + handle: "founder_relay", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + + const make = (mod as { + makeOperatorDeliveryEngine: (deps: unknown) => { + dispatchEscalation: (row: QueueItem, reason: string) => Promise<{ decision: string; resolved: boolean; dispatched?: boolean; notificationKey?: string }>; + hasAcceptedEscalation: (row: QueueItem) => boolean; + }; + }).makeOperatorDeliveryEngine; + + // Cross-entity provisioning conflict must be caught by default readiness via { entities: reg.entities } + const conflictingRegistry = { + ok: true as const, + entities: [ + ...relayRegistry.entities, + { + entityId: "human-second", + class: "human" as const, + displayName: "Second", + address: "human-second@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-op", + secretsRef: `file:${join(home, "other-relay.env")}`, + role: "secondary" as const, + handle: "second_relay", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + mkdirSync(join(home, "gateway"), { recursive: true }); + writeFileSync( + join(home, "gateway", "participants.yaml"), + "version: 1\ndefault: human-founder\norganizations: {}\ngroups: {}\nparticipants: {}\n", + ); + + const conflictingPort = make({ + home, + gatewayState: () => "active", + queueRepo: repo, + dispatch: () => ({ ok: true, decisionId: "d-conflict" }), + registry: { loadHumanRegistry: () => conflictingRegistry }, + }); + const conflictResult = await conflictingPort.dispatchEscalation(created, "wake failed"); + expect(conflictResult).toMatchObject({ + dispatched: false, + resolved: false, + decision: expect.stringMatching(/^unavailable:human-delivery:/), + }); + + // Default readiness with provisioned relay profile (no custom readiness stub, no Slack config/network probe) + const port = make({ + home, + gatewayState: () => "active", + queueRepo: repo, + dispatch: () => ({ ok: true, decisionId: "d-relay" }), + registry: { loadHumanRegistry: () => relayRegistry }, + }); + + const first = await port.dispatchEscalation(created, "wake failed"); + expect(first).toMatchObject({ decision: "interrupt", dispatched: true, resolved: false }); + expect(first.notificationKey).toBeTruthy(); + + // Hypothetical non-shipped alternative formats must NOT resolve the escalation + repo.update({ + qitemId: created.qitemId, + actorSession: "daemon@kernel", + transitionNote: `relay_accepted notification_key=${first.notificationKey!} outcome=relay_accepted`, + }); + expect(port.hasAcceptedEscalation(created)).toBe(false); + + // Real relay-owner-notification-accepted evidence resolves as "accepted" (never "posted") without closing work + repo.recordRelayOwnerNotificationAccepted({ + qitemId: created.qitemId, + notificationKey: first.notificationKey!, + deliveryId: "del-op-1", + connectorRef: "relay-op", + level: "ALERT", + kind: "human-required", + }); + + expect(port.hasAcceptedEscalation(created)).toBe(true); + + const second = await port.dispatchEscalation(created, "wake failed"); + expect(second).toMatchObject({ + decision: "accepted", + resolved: true, + dispatched: true, + notificationKey: first.notificationKey, + }); + expect(second.decision).toBe("accepted"); + expect(second.decision).not.toBe("posted"); + expect(repo.getById(created.qitemId)?.state).toBe("pending"); + }); }); diff --git a/packages/daemon/test/fixtures/recording-relay.ts b/packages/daemon/test/fixtures/recording-relay.ts new file mode 100644 index 000000000..5f617ab9b --- /dev/null +++ b/packages/daemon/test/fixtures/recording-relay.ts @@ -0,0 +1,601 @@ +/** + * Test-only recording local HTTP bridge fixture (`recording-relay.ts`). + * + * NOTE: This is a local test recording bridge for verifying OpenRig's loopback + * HTTP relay contract in integration tests and the private environment validator. + * It is NOT an installed or production messaging platform backend. + * + * Strictly zero `vitest` runtime imports so standalone `tsx` scripts can reuse it. + */ + +import { createHash, timingSafeEqual } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + writeSync, +} from "node:fs"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { dirname } from "node:path"; +import type { + RelayDigestItem, + RelayOutboundEnvelope, +} from "../../src/domain/gateway/relay-outbound.js"; +import type { RelayInboundOrdering } from "../../src/domain/gateway/relay-inbound.js"; + +export type { RelayDigestItem, RelayOutboundEnvelope, RelayInboundOrdering }; + +export interface BridgeCustodyRecord { + deliveryId: string; + payloadSha256: string; + rawBytesSha256: string; + receivedAt: string; + envelope: RelayOutboundEnvelope; +} + +export interface BridgeSanitizedStats { + relayPostCount: number; + acceptedCount: number; + deduplicatedCount: number; + conflictCount: number; + unauthorizedCount: number; + lostAckCount: number; + ignoredProbeCount: number; + custodyCount: number; + custodyDigestSha256: string; +} + +export interface RelayInboundReplyInput { + eventId: string; + sender: { + handle: string; + }; + replyRef: string; + text: string; + ordering?: RelayInboundOrdering; +} + +export interface SendInboundReplyOptions { + /** Override Bearer token (`null` omits the Authorization header). */ + tokenOverride?: string | null; +} + +export interface SendInboundReplyResult { + status: number; + body: Record; +} + +export interface RecordingRelayOptions { + /** Expected Bearer token on `POST /outbound` from OpenRig. Held in memory only. */ + outboundToken: string; + /** Bearer token sent on `POST /api/gateway/relay/inbound` to OpenRig. Held in memory only. */ + inboundToken: string; + /** Path to durable JSONL custody store file. */ + custodyFilePath: string; + /** Optional TCP port (defaults to 0 for OS-assigned loopback port). */ + port?: number; +} + +export interface RecordingRelayHandle { + readonly baseUrl: string; + readonly outboundUrl: string; + readonly port: number; + readonly custodyFilePath: string; + setLoseNextAck(lose: boolean): void; + getCustodyRecords(): BridgeCustodyRecord[]; + getSanitizedStats(): BridgeSanitizedStats; + restart(): Promise; + stop(): Promise; + sendInboundReply( + daemonBaseUrl: string, + payload: RelayInboundReplyInput, + opts?: SendInboundReplyOptions, + ): Promise; +} + +function canonicalJsonStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJsonStringify(item)).join(",")}]`; + } + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJsonStringify(obj[k])}`).join(",")}}`; +} + +export function computeCanonicalSha256(value: unknown): string { + return createHash("sha256") + .update(canonicalJsonStringify(value), "utf8") + .digest("hex"); +} + +function constantTimeBearerCheck( + authHeader: string | undefined, + expectedToken: string, +): boolean { + if (!authHeader || typeof authHeader !== "string") return false; + const match = /^Bearer\s+(\S+)$/.exec(authHeader.trim()); + if (!match?.[1] || !expectedToken) return false; + const providedBuf = Buffer.from(match[1], "utf8"); + const expectedBuf = Buffer.from(expectedToken, "utf8"); + if (providedBuf.length !== expectedBuf.length) { + timingSafeEqual(expectedBuf, expectedBuf); + return false; + } + return timingSafeEqual(providedBuf, expectedBuf); +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +const ALLOWED_ENVELOPE_KEYS = new Set([ + "v", + "deliveryId", + "recipient", + "intent", + "outcome", + "summary", + "body", + "replyRef", + "items", +]); + +const ALLOWED_ITEM_KEYS = new Set([ + "qitemId", + "notificationKey", + "summary", + "body", + "replyRef", +]); + +function validateOutboundEnvelope( + parsed: unknown, +): { ok: true; envelope: RelayOutboundEnvelope } | { ok: false; error: string } { + if (!isPlainObject(parsed)) { + return { ok: false, error: "Envelope must be a JSON object" }; + } + for (const k of Object.keys(parsed)) { + if (!ALLOWED_ENVELOPE_KEYS.has(k)) { + return { ok: false, error: `Unknown envelope field: ${k}` }; + } + } + if (parsed.v !== 1) { + return { ok: false, error: "Expected protocol version v: 1" }; + } + if (typeof parsed.deliveryId !== "string" || !parsed.deliveryId.trim()) { + return { ok: false, error: "Missing or empty deliveryId" }; + } + if ( + !isPlainObject(parsed.recipient) || + typeof parsed.recipient.handle !== "string" || + !parsed.recipient.handle.trim() || + Object.keys(parsed.recipient).some((k) => k !== "handle") + ) { + return { ok: false, error: "Invalid recipient.handle" }; + } + if (parsed.intent !== "decision" && parsed.intent !== "update") { + return { ok: false, error: "Invalid intent (expected decision | update)" }; + } + if ( + parsed.outcome !== "interrupt" && + parsed.outcome !== "notify" && + parsed.outcome !== "digest" + ) { + return { ok: false, error: "Invalid outcome (expected interrupt | notify | digest)" }; + } + if (typeof parsed.summary !== "string" || typeof parsed.body !== "string") { + return { ok: false, error: "Missing summary or body string" }; + } + if ( + parsed.replyRef !== null && + (typeof parsed.replyRef !== "string" || !parsed.replyRef.trim()) + ) { + return { ok: false, error: "Invalid replyRef (expected string | null)" }; + } + + let items: RelayDigestItem[] | undefined; + if (parsed.items !== undefined) { + if (!Array.isArray(parsed.items)) { + return { ok: false, error: "Invalid items array" }; + } + items = []; + for (const item of parsed.items) { + if (!isPlainObject(item)) { + return { ok: false, error: "Digest item must be an object" }; + } + for (const k of Object.keys(item)) { + if (!ALLOWED_ITEM_KEYS.has(k)) { + return { ok: false, error: `Unknown digest item field: ${k}` }; + } + } + if ( + typeof item.qitemId !== "string" || + typeof item.notificationKey !== "string" || + typeof item.summary !== "string" || + (item.body !== undefined && typeof item.body !== "string") || + (item.replyRef !== null && typeof item.replyRef !== "string") + ) { + return { ok: false, error: "Malformed digest item fields" }; + } + items.push({ + qitemId: item.qitemId, + notificationKey: item.notificationKey, + summary: item.summary, + ...(item.body !== undefined ? { body: item.body } : {}), + replyRef: item.replyRef, + }); + } + } + + return { + ok: true, + envelope: { + v: 1, + deliveryId: parsed.deliveryId, + recipient: { handle: parsed.recipient.handle }, + intent: parsed.intent, + outcome: parsed.outcome, + summary: parsed.summary, + body: parsed.body, + replyRef: parsed.replyRef, + ...(items !== undefined ? { items } : {}), + }, + }; +} + +function loadCustodyRecordsStrict(custodyFilePath: string): BridgeCustodyRecord[] { + if (!existsSync(custodyFilePath)) return []; + const raw = readFileSync(custodyFilePath, "utf8"); + if (!raw.trim()) return []; + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + return lines.map((line, idx) => { + const parsed = JSON.parse(line) as unknown; + if ( + !isPlainObject(parsed) || + typeof parsed.deliveryId !== "string" || + typeof parsed.payloadSha256 !== "string" || + typeof parsed.rawBytesSha256 !== "string" || + typeof parsed.receivedAt !== "string" || + !isPlainObject(parsed.envelope) + ) { + throw new Error(`Invalid custody record at line ${idx + 1}`); + } + return { + deliveryId: parsed.deliveryId, + payloadSha256: parsed.payloadSha256, + rawBytesSha256: parsed.rawBytesSha256, + receivedAt: parsed.receivedAt, + envelope: parsed.envelope as unknown as RelayOutboundEnvelope, + }; + }); +} + +function appendCustodyRecordDurable( + custodyFilePath: string, + record: BridgeCustodyRecord, +): void { + mkdirSync(dirname(custodyFilePath), { recursive: true }); + const fd = openSync(custodyFilePath, "a", 0o600); + try { + writeSync(fd, `${JSON.stringify(record)}\n`, undefined, "utf8"); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function readBody( + req: IncomingMessage, +): Promise<{ ok: true; text: string } | { ok: false; status: number; error: string }> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let settled = false; + req.on("data", (chunk: Buffer | string) => { + if (settled) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); + chunks.push(buf); + }); + req.on("end", () => { + if (!settled) { + settled = true; + resolve({ ok: true, text: Buffer.concat(chunks).toString("utf8") }); + } + }); + req.on("error", () => { + if (!settled) { + settled = true; + resolve({ ok: false, status: 400, error: "stream_error" }); + } + }); + }); +} + +function sendJson( + res: ServerResponse, + status: number, + payload: Record, +): void { + const body = JSON.stringify(payload); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": String(Buffer.byteLength(body, "utf8")), + }); + res.end(body); +} + +export async function startRecordingRelay( + opts: RecordingRelayOptions, +): Promise { + // Secrets remain in closure memory only — never written to custody log or stats + const expectedOutboundToken = opts.outboundToken; + const defaultInboundToken = opts.inboundToken; + const custodyFilePath = opts.custodyFilePath; + const host = "127.0.0.1"; + + let loseNextAck = false; + let relayPostCount = 0; + let acceptedCount = 0; + let deduplicatedCount = 0; + let conflictCount = 0; + let unauthorizedCount = 0; + let lostAckCount = 0; + let ignoredProbeCount = 0; + + let server: Server | null = null; + let boundPort = opts.port ?? 0; + + const handleRequest = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + const method = (req.method ?? "GET").toUpperCase(); + const urlPath = (req.url ?? "/").split("?")[0]; + + // Ignore non-relay probes (such as host GET / checks) without counting as relay POSTs + if (method !== "POST" || urlPath !== "/outbound") { + ignoredProbeCount += 1; + sendJson(res, 404, { ok: false, error: "not_found" }); + return; + } + + relayPostCount += 1; + + const authHeader = + typeof req.headers.authorization === "string" + ? req.headers.authorization + : undefined; + if (!constantTimeBearerCheck(authHeader, expectedOutboundToken)) { + unauthorizedCount += 1; + sendJson(res, 401, { ok: false, error: "unauthorized" }); + return; + } + + const bodyResult = await readBody(req); + if (!bodyResult.ok) { + sendJson(res, bodyResult.status, { ok: false, error: bodyResult.error }); + return; + } + + const rawBytesSha256 = createHash("sha256") + .update(bodyResult.text, "utf8") + .digest("hex"); + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(bodyResult.text); + } catch { + sendJson(res, 400, { ok: false, error: "invalid_json" }); + return; + } + + const validation = validateOutboundEnvelope(parsedJson); + if (!validation.ok) { + sendJson(res, 400, { ok: false, error: validation.error }); + return; + } + + const envelope = validation.envelope; + const payloadSha256 = computeCanonicalSha256(envelope); + + let existingRecords: BridgeCustodyRecord[]; + try { + existingRecords = loadCustodyRecordsStrict(custodyFilePath); + } catch { + sendJson(res, 503, { ok: false, error: "custody_store_corrupted" }); + return; + } + + const existing = existingRecords.find( + (r) => r.deliveryId === envelope.deliveryId, + ); + if (existing) { + if (existing.payloadSha256 === payloadSha256) { + deduplicatedCount += 1; + sendJson(res, 200, { + ok: true, + deliveryId: envelope.deliveryId, + deduplicated: true, + }); + return; + } + conflictCount += 1; + sendJson(res, 409, { + ok: false, + error: "delivery_id_payload_conflict", + deliveryId: envelope.deliveryId, + }); + return; + } + + // Durable custody commit + fsync BEFORE returning 2xx (or simulating lost ACK) + const record: BridgeCustodyRecord = { + deliveryId: envelope.deliveryId, + payloadSha256, + rawBytesSha256, + receivedAt: new Date().toISOString(), + envelope, + }; + + try { + appendCustodyRecordDurable(custodyFilePath, record); + } catch { + sendJson(res, 503, { ok: false, error: "custody_write_failed" }); + return; + } + + acceptedCount += 1; + + if (loseNextAck) { + loseNextAck = false; + lostAckCount += 1; + sendJson(res, 502, { + ok: false, + error: "simulated_lost_ack_after_durable_commit", + }); + return; + } + + sendJson(res, 200, { + ok: true, + deliveryId: envelope.deliveryId, + deduplicated: false, + }); + }; + + const listenServer = async (portToBind: number): Promise => { + const srv = createServer((req, res) => { + void handleRequest(req, res); + }); + await new Promise((resolve, reject) => { + srv.once("error", reject); + srv.listen(portToBind, host, () => { + srv.removeListener("error", reject); + resolve(); + }); + }); + boundPort = (srv.address() as AddressInfo).port; + server = srv; + }; + + const closeServer = async (): Promise => { + const srv = server; + server = null; + if (!srv) return; + await new Promise((resolve, reject) => { + srv.close((err) => (err ? reject(err) : resolve())); + }); + }; + + await listenServer(boundPort); + + return { + get baseUrl(): string { + return `http://${host}:${boundPort}`; + }, + get outboundUrl(): string { + return `http://${host}:${boundPort}/outbound`; + }, + get port(): number { + return boundPort; + }, + get custodyFilePath(): string { + return custodyFilePath; + }, + setLoseNextAck(lose: boolean): void { + loseNextAck = lose; + }, + getCustodyRecords(): BridgeCustodyRecord[] { + return loadCustodyRecordsStrict(custodyFilePath); + }, + getSanitizedStats(): BridgeSanitizedStats { + let records: BridgeCustodyRecord[] = []; + try { + records = loadCustodyRecordsStrict(custodyFilePath); + } catch { + records = []; + } + const digestInput = records + .map((r) => `${r.deliveryId}:${r.payloadSha256}`) + .join("\n"); + const custodyDigestSha256 = createHash("sha256") + .update(digestInput, "utf8") + .digest("hex"); + + return { + relayPostCount, + acceptedCount, + deduplicatedCount, + conflictCount, + unauthorizedCount, + lostAckCount, + ignoredProbeCount, + custodyCount: records.length, + custodyDigestSha256, + }; + }, + async restart(): Promise { + const portToRebind = boundPort; + await closeServer(); + await listenServer(portToRebind); + }, + async stop(): Promise { + await closeServer(); + }, + async sendInboundReply( + daemonBaseUrl: string, + payload: RelayInboundReplyInput, + replyOpts?: SendInboundReplyOptions, + ): Promise { + const targetUrl = `${daemonBaseUrl.replace(/\/+$/, "")}/api/gateway/relay/inbound`; + const headers: Record = { + "content-type": "application/json", + }; + const tokenToUse = + replyOpts && "tokenOverride" in replyOpts + ? replyOpts.tokenOverride + : defaultInboundToken; + if (tokenToUse !== null && tokenToUse !== undefined) { + headers.authorization = `Bearer ${tokenToUse}`; + } + + const wirePayload = JSON.stringify({ + eventId: payload.eventId, + sender: { handle: payload.sender.handle }, + replyRef: payload.replyRef, + text: payload.text, + ordering: payload.ordering ?? "after-question", + }); + + const response = await fetch(targetUrl, { + method: "POST", + headers, + body: wirePayload, + }); + + const rawText = await response.text(); + let parsedBody: Record = {}; + try { + const json = JSON.parse(rawText) as unknown; + if (isPlainObject(json)) parsedBody = json; + } catch { + parsedBody = { rawText }; + } + + return { status: response.status, body: parsedBody }; + }, + }; +} diff --git a/packages/daemon/test/gateway-channel-lifecycle.test.ts b/packages/daemon/test/gateway-channel-lifecycle.test.ts index 6608ed935..02d4c6718 100644 --- a/packages/daemon/test/gateway-channel-lifecycle.test.ts +++ b/packages/daemon/test/gateway-channel-lifecycle.test.ts @@ -12,6 +12,9 @@ import { EventBus } from "../src/domain/event-bus.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; import { addHumanFragment, writeProjection } from "../src/domain/gateway/human-registry.js"; +import { setSharedChannel } from "../src/domain/gateway/participants.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { OUTBOUND_OP } from "../src/domain/gateway/slack/outbound-driver.js"; const homes: string[] = []; const databases: ReturnType[] = []; @@ -35,9 +38,9 @@ function fixture(opts: { registerHuman?: boolean } = {}) { const queueRepo = new QueueRepository(db, new EventBus(db)); const prepare = vi.spyOn(db, "prepare"); const selectionCount = () => prepare.mock.calls.filter(([sql]) => /SELECT qitem_id FROM queue_items/.test(sql)).length; - const alert = (id: string) => { + const alert = (id: string, destination = "alex@external") => { db.prepare(`INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,body) - VALUES (?, '2026-01-01', '2026-01-01', 'author@rig', 'alex@external', 'pending', 'fixture backlog')`).run(id); + VALUES (?, '2026-01-01', '2026-01-01', 'author@rig', ?, 'pending', 'fixture backlog')`).run(id, destination); const result = db.prepare(`INSERT INTO queue_transitions(qitem_id,ts,state,actor_session,owner_notification_level,owner_notification_kind) VALUES (?, '2026-01-01', 'pending', 'author@rig', 'ALERT', 'human-required')`).run(id); return `${id}:${result.lastInsertRowid}`; @@ -168,4 +171,85 @@ describe("human channel lifecycle at the daemon door", () => { expect(loadConfig(f.home).enabled).toBe(false); expect(f.restart).not.toHaveBeenCalled(); }); + + it("seeds only Slack-primary humans and Slack shared channels on enable without seeding Relay-primary episodes or claiming Relay buffer entries as Slack posts", async () => { + const f = fixture(); + expect( + addHumanFragment( + { + entityId: "sam", + class: "human", + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-main", + secretsRef: `file:${join(f.home, "relay.env")}`, + role: "primary", + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B" }, + }, + f.home, + ).ok, + ).toBe(true); + expect( + setSharedChannel( + "ops-room", + { + displayName: "Ops Room", + channel: { + connector: "slack", + connectorRef: "main", + secretsRef: "env:private-pointer", + channelId: "COPSROOM", + }, + }, + {}, + f.home, + ).ok, + ).toBe(true); + + const slackKey = f.alert("slack-backlog", "alex@external"); + const channelKey = f.alert("channel-backlog", "ops-room@external"); + const relayKey = f.alert("relay-backlog", "sam@external"); + + const buffer = new DispatchBuffer(f.home); + buffer.enqueue({ + kind: "outbound_decision", + decisionId: "relay-pending-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "relay-backlog", + notificationKey: relayKey, + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + }, + }); + buffer.enqueue({ + kind: "outbound_decision", + decisionId: "slack-pending-1", + op: OUTBOUND_OP, + entityBindingRef: "alex@external", + payload: { + qitemId: "earlier-slack", + notificationKey: "earlier-slack:1", + destinationSession: "alex@external", + }, + }); + + const response = await f.post("enable"); + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean; seeded: number; onlineStatus: string }; + expect(body.seeded).toBe(2); + expect(f.seen().sort()).toEqual([channelKey, slackKey].sort()); + expect(f.seen()).not.toContain(relayKey); + // Scoped buffer read view counts only the 1 Slack decision, never claiming the Relay decision + expect(body.onlineStatus).toContain("1 earlier undelivered post(s) wait in the replay buffer"); + // Both durable buffer entries remain intact on disk + expect(buffer.pending().map((d) => d.decisionId)).toEqual(["relay-pending-1", "slack-pending-1"]); + }); }); diff --git a/packages/daemon/test/gateway-human-handle.test.ts b/packages/daemon/test/gateway-human-handle.test.ts index 632fffc56..5d53c9416 100644 --- a/packages/daemon/test/gateway-human-handle.test.ts +++ b/packages/daemon/test/gateway-human-handle.test.ts @@ -8,6 +8,7 @@ import { addHumanFragment, projectHumans, resolveSlackHandle, + resolveRelayHandle, humansDir, type HumanFragment, } from "../src/domain/gateway/human-registry.js"; @@ -31,6 +32,9 @@ function frag(entityId: string, bindings: Record[]): Record = {}): Record => ({ kind: "slack", connectorRef: "slack-main", secretsRef: "vault://slack/x", role: "primary", ...over, }); +const relay = (over: Record = {}): Record => ({ + kind: "relay", connectorRef: "relay-main", secretsRef: "file:/etc/openrig/relay.env", role: "primary", ...over, +}); describe("A6 v3 handle schema", () => { it("accepts an optional well-formed handle; a handle-less binding is still valid (outbound-only)", () => { @@ -57,6 +61,21 @@ describe("A6 v3 handle schema", () => { expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/duplicate .*handle|unique per kind/i); }); + + it("relay handle within-fragment uniqueness is scoped to connectorRef", () => { + const dupSameRef = validateHumanFragment(frag("mike", [ + relay({ connectorRef: "relay-a", handle: "alice", role: "primary" }), + relay({ connectorRef: "relay-a", handle: "alice", role: "secondary" }), + ])); + expect(dupSameRef.ok).toBe(false); + if (!dupSameRef.ok) expect(dupSameRef.error).toMatch(/duplicate .*handle/i); + + const diffRefs = validateHumanFragment(frag("mike", [ + relay({ connectorRef: "relay-a", handle: "alice", role: "primary" }), + relay({ connectorRef: "relay-b", handle: "alice", role: "secondary" }), + ])); + expect(diffRefs.ok).toBe(true); + }); }); describe("A6 v3 handle uniqueness across humans (pin-1 registry-level)", () => { @@ -88,6 +107,40 @@ describe("A6 v3 handle uniqueness across humans (pin-1 registry-level)", () => { const r = addHumanFragment(frag("mike", [slack({ handle: "U1", connectorRef: "slack-main" })]), home, { replace: true }); expect(r.ok).toBe(true); }); + + it("relay handle uniqueness across humans is scoped by connectorRef (same ref collides, different ref allowed)", () => { + expect(addHumanFragment(frag("mike", [relay({ connectorRef: "relay-a", handle: "op" })]), home).ok).toBe(true); + const collision = addHumanFragment(frag("dana", [relay({ connectorRef: "relay-a", handle: "op" })]), home); + expect(collision.ok).toBe(false); + if (!collision.ok) expect(collision.error).toMatch(/already registered to human "mike"|registration conflict/i); + + const differentRef = addHumanFragment(frag("dana", [relay({ connectorRef: "relay-b", handle: "op" })]), home); + expect(differentRef.ok).toBe(true); + }); + + it("handles relay connectorRef containing colons unambiguously without false collision", () => { + // If colon-concatenation were used ("relay:" + connectorRef + ":" + handle), + // ("net:east", "worker") and ("net", "east:worker") could collide if handles permitted colons. + // Even with handle restricted to HANDLE_PATTERN, distinct connectorRefs like "bridge:1" vs "bridge" + // with handles "2" and "1:2" would be ambiguous if handles had colons. + // With unambiguous JSON tuple [kind, connectorRef, handle], different connectorRefs with colons never falsely collide. + expect(addHumanFragment(frag("mike", [relay({ connectorRef: "region:us-east:primary", handle: "admin" })]), home).ok).toBe(true); + const otherRef = addHumanFragment(frag("dana", [relay({ connectorRef: "region:us-east:secondary", handle: "admin" })]), home); + expect(otherRef.ok).toBe(true); + + const sameColonRefCollision = addHumanFragment(frag("alex", [relay({ connectorRef: "region:us-east:primary", handle: "admin" })]), home); + expect(sameColonRefCollision.ok).toBe(false); + }); + + it("projectHumans refuses two fragments colliding on the same relay connectorRef+handle", () => { + const dir = humansDir(home); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "mike.yaml"), stringifyYaml(frag("mike", [relay({ connectorRef: "relay-a", handle: "op" })]))); + writeFileSync(join(dir, "dana.yaml"), stringifyYaml(frag("dana", [relay({ connectorRef: "relay-a", handle: "op" })]))); + const p = projectHumans(home); + expect(p.ok).toBe(false); + if (!p.ok) expect(p.error).toMatch(/claimed by both|registration conflict/i); + }); }); describe("A6 v3 resolveSlackHandle (pins 2+3)", () => { @@ -116,3 +169,47 @@ describe("A6 v3 resolveSlackHandle (pins 2+3)", () => { expect(r.kind).toBe("unregistered"); }); }); + +describe("resolveRelayHandle (scoped to connectorRef + handle)", () => { + const mike = (validateHumanFragment(frag("mike", [relay({ connectorRef: "relay-a", handle: "founder" })])) as { ok: true; fragment: HumanFragment }).fragment; + const dana = (validateHumanFragment(frag("dana", [relay({ connectorRef: "relay-a", handle: "partner" })])) as { ok: true; fragment: HumanFragment }).fragment; + const otherRelay = (validateHumanFragment(frag("alex", [relay({ connectorRef: "relay-b", handle: "founder" })])) as { ok: true; fragment: HumanFragment }).fragment; + const outboundRelay = (validateHumanFragment(frag("outbound", [relay({ connectorRef: "relay-a" })])) as { ok: true; fragment: HumanFragment }).fragment; + const slackOnly = (validateHumanFragment(frag("slackperson", [slack({ connectorRef: "relay-a", handle: "slack-alias" })])) as { ok: true; fragment: HumanFragment }).fragment; + const all = [mike, dana, otherRelay, outboundRelay, slackOnly]; + + it("resolves a registered relay connectorRef+handle to the exact person on that relay", () => { + expect(resolveRelayHandle("relay-a", "founder", all)).toMatchObject({ + kind: "registered", + entityId: "mike", + address: "mike@external", + connectorRef: "relay-a", + handle: "founder", + }); + expect(resolveRelayHandle("relay-a", "partner", all)).toMatchObject({ + kind: "registered", + entityId: "dana", + address: "dana@external", + connectorRef: "relay-a", + handle: "partner", + }); + expect(resolveRelayHandle("relay-b", "founder", all)).toMatchObject({ + kind: "registered", + entityId: "alex", + address: "alex@external", + connectorRef: "relay-b", + handle: "founder", + }); + }); + + it("rejects cross-profile handle assertions, slack handles, and handle-less relay bindings", () => { + const wrongProfile = resolveRelayHandle("relay-b", "partner", all); + expect(wrongProfile.kind).toBe("unregistered"); + if (wrongProfile.kind === "unregistered") { + expect(wrongProfile.error).toMatch(/not a registered human/i); + expect(wrongProfile.error).toMatch(/NOT landed as a fabricated human seat/i); + } + expect(resolveRelayHandle("relay-a", "slack-alias", all).kind).toBe("unregistered"); + expect(resolveRelayHandle("relay-a", "relay-a", all).kind).toBe("unregistered"); + }); +}); diff --git a/packages/daemon/test/gateway-human-readiness.test.ts b/packages/daemon/test/gateway-human-readiness.test.ts index 060b53c08..2242b3feb 100644 --- a/packages/daemon/test/gateway-human-readiness.test.ts +++ b/packages/daemon/test/gateway-human-readiness.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { Hono } from "hono"; import { resolveHumanDeliveryReadiness } from "../src/domain/gateway/human-readiness.js"; +import { loadRelayProfiles } from "../src/domain/gateway/relay-config.js"; import { gatewayRoutes } from "../src/routes/gateway.js"; -import type { HumanFragment } from "../src/domain/gateway/human-registry.js"; +import { + addHumanFragment, + projectionPath, + type HumanFragment, +} from "../src/domain/gateway/human-registry.js"; import type { SlackConnectorConfig } from "../src/domain/gateway/slack/config.js"; const human: HumanFragment = { @@ -149,3 +157,236 @@ describe("#192 readiness checks every channel the connector posts to", () => { expect(await run({ ok: false, isMember: false, error: "boom" })).toEqual({ asked: ["C1"], state: "indeterminate", reason: "channel membership verification unavailable: boom" }); }); }); + +describe("relay primary-binding readiness", () => { + const unconfiguredSlack: SlackConnectorConfig = { + ...config, + enabled: false, + channel: null, + }; + + it("reports ready for a valid relay profile even when Slack is disabled and unconfigured", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-ready-")); + try { + const envFile = join(dir, "relay.env"); + writeFileSync( + envFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-1", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-1", + ].join("\n"), + { mode: 0o600 }, + ); + const relayHuman: HumanFragment = { + ...human, + connectorBindings: [ + { + kind: "relay", + connectorRef: "bridge-main", + secretsRef: `file:${envFile}`, + role: "primary", + handle: "founder", + }, + ], + }; + const verifyScopes = vi.fn(); + const result = await resolveHumanDeliveryReadiness( + { human: relayHuman, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes, entities: [relayHuman], now: () => new Date("2026-11-06T00:00:00.000Z") }, + ); + expect(verifyScopes).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + connector: { kind: "relay", ref: "bridge-main" }, + nextAction: null, + checkedAt: "2026-11-06T00:00:00.000Z", + }); + expect(JSON.stringify(result)).not.toContain("out-secret-token-1"); + expect(JSON.stringify(result)).not.toContain("in-secret-token-1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps relay profile ready with redacted warning when secret file has group/other bits or uses the same directional token", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-warn-ready-")); + try { + const envFile = join(dir, "relay-warn.env"); + writeFileSync( + envFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=shared-bidirectional-tok-99", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-bidirectional-tok-99", + ].join("\n"), + { mode: 0o644 }, + ); + chmodSync(envFile, 0o644); + const relayHuman: HumanFragment = { + ...human, + connectorBindings: [ + { + kind: "relay", + connectorRef: "bridge-warn", + secretsRef: `file:${envFile}`, + role: "primary", + handle: "founder", + }, + ], + }; + const result = await resolveHumanDeliveryReadiness( + { human: relayHuman, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { entities: [relayHuman], now: () => new Date("2026-11-07T00:00:00.000Z") }, + ); + expect(result).toMatchObject({ + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + connector: { kind: "relay", ref: "bridge-warn" }, + nextAction: null, + }); + expect(result.reason).toMatch(/warning/i); + expect(result.reason).toMatch(/group\/other/i); + expect(result.reason).toMatch(/OPENRIG_RELAY_OUTBOUND_TOKEN/); + expect(JSON.stringify(result)).not.toContain("shared-bidirectional-tok-99"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports not-ready with sanitized reason when relay credentials are invalid or ambiguous", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-unready-")); + try { + const envFile1 = join(dir, "relay1.env"); + const envFile2 = join(dir, "relay2.env"); + writeFileSync( + envFile1, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out1", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-1", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + writeFileSync( + envFile2, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8788/out2", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-2", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + const h1: HumanFragment = { + ...human, + entityId: "founder", + address: "founder@external", + connectorBindings: [{ kind: "relay", connectorRef: "r1", secretsRef: `file:${envFile1}`, role: "primary", handle: "f1" }], + }; + const h2: HumanFragment = { + ...human, + entityId: "partner", + address: "partner@external", + connectorBindings: [{ kind: "relay", connectorRef: "r2", secretsRef: `file:${envFile2}`, role: "primary", handle: "p1" }], + }; + const relayProfiles = loadRelayProfiles(dir, { entities: [h1, h2] }); + const r1Readiness = await resolveHumanDeliveryReadiness({ + human: h1, + config: unconfiguredSlack, + gatewayState: "active", + botToken: null, + relayProfiles, + }); + expect(r1Readiness).toMatchObject({ + state: "not-ready", + configured: false, + enabled: true, + active: false, + ready: false, + connector: { kind: "relay", ref: "r1" }, + }); + expect(r1Readiness.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + expect(r1Readiness.reason).not.toContain("shared-inbound-secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("never falls back to single input.human when home registry is corrupt or human is unregistered, and preserves cross-profile ambiguity from home", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-failclosed-")); + try { + const envFile1 = join(dir, "relay1.env"); + const envFile2 = join(dir, "relay2.env"); + writeFileSync( + envFile1, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out1", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-1", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + writeFileSync( + envFile2, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8788/out2", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-2", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + const h1: HumanFragment = { + ...human, + entityId: "founder", + address: "founder@external", + connectorBindings: [{ kind: "relay", connectorRef: "r1", secretsRef: `file:${envFile1}`, role: "primary", handle: "f1" }], + }; + const h2: HumanFragment = { + ...human, + entityId: "partner", + address: "partner@external", + connectorBindings: [{ kind: "relay", connectorRef: "r2", secretsRef: `file:${envFile2}`, role: "primary", handle: "p1" }], + }; + + // 1. Unregistered human in empty home must not fall back to single-human evaluation + const verifyScopes = vi.fn(); + const unregisteredReady = await resolveHumanDeliveryReadiness( + { human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes }, + ); + expect(unregisteredReady.ready).toBe(false); + expect(unregisteredReady.state).toBe("not-ready"); + expect(verifyScopes).not.toHaveBeenCalled(); + + // 2. Full registry on disk with cross-profile ambiguous inbound token remains not-ready + expect(addHumanFragment(h1 as unknown as Record, dir).ok).toBe(true); + expect(addHumanFragment(h2 as unknown as Record, dir).ok).toBe(true); + const fromHomeAmbiguous = await resolveHumanDeliveryReadiness( + { human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes }, + ); + expect(fromHomeAmbiguous.ready).toBe(false); + expect(fromHomeAmbiguous.state).toBe("not-ready"); + expect(fromHomeAmbiguous.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + + // 3. Corrupt registry projection fails closed (never ready) + writeFileSync(projectionPath(dir), "corrupted: hand-edited\n"); + const corruptReady = await resolveHumanDeliveryReadiness( + { human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes }, + ); + expect(corruptReady.ready).toBe(false); + expect(corruptReady.state).toBe("not-ready"); + expect(verifyScopes).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/daemon/test/gateway-human-registry.test.ts b/packages/daemon/test/gateway-human-registry.test.ts index d02a4b5bf..216e9cc38 100644 --- a/packages/daemon/test/gateway-human-registry.test.ts +++ b/packages/daemon/test/gateway-human-registry.test.ts @@ -6,6 +6,8 @@ import { stringify as stringifyYaml } from "yaml"; import { validateHumanFragment, addHumanFragment, + setHumanField, + parseBindingSpec, projectHumans, writeProjection, loadHumanRegistry, @@ -50,6 +52,55 @@ describe("A3 human-fragment validation (add-time == load-time)", () => { expect(r.ok).toBe(false); }); + it("accepts a relay connector binding and preserves kind=relay", () => { + const r = validateHumanFragment({ + ...fragment(), + connectorBindings: [ + { kind: "relay", connectorRef: "bridge-main", secretsRef: "file:/etc/openrig/relay.env", role: "primary", handle: "mike" }, + ], + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.fragment.connectorBindings[0]).toEqual({ + kind: "relay", + connectorRef: "bridge-main", + secretsRef: "file:/etc/openrig/relay.env", + role: "primary", + handle: "mike", + }); + } + }); + + it("parseBindingSpec supports relay file: syntax and rejects relative file: paths", () => { + const ok = parseBindingSpec("relay:bridge-main:file:/etc/openrig/relay.env:primary:handle=mike"); + expect(ok).toEqual({ + ok: true, + binding: { + kind: "relay", + connectorRef: "bridge-main", + secretsRef: "file:/etc/openrig/relay.env", + role: "primary", + handle: "mike", + }, + }); + expect(parseBindingSpec("relay:bridge-main:file:relative/relay.env:primary:handle=mike").ok).toBe(false); + expect(parseBindingSpec("relay:bridge-main:file::primary:handle=mike").ok).toBe(false); + }); + + it("parseBindingSpec preserves Slack relative-file backward-compatibility", () => { + const parsed = parseBindingSpec("slack:slack-main:file:relative/secrets.env:primary:handle=mike"); + expect(parsed).toEqual({ + ok: true, + binding: { + kind: "slack", + connectorRef: "slack-main", + secretsRef: "file:relative/secrets.env", + role: "primary", + handle: "mike", + }, + }); + }); + it("accepts a person with no connector binding, as an empty list or left out, and rejects a value that isn't a list", () => { expect(validateHumanFragment({ ...fragment(), connectorBindings: [] })).toMatchObject({ ok: true, fragment: { connectorBindings: [] } }); const { connectorBindings: _none, ...withoutBindings } = fragment(); @@ -238,6 +289,35 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi const loaded = loadHumanRegistry(home); expect(loaded.ok && loaded.entities[0]!.displayName).toBe("Founder Renamed"); }); + + it("setHumanField updates binding. to a relay file binding and enforces connectorRef+handle uniqueness", () => { + expect(addHumanFragment(fragment({ entityId: "mike", address: "mike@external" }), home).ok).toBe(true); + expect(addHumanFragment(fragment({ entityId: "founder", displayName: "Founder", address: "founder@external" }), home).ok).toBe(true); + + const setMike = setHumanField( + "mike", + "binding.0", + "relay:bridge-1:file:/etc/openrig/relay1.env:primary:handle=shared-alias", + home, + ); + expect(setMike.ok).toBe(true); + + const conflictSameRelay = setHumanField( + "founder", + "binding.0", + "relay:bridge-1:file:/etc/openrig/relay1.env:primary:handle=shared-alias", + home, + ); + expect(conflictSameRelay.ok).toBe(false); + + const okOtherRelay = setHumanField( + "founder", + "binding.0", + "relay:bridge-2:file:/etc/openrig/relay2.env:primary:handle=shared-alias", + home, + ); + expect(okOtherRelay.ok).toBe(true); + }); }); describe("A3 pt2 — hardening (r1 pooled notes)", () => { diff --git a/packages/daemon/test/gateway-relay-inbound.test.ts b/packages/daemon/test/gateway-relay-inbound.test.ts new file mode 100644 index 000000000..a79b33286 --- /dev/null +++ b/packages/daemon/test/gateway-relay-inbound.test.ts @@ -0,0 +1,2392 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { serve, type ServerType } from "@hono/node-server"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type Database from "better-sqlite3"; +import type { Hono } from "hono"; +import { createTestApp } from "./helpers/test-app.js"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import { QueueRepository } from "../src/domain/queue-repository.js"; +import { archiveAgedTerminalTransitions } from "../src/domain/queue-retention.js"; +import { OutboxHandler } from "../src/domain/outbox-handler.js"; +import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js"; +import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js"; +import { GatewaySubsystem } from "../src/domain/gateway/gateway-subsystem.js"; +import { buildSlackGatewayWire, makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js"; +import { addHumanFragment, loadHumanRegistry } from "../src/domain/gateway/human-registry.js"; +import { OUTBOUND_OP } from "../src/domain/gateway/slack/outbound-driver.js"; +import { + encodeRelayReplyRef, + hashRelayReplyRef, + prepareRelayDecision, + type RelayReplyRefPayload, +} from "../src/domain/gateway/relay-outbound.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; +import { + RELAY_INBOUND_SEEN_FILE, + computeRelayEventFingerprint, + computeRelayEventKey, + createRelayInboundHandler, +} from "../src/domain/gateway/relay-inbound.js"; + +interface DaemonHandles { + app: Hono; + db: Database.Database; + queueRepo: QueueRepository; + missionControlActionLog: MissionControlActionLog; + missionControlWriteContract: MissionControlWriteContract; + gatewaySubsystem: GatewaySubsystem; +} + +interface RelayFixture { + home: string; + db: Database.Database; + daemon: DaemonHandles; + server: ServerType; + baseUrl: string; + inboundTokenMain: string; + outboundTokenMain: string; + inboundTokenSecondary: string; + outboundTokenSecondary: string; +} + +const fixtures: RelayFixture[] = []; +const savedOpenRigHome = process.env.OPENRIG_HOME; + +afterEach(async () => { + if (savedOpenRigHome === undefined) { + delete process.env.OPENRIG_HOME; + } else { + process.env.OPENRIG_HOME = savedOpenRigHome; + } + while (fixtures.length > 0) { + const f = fixtures.pop()!; + f.daemon.gatewaySubsystem.stop(); + await new Promise((resolve) => f.server.close(() => resolve())); + f.db.close(); + rmSync(f.home, { recursive: true, force: true }); + } +}); + +function writeRelayEnvFile( + home: string, + fileName: string, + vars: Record, + mode = 0o600, +): string { + const secretsDir = join(home, "secrets"); + mkdirSync(secretsDir, { recursive: true }); + const filePath = join(secretsDir, fileName); + const content = Object.entries(vars) + .map(([k, v]) => `${k}=${v}`) + .join("\n") + "\n"; + writeFileSync(filePath, content, { mode }); + chmodSync(filePath, mode); + return filePath; +} + +async function createRelayFixture(opts?: { + configureMainSecret?: boolean; + mainSecretMode?: number; + mainVars?: Record; + registerPartner?: boolean; + registerSecondaryRelay?: boolean; + secondaryVars?: Record; + missionControlBearerToken?: string | null; +}): Promise { + const home = mkdtempSync(join(tmpdir(), "relay-inbound-test-")); + writeFileSync(join(home, ".openrig-fixture"), ""); + process.env.OPENRIG_HOME = home; + + const inboundTokenMain = "relay-in-secret-main-token-001"; + const outboundTokenMain = "relay-out-secret-main-token-001"; + const inboundTokenSecondary = "relay-in-secret-sec-token-002"; + const outboundTokenSecondary = "relay-out-secret-sec-token-002"; + const mainEnvPath = join(home, "secrets", "relay-main.env"); + const secEnvPath = join(home, "secrets", "relay-sec.env"); + + if (opts?.configureMainSecret !== false) { + writeRelayEnvFile( + home, + "relay-main.env", + opts?.mainVars ?? { + OPENRIG_RELAY_URL: "http://127.0.0.1:19876/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: outboundTokenMain, + OPENRIG_RELAY_INBOUND_TOKEN: inboundTokenMain, + }, + opts?.mainSecretMode ?? 0o600, + ); + } + + addHumanFragment( + { + entityId: "founder", + class: "human", + displayName: "Founder", + address: "founder@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "relay-main", + handle: "founder-handle", + secretsRef: `file:${mainEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + home, + ); + + if (opts?.registerPartner !== false) { + addHumanFragment( + { + entityId: "partner", + class: "human", + displayName: "Partner", + address: "partner@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "relay-main", + handle: "partner-handle", + secretsRef: `file:${mainEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + home, + ); + } + + if (opts?.registerSecondaryRelay) { + writeRelayEnvFile( + home, + "relay-sec.env", + opts.secondaryVars ?? { + OPENRIG_RELAY_URL: "http://127.0.0.1:19877/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: outboundTokenSecondary, + OPENRIG_RELAY_INBOUND_TOKEN: inboundTokenSecondary, + }, + 0o600, + ); + addHumanFragment( + { + entityId: "ops", + class: "human", + displayName: "Ops", + address: "ops@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "relay-sec", + handle: "ops-handle", + secretsRef: `file:${secEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + home, + ); + } + + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + const eventBus = new EventBus(db); + const queueRepo = new QueueRepository(db, eventBus, { + validateRig: () => true, + loadHumanRegistry: () => loadHumanRegistry(home), + }); + const outbox = new OutboxHandler(db); + queueRepo.attachOutbox(outbox); + const missionControlActionLog = new MissionControlActionLog(db); + const missionControlWriteContract = new MissionControlWriteContract({ + db, + eventBus, + queueRepo, + actionLog: missionControlActionLog, + }); + const gatewaySubsystem = new GatewaySubsystem({ + home, + wire: () => + buildSlackGatewayWire({ + home, + queueRepo, + resolveHumanReply: makeHumanReplyResolver(queueRepo, missionControlWriteContract, home), + }), + }); + gatewaySubsystem.start(); + const relayInboundHandler = createRelayInboundHandler({ + home, + queueRepo, + missionControlWriteContract, + missionControlActionLog, + }); + const { app } = createTestApp(db, { + appDeps: { + queueRepo, + outboxHandler: outbox, + missionControlActionLog, + missionControlWriteContract, + gatewaySubsystem, + relayInboundHandler, + missionControlBearerToken: opts?.missionControlBearerToken ?? null, + }, + }); + const daemon: DaemonHandles = { + app, + db, + queueRepo, + missionControlActionLog, + missionControlWriteContract, + gatewaySubsystem, + }; + + let server!: ServerType; + const port = await new Promise((resolve) => { + server = serve( + { + fetch: daemon.app.fetch, + hostname: "127.0.0.1", + port: 0, + }, + (info: AddressInfo) => resolve(info.port), + ); + }); + + const fixture: RelayFixture = { + home, + db, + daemon, + server, + baseUrl: `http://127.0.0.1:${port}`, + inboundTokenMain, + outboundTokenMain, + inboundTokenSecondary, + outboundTokenSecondary, + }; + fixtures.push(fixture); + return fixture; +} + +async function createAskWithAcceptedRelayRef( + f: RelayFixture, + opts?: { + destinationSession?: string; + connectorRef?: string; + summary?: string; + body?: string; + humanIntent?: "decision" | "update"; + recordViaPendingBufferOnly?: boolean; + }, +): Promise<{ qitemId: string; replyRef: string; payload: RelayReplyRefPayload }> { + const destinationSession = opts?.destinationSession ?? "founder@external"; + const connectorRef = opts?.connectorRef ?? "relay-main"; + const isUpdate = opts?.humanIntent === "update"; + const created = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession, + summary: opts?.summary ?? "Architecture sign-off needed", + body: opts?.body ?? "Should we proceed with Option A or Option B?", + evidenceRef: "docs/arch-decision.md", + humanIntent: opts?.humanIntent ?? "decision", + ...(isUpdate + ? {} + : { + humanAlert: true, + humanQuestions: [ + { + id: "q-arch", + header: "Architecture", + question: "Which option should we ship?", + options: [ + { id: "opt-a", label: "Option A", description: "Strict local boundary" }, + { id: "opt-b", label: "Option B", description: "Deferred boundary" }, + ], + }, + ], + }), + nudge: false, + }); + + const item = f.daemon.queueRepo.getById(created.qitemId)!; + const transitions = f.daemon.queueRepo.listTransitions(created.qitemId); + const fallbackTransition = transitions[transitions.length - 1]!; + const episodeKey = isUpdate + ? `${created.qitemId}:${fallbackTransition.transitionId}` + : f.daemon.queueRepo.currentHumanRequiredEpisode(created.qitemId)!.key; + const decisionRevision = f.daemon.queueRepo.decisionRevision(item); + const decisionId = `dec-${episodeKey.replace(":", "-")}`; + + const payload: RelayReplyRefPayload = { + v: 1, + qitemId: created.qitemId, + episodeKey, + decisionRevision, + decisionId, + connectorRef, + mode: "ask", + }; + const replyRef = encodeRelayReplyRef(payload); + + if (opts?.recordViaPendingBufferOnly) { + const buffer = new DispatchBuffer(f.home); + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId, + op: OUTBOUND_OP, + entityBindingRef: destinationSession, + payload: { + qitemId: created.qitemId, + notificationKey: episodeKey, + destinationSession, + summary: item.summary, + body: item.body, + humanIntent: item.humanIntent, + ownerNotificationKind: isUpdate ? "human-update" : "human-required", + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "interrupt", + }, + ); + buffer.enqueue(prepared); + } else { + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: created.qitemId, + actorSession: "relay-connector", + notificationKey: episodeKey, + connectorRef, + deliveryId: decisionId, + replyRef, + replyRefHash: hashRelayReplyRef(replyRef), + revision: decisionRevision, + level: "ALERT", + kind: isUpdate ? "human-update" : "human-required", + closeUpdate: false, + }); + } + + return { qitemId: created.qitemId, replyRef, payload }; +} + +async function createParkedItemWithAcceptedRelayRef( + f: RelayFixture, + opts?: { + blockedOn?: string; + connectorRef?: string; + summary?: string; + body?: string; + withHumanQuestions?: boolean; + }, +): Promise<{ qitemId: string; replyRef: string; payload: RelayReplyRefPayload }> { + const blockedOn = opts?.blockedOn ?? "founder@external"; + const connectorRef = opts?.connectorRef ?? "relay-main"; + const created = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "builder@rig", + summary: opts?.summary ?? "Release approval gate", + body: opts?.body ?? "Waiting on founder sign-off for release cut.", + evidenceRef: "docs/release-plan.md", + nudge: false, + }); + + if (opts?.withHumanQuestions) { + f.daemon.db + .prepare("UPDATE queue_items SET human_questions = ? WHERE qitem_id = ?") + .run( + JSON.stringify([ + { + id: "q-release", + header: "Release", + question: "Can we cut the release?", + options: [{ id: "yes", label: "Yes", description: "Cut now" }], + }, + ]), + created.qitemId, + ); + } + + f.daemon.queueRepo.claim({ + qitemId: created.qitemId, + destinationSession: "builder@rig", + }); + f.daemon.queueRepo.update({ + qitemId: created.qitemId, + actorSession: "builder@rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: blockedOn, + blockedOn, + transitionNote: "Parked on founder for release sign-off", + }); + + const item = f.daemon.queueRepo.getById(created.qitemId)!; + const episodeKey = f.daemon.queueRepo.currentHumanRequiredEpisode(created.qitemId)!.key; + const decisionRevision = f.daemon.queueRepo.decisionRevision(item); + const decisionId = `dec-${episodeKey.replace(":", "-")}`; + + const payload: RelayReplyRefPayload = { + v: 1, + qitemId: created.qitemId, + episodeKey, + decisionRevision, + decisionId, + connectorRef, + mode: "park", + }; + const replyRef = encodeRelayReplyRef(payload); + + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: created.qitemId, + actorSession: "relay-connector", + notificationKey: episodeKey, + connectorRef, + deliveryId: decisionId, + replyRef, + replyRefHash: hashRelayReplyRef(replyRef), + revision: decisionRevision, + level: "ALERT", + kind: "human-required", + }); + + return { qitemId: created.qitemId, replyRef, payload }; +} + +describe("POST /api/gateway/relay/inbound — boundary, auth, semantics, durability & composition", () => { + it("enforces real TCP loopback peer and rejects in-process requests without a socket, non-loopback peers, forwarding headers, and browser origins", async () => { + const f = await createRelayFixture(); + const { qitemId, replyRef } = await createAskWithAcceptedRelayRef(f); + const validBody = { + eventId: "evt-loopback-1", + sender: { handle: "founder-handle" }, + replyRef, + text: "Approve Option A", + ordering: "after-question", + }; + + // 1. In-process app.request has no real TCP socket remoteAddress -> must be rejected (403) + const inMemoryRes = await f.daemon.app.request("/api/gateway/relay/inbound", { + method: "POST", + headers: { + Host: "127.0.0.1:7433", + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(validBody), + }); + expect(inMemoryRes.status).toBe(403); + + // 2. Non-loopback TCP socket peer is rejected even if Host claims 127.0.0.1 + const nonLoopbackRes = await f.daemon.app.fetch( + new Request("http://127.0.0.1:7433/api/gateway/relay/inbound", { + method: "POST", + headers: { + Host: "127.0.0.1:7433", + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(validBody), + }), + { incoming: { socket: { remoteAddress: "100.64.0.12" } } } as never, + ); + expect(nonLoopbackRes.status).toBe(403); + + // 3. Browser boundary rejects cross-origin and untrusted Host requests (403) + const crossOriginRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + Origin: "http://evil.example", + "Content-Type": "application/json", + }, + body: JSON.stringify(validBody), + }); + expect(crossOriginRes.status).toBe(403); + + // Verify qitem is still untouched (pending) after all boundary rejections + expect(f.daemon.queueRepo.getById(qitemId)?.state).toBe("pending"); + + // 4. Forwarding / proxy headers (Via, X-Forwarded-*, Forwarded, X-Real-Ip) over a direct loopback TCP peer are ignored + const withForwardingHeadersRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + Via: "1.1 local-bridge", + "X-Forwarded-For": "203.0.113.9", + "X-Forwarded-Host": "relay.example", + "X-Forwarded-Proto": "https", + Forwarded: "for=203.0.113.9;proto=https", + "X-Real-Ip": "203.0.113.9", + }, + body: JSON.stringify(validBody), + }); + expect(withForwardingHeadersRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(qitemId)?.state).toBe("done"); + }); + + it("enforces explicit relay bearer token authentication and never allows null-token bypass when unready", async () => { + // Unconfigured secret file -> unready profile, never bypasses auth + const unconfigured = await createRelayFixture({ configureMainSecret: false }); + const { qitemId: unreadyQitem, replyRef: unreadyRef } = await createAskWithAcceptedRelayRef(unconfigured); + + const noHeaderOnUnready = await fetch(`${unconfigured.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + eventId: "evt-auth-1", + sender: { handle: "founder-handle" }, + replyRef: unreadyRef, + text: "Bypass attempt", + }), + }); + expect(noHeaderOnUnready.status).toBe(401); + expect(unconfigured.daemon.queueRepo.getById(unreadyQitem)?.state).toBe("pending"); + + // Missing inbound token in env file -> unready profile, rejects and never leaks outbound secret + const missingInbound = await createRelayFixture({ + mainVars: { + OPENRIG_RELAY_URL: "http://127.0.0.1:19876/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: "relay-out-secret-main-token-001", + }, + }); + const { replyRef: missingInboundRef } = await createAskWithAcceptedRelayRef(missingInbound); + const missingInboundRes = await fetch(`${missingInbound.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${missingInbound.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-auth-2", + sender: { handle: "founder-handle" }, + replyRef: missingInboundRef, + text: "Should fail due to missing inbound token", + }), + }); + expect(missingInboundRes.status).toBe(401); + const missingInboundBodyText = await missingInboundRes.text(); + expect(missingInboundBodyText).not.toContain(missingInbound.inboundTokenMain); + expect(missingInboundBodyText).not.toContain(missingInbound.outboundTokenMain); + + // Configured ready profile: test missing bearer, wrong bearer, and outbound token used on inbound + const f = await createRelayFixture({ + registerSecondaryRelay: true, + // Duplicate inbound token on secondary profile to test ambiguous token rejection + secondaryVars: { + OPENRIG_RELAY_URL: "http://127.0.0.1:19877/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: "unique-out-sec", + OPENRIG_RELAY_INBOUND_TOKEN: "relay-in-secret-main-token-001", + }, + }); + const { replyRef: dupRef } = await createAskWithAcceptedRelayRef(f); + const ambiguousRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-auth-ambiguous", + sender: { handle: "founder-handle" }, + replyRef: dupRef, + text: "Ambiguous token", + }), + }); + expect(ambiguousRes.status).toBe(401); + + const ready = await createRelayFixture(); + const { replyRef: readyRef } = await createAskWithAcceptedRelayRef(ready); + for (const badAuth of [ + undefined, + "Basic Zm9vOmJhcg==", + "Bearer ", + "Bearer wrong-token", + `Bearer ${ready.outboundTokenMain}`, // Outbound token must be rejected on inbound + ]) { + const res = await fetch(`${ready.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + ...(badAuth ? { Authorization: badAuth } : {}), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-auth-3", + sender: { handle: "founder-handle" }, + replyRef: readyRef, + text: "Attempt", + }), + }); + expect(res.status, `auth=${String(badAuth)}`).toBe(401); + const errText = await res.text(); + expect(errText).not.toContain(ready.inboundTokenMain); + expect(errText).not.toContain(ready.outboundTokenMain); + } + }); + + it("preserves existing /api/gateway/slack/forwarded hub protocol alongside /api/gateway/relay/inbound", async () => { + const f = await createRelayFixture({ missionControlBearerToken: "hub-forward-token-999" }); + + const unauthForward = await fetch(`${f.baseUrl}/api/gateway/slack/forwarded`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "event_callback" }), + }); + expect(unauthForward.status).toBe(401); + + const authForward = await fetch(`${f.baseUrl}/api/gateway/slack/forwarded`, { + method: "POST", + headers: { + Authorization: "Bearer hub-forward-token-999", + "Content-Type": "application/json", + }, + body: JSON.stringify({ envelope: { type: "event_callback" } }), + }); + // Slack forwarding isn't active in a relay-only fixture so subsystem returns 503 not-active, proving route & token still work + expect(authForward.status).toBe(503); + }); + + it("validates bounded JSON schema and size limits while accepting and ignoring harmless extra top-level and sender fields", async () => { + const f = await createRelayFixture(); + const { qitemId, replyRef } = await createAskWithAcceptedRelayRef(f); + + // Malformed JSON + const malformed = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: "{bad json", + }); + expect(malformed.status).toBe(400); + + // Invalid ordering value + const badOrdering = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-schema-3", + sender: { handle: "founder-handle" }, + replyRef, + text: "Hello", + ordering: "verified", + }), + }); + expect(badOrdering.status).toBe(400); + + // Oversized body (> 64 KiB) + const oversized = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-schema-big", + sender: { handle: "founder-handle" }, + replyRef, + text: "x".repeat(70_000), + }), + }); + expect(oversized.status).toBe(413); + + // Harmless extra top-level and sender fields are accepted and ignored (never overriding handle attribution) + const withExtras = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-schema-extras-ok", + sender: { + handle: "founder-handle", + displayName: "Founder Extra", + entityId: "partner", + }, + replyRef, + text: "Approve Option A with extra fields ignored", + ordering: "after-question", + actorSession: "partner@external", + bridgeMeta: { bridgeVersion: "1.0.0" }, + }), + }); + expect(withExtras.status).toBe(200); + expect(await withExtras.json()).toMatchObject({ + ok: true, + qitemId, + actorSession: "founder@external", + deduplicated: false, + }); + expect(f.daemon.queueRepo.getById(qitemId)?.state).toBe("done"); + }); + + it("answers a direct ask and resolves a parked item with after-question ordering, including pending-buffer replyRef acceptance", async () => { + const f = await createRelayFixture(); + + // 1. Direct ask whose outbound decision is still in the pending DispatchBuffer (race before ack) + const ask = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + const askRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-ask-1", + sender: { handle: "founder-handle" }, + replyRef: ask.replyRef, + text: "We should ship Option A with strict local boundaries.", + ordering: "after-question", + }), + }); + expect(askRes.status).toBe(200); + const askJson = (await askRes.json()) as Record; + expect(askJson).toMatchObject({ + ok: true, + qitemId: ask.qitemId, + verb: "answer", + actorSession: "founder@external", + deduplicated: false, + }); + + const updatedAsk = f.daemon.queueRepo.getById(ask.qitemId)!; + expect(updatedAsk.state).toBe("done"); + expect(updatedAsk.humanAnswers).toEqual({ + "q-arch": { + kind: "typed-reply", + placement: "first-unanswered", + text: "We should ship Option A with strict local boundaries.", + unansweredCount: 0, + }, + }); + const replyItem = f.daemon.queueRepo.getHumanAnswer(ask.qitemId, "founder@external"); + expect(replyItem).not.toBeNull(); + expect(replyItem?.sourceSession).toBe("founder@external"); + expect(replyItem?.destinationSession).toBe("architect@rig"); + + // 2. Parked item resolved with ordering="after-question" records typed answer and transitions blocked -> in-progress + const park = await createParkedItemWithAcceptedRelayRef(f); + const parkRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-park-1", + sender: { handle: "founder-handle" }, + replyRef: park.replyRef, + text: "Release approved; proceed with tag v1.2.0.", + ordering: "after-question", + }), + }); + expect(parkRes.status).toBe(200); + const parkJson = (await parkRes.json()) as Record; + expect(parkJson).toMatchObject({ + ok: true, + qitemId: park.qitemId, + verb: "resolve", + actorSession: "founder@external", + deduplicated: false, + }); + + const updatedPark = f.daemon.queueRepo.getById(park.qitemId)!; + expect(updatedPark.state).toBe("in-progress"); + expect(updatedPark.blockedOn).toBeNull(); + }); + + it("allows another registered person on the same relay to answer with real attribution and preserves first-responder winner finality", async () => { + const f = await createRelayFixture({ registerSecondaryRelay: true }); + const ask = await createAskWithAcceptedRelayRef(f, { destinationSession: "founder@external" }); + + // Cross-profile handle (ops-handle is bound to relay-sec, not relay-main) -> rejected (403) + const crossHandleRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-cross-handle", + sender: { handle: "ops-handle" }, + replyRef: ask.replyRef, + text: "Cross profile attempt", + ordering: "after-question", + }), + }); + expect(crossHandleRes.status).toBe(403); + + // Cross-profile token (relay-sec token trying to answer a relay-main replyRef) -> rejected (403) + const crossRefRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenSecondary}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-cross-ref", + sender: { handle: "ops-handle" }, + replyRef: ask.replyRef, + text: "Cross profile ref attempt", + ordering: "after-question", + }), + }); + expect(crossRefRes.status).toBe(403); + + // Partner on the SAME relay-main answers founder's ask -> succeeds with partner@external attribution + const partnerRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-partner-answer", + sender: { handle: "partner-handle" }, + replyRef: ask.replyRef, + text: "Partner stepping in: use Option A.", + ordering: "after-question", + }), + }); + expect(partnerRes.status).toBe(200); + expect(await partnerRes.json()).toMatchObject({ + ok: true, + actorSession: "partner@external", + qitemId: ask.qitemId, + }); + + const auditActions = f.daemon.missionControlActionLog.listForQitem(ask.qitemId, 5); + expect(auditActions[0]?.actorSession).toBe("partner@external"); + expect(auditActions[0]?.auditNotes).toMatchObject({ + asked: "founder@external", + }); + const partnerReply = f.daemon.queueRepo.getHumanAnswer(ask.qitemId, "partner@external"); + expect(partnerReply?.sourceSession).toBe("partner@external"); + expect(partnerReply?.body).toContain("Answered by partner@external, a registered person, on founder@external's ask"); + + // Founder replies later to the same ask -> rejected (409), partner's winning answer stands + const lateFounderRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-late-founder", + sender: { handle: "founder-handle" }, + replyRef: ask.replyRef, + text: "Founder late answer: Option B.", + ordering: "after-question", + }), + }); + expect(lateFounderRes.status).toBe(409); + expect(f.daemon.queueRepo.getById(ask.qitemId)?.humanAnswers).toEqual({ + "q-arch": { + kind: "typed-reply", + placement: "first-unanswered", + text: "Partner stepping in: use Option A.", + unansweredCount: 0, + }, + }); + }); + + it("rejects forged replyRefs, update/notice replies, and stale revision or re-parked episode references", async () => { + const f = await createRelayFixture(); + + // 1. Forged replyRef that was never offered in DispatchBuffer or queue_transitions + const ask = await createAskWithAcceptedRelayRef(f); + const forgedPayload: RelayReplyRefPayload = { + ...ask.payload, + decisionId: "dec-forged-never-offered", + }; + const forgedRef = encodeRelayReplyRef(forgedPayload); + const forgedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-forged-1", + sender: { handle: "founder-handle" }, + replyRef: forgedRef, + text: "Forged reply", + ordering: "after-question", + }), + }); + expect(forgedRes.status).toBe(409); + expect(f.daemon.queueRepo.getById(ask.qitemId)?.state).toBe("pending"); + + // 2. Informational update item cannot be answered even if a receipt is present + const updateItem = await createAskWithAcceptedRelayRef(f, { humanIntent: "update" }); + const updateRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-update-1", + sender: { handle: "founder-handle" }, + replyRef: updateItem.replyRef, + text: "Trying to answer an update notice", + ordering: "after-question", + }), + }); + expect(updateRes.status).toBe(409); + + // 3. Stale park episode: item is resolved via terminal, then re-parked on founder in a new episode + const park = await createParkedItemWithAcceptedRelayRef(f); + await f.daemon.missionControlWriteContract.act({ + verb: "resolve", + qitemId: park.qitemId, + actorSession: "founder@external", + decision: "Resolved episode 1 via CLI", + identityProvenance: "transport:v1", + notify: false, + }); + // Re-park into episode 2 + f.daemon.queueRepo.update({ + qitemId: park.qitemId, + actorSession: "builder@rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "founder@external", + blockedOn: "founder@external", + transitionNote: "Parked again for episode 2 question", + }); + + // Stale replyRef from episode 1 must NOT resolve episode 2! + const staleParkRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-stale-park-ep1", + sender: { handle: "founder-handle" }, + replyRef: park.replyRef, + text: "Late reply to episode 1", + ordering: "after-question", + }), + }); + expect(staleParkRes.status).toBe(409); + expect(f.daemon.queueRepo.getById(park.qitemId)?.state).toBe("blocked"); + + // 4. Verification that receipt matching requires canonical full 64-hex SHA256 when replyRef is omitted from receipt + const askHashMatch = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + // Record receipt with replyRef = null and only canonical full 64-hex replyRefHash for this actual current episode + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: askHashMatch.qitemId, + actorSession: "relay-connector", + notificationKey: askHashMatch.payload.episodeKey, + connectorRef: askHashMatch.payload.connectorRef, + deliveryId: askHashMatch.payload.decisionId, + replyRef: null, + replyRefHash: hashRelayReplyRef(askHashMatch.replyRef), + revision: askHashMatch.payload.decisionRevision, + level: "ALERT", + kind: "human-required", + }); + // Drain/ack dispatch buffer so the hash-only receipt is the sole offer path + new DispatchBuffer(f.home).ack(askHashMatch.payload.decisionId); + + // Inbound reply with original askHashMatch.replyRef succeeds matching canonical full SHA-256 + const hashMatchRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-hash-match-1", + sender: { handle: "founder-handle" }, + replyRef: askHashMatch.replyRef, + text: "Answered via full-hash receipt match", + ordering: "after-question", + }), + }); + expect(hashMatchRes.status, `unexpected status for full-hash match`).toBe(200); + expect(f.daemon.queueRepo.getById(askHashMatch.qitemId)?.state).toBe("done"); + + // If receipt has a truncated 24-hex hash on a fresh active ask, inbound reply is rejected (409) + const askTruncated = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: askTruncated.qitemId, + actorSession: "relay-connector", + notificationKey: askTruncated.payload.episodeKey, + connectorRef: askTruncated.payload.connectorRef, + deliveryId: askTruncated.payload.decisionId, + replyRef: null, + replyRefHash: hashRelayReplyRef(askTruncated.replyRef).slice(0, 24), // truncated 24 hex + revision: askTruncated.payload.decisionRevision, + level: "ALERT", + kind: "human-required", + }); + new DispatchBuffer(f.home).ack(askTruncated.payload.decisionId); + + const truncatedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-hash-truncated-1", + sender: { handle: "founder-handle" }, + replyRef: askTruncated.replyRef, + text: "Should fail because 24-hex shortHash compatibility is removed", + ordering: "after-question", + }), + }); + expect(truncatedRes.status).toBe(409); + const truncatedBody = (await truncatedRes.json()) as { error?: string }; + expect(truncatedBody.error).toBe("unrecognized_reply_ref"); + expect(f.daemon.queueRepo.getById(askTruncated.qitemId)?.state).toBe("pending"); + }); + + it("records uncertain ordering with explicit timing uncertainty context and never populates verified answerText on blocked items", async () => { + const f = await createRelayFixture(); + const park = await createParkedItemWithAcceptedRelayRef(f, { withHumanQuestions: true }); + + const res = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-uncertain-1", + sender: { handle: "founder-handle" }, + replyRef: park.replyRef, + text: "Hold until staging smoke tests finish.", + // ordering omitted -> defaults to "uncertain" + }), + }); + expect(res.status).toBe(200); + + const afterItem = f.daemon.queueRepo.getById(park.qitemId)!; + expect(afterItem.state).toBe("in-progress"); + // Must NOT populate verified humanAnswers on blocked item when ordering is uncertain + expect(afterItem.humanAnswers ?? null).toBeNull(); + + const transitions = f.daemon.queueRepo.listTransitions(park.qitemId); + const resolveTransition = transitions[transitions.length - 1]!; + expect(resolveTransition.transitionNote).toContain("Hold until staging smoke tests finish."); + expect(resolveTransition.transitionNote).toMatch(/ordering=uncertain/i); + }); + + it("enforces SeenStore event idempotency, rejects equivocation across text/ref/handle/qitem, and stores no plaintext reply text in the seen ledger", async () => { + const f = await createRelayFixture(); + const ask1 = await createAskWithAcceptedRelayRef(f); + const ask2 = await createAskWithAcceptedRelayRef(f); + const secretReplyText = "CONFIDENTIAL-DECISION-TEXT-DO-NOT-LOG-IN-SEEN-STORE"; + + // First delivery + const first = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask1.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ ok: true, deduplicated: false }); + + // Exact duplicate delivery -> 200 idempotent no-op + const second = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask1.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(second.status).toBe(200); + expect(await second.json()).toMatchObject({ ok: true, deduplicated: true }); + + // Only one audit action recorded + expect(f.daemon.missionControlActionLog.listForQitem(ask1.qitemId, 10)).toHaveLength(1); + + // SeenStore file contains only hashed metadata, never plaintext reply text or raw tokens + const seenRaw = readFileSync(join(f.home, "state", RELAY_INBOUND_SEEN_FILE), "utf8"); + expect(seenRaw).not.toContain(secretReplyText); + expect(seenRaw).not.toContain(f.inboundTokenMain); + + // Equivocation 1: same eventId with different text -> 409 conflict + const diffText = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask1.replyRef, + text: "Changed text under same eventId", + ordering: "after-question", + }), + }); + expect(diffText.status).toBe(409); + + // Equivocation 2: same eventId with different sender handle -> 409 conflict + const diffSender = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "partner-handle" }, + replyRef: ask1.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(diffSender.status).toBe(409); + + // Equivocation 3: same eventId reused on a DIFFERENT qitem's replyRef -> 409 conflict + const diffQitem = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask2.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(diffQitem.status).toBe(409); + expect(f.daemon.queueRepo.getById(ask2.qitemId)?.state).toBe("pending"); + }); + + it("recovers cleanly from mid-flight crashes (received-before-effect, answer-row-before-audit, and resolve-before-processed even after re-park) and refuses 2xx when seen-store is unreadable", async () => { + const f = await createRelayFixture(); + const seenPath = join(f.home, "state", RELAY_INBOUND_SEEN_FILE); + const seen = new SeenStore(seenPath); + + // Case A: Crash after 'received' reservation before any Mission Control effect + const askA = await createAskWithAcceptedRelayRef(f); + const bodyA = { + eventId: "evt-crash-a", + sender: { handle: "founder-handle" }, + replyRef: askA.replyRef, + text: "Recovered answer A", + ordering: "after-question" as const, + }; + const origAct = f.daemon.missionControlWriteContract.act.bind(f.daemon.missionControlWriteContract); + let failOnce = true; + f.daemon.missionControlWriteContract.act = async (input) => { + if (failOnce) { + failOnce = false; + throw new Error("simulated crash before MC effect"); + } + return origAct(input); + }; + + const failedAttemptA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(failedAttemptA.status).toBe(500); + const keyA = computeRelayEventKey("relay-main", "evt-crash-a"); + expect(seen.statuses().get(keyA)).toMatch(/^received:/); + + const retryA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(retryA.status).toBe(200); + expect(seen.statuses().get(keyA)).toMatch(/^processed:/); + expect(f.daemon.queueRepo.getById(askA.qitemId)?.state).toBe("done"); + + // Case B: Crash after createHumanAnswer (reply row created) before resolveDirectHumanReply & audit commit + const askB = await createAskWithAcceptedRelayRef(f); + const bodyB = { + eventId: "evt-crash-b", + sender: { handle: "founder-handle" }, + replyRef: askB.replyRef, + text: "Recovered answer B after partial write", + ordering: "after-question" as const, + }; + const origCreateHumanAnswer = f.daemon.queueRepo.createHumanAnswer.bind(f.daemon.queueRepo); + let failAfterCreateReplyForQitem: string | null = askB.qitemId; + f.daemon.queueRepo.createHumanAnswer = async (input) => { + const createdReply = await origCreateHumanAnswer(input); + if (failAfterCreateReplyForQitem && input.inboundReplyTo === failAfterCreateReplyForQitem) { + failAfterCreateReplyForQitem = null; + throw new Error("simulated crash after createHumanAnswer before resolveDirectHumanReply"); + } + return createdReply; + }; + + const failedAttemptB = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyB), + }); + expect(failedAttemptB.status).toBe(409); + expect(await failedAttemptB.json()).toMatchObject({ + error: "answer_not_closed", + }); + // Reply row was already created on first attempt, but askB is still pending and no audit row committed yet + expect(f.daemon.queueRepo.getHumanAnswer(askB.qitemId, "founder@external")).not.toBeNull(); + expect(f.daemon.queueRepo.getById(askB.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(askB.qitemId, 5)).toHaveLength(0); + + const retryB = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyB), + }); + expect(retryB.status).toBe(200); + expect(f.daemon.queueRepo.getById(askB.qitemId)?.state).toBe("done"); + expect(f.daemon.missionControlActionLog.listForQitem(askB.qitemId, 5)).toHaveLength(1); + const keyB = computeRelayEventKey("relay-main", "evt-crash-b"); + expect(seen.statuses().get(keyB)).toMatch(/^processed:/); + + // Case B-negative: Crash after createHumanAnswer AND another edit/question mutation happens before retry -> MUST reject (409) + const askBNeg = await createAskWithAcceptedRelayRef(f); + const bodyBNeg = { + eventId: "evt-crash-b-neg", + sender: { handle: "founder-handle" }, + replyRef: askBNeg.replyRef, + text: "Partial answer before concurrent question edit", + ordering: "after-question" as const, + }; + failAfterCreateReplyForQitem = askBNeg.qitemId; + const failedAttemptBNeg = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyBNeg), + }); + expect(failedAttemptBNeg.status).toBe(409); + expect(await failedAttemptBNeg.json()).toMatchObject({ + error: "answer_not_closed", + }); + expect(f.daemon.queueRepo.getHumanAnswer(askBNeg.qitemId, "founder@external")).not.toBeNull(); + + // Concurrent edit to askBNeg's question body before retry arrives + f.daemon.db + .prepare("UPDATE queue_items SET body = ? WHERE qitem_id = ?") + .run("Edited question after partial reply — old revision must not close this", askBNeg.qitemId); + + const retryBNeg = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyBNeg), + }); + expect(retryBNeg.status).toBe(409); + expect(await retryBNeg.json()).toMatchObject({ + error: "decision_revision_changed", + }); + expect(f.daemon.queueRepo.getById(askBNeg.qitemId)?.state).toBe("pending"); + const keyBNeg = computeRelayEventKey("relay-main", "evt-crash-b-neg"); + expect(seen.statuses().get(keyBNeg)).toMatch(/^received:/); + f.daemon.queueRepo.createHumanAnswer = origCreateHumanAnswer; + + // Case B2: Crash between ask close (resolveDirectHumanReply) and audit record (missionControlActionLog.record), + // then retry the same received event through a fresh handler -> one answer, one close/resume transition, one audit row. + const askB2 = await createAskWithAcceptedRelayRef(f); + const bodyB2 = { + eventId: "evt-crash-b2-close-before-audit", + sender: { handle: "founder-handle" }, + replyRef: askB2.replyRef, + text: "Recovered answer B2 after close-before-audit crash", + ordering: "after-question" as const, + }; + const origRecord = f.daemon.missionControlActionLog.record.bind(f.daemon.missionControlActionLog); + let failAuditOnce = true; + f.daemon.missionControlActionLog.record = (entry) => { + if (failAuditOnce && entry.qitemId === askB2.qitemId) { + failAuditOnce = false; + throw new Error("simulated crash after ask close before audit record"); + } + return origRecord(entry); + }; + + const failedAttemptB2 = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyB2), + }); + expect(failedAttemptB2.status).toBe(500); + f.daemon.missionControlActionLog.record = origRecord; + + const keyB2 = computeRelayEventKey("relay-main", "evt-crash-b2-close-before-audit"); + expect(seen.statuses().get(keyB2)).toMatch(/^received:/); + // Close rolled back atomically with the failed audit; only the 1 reply row is present + expect(f.daemon.queueRepo.getById(askB2.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(askB2.qitemId, 5)).toHaveLength(0); + + // Retry the same received event through a freshly constructed RelayInboundHandler + const freshHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const retryB2 = await freshHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify(bodyB2), + }); + expect(retryB2.status).toBe(200); + expect(seen.statuses().get(keyB2)).toMatch(/^processed:/); + expect(f.daemon.queueRepo.getById(askB2.qitemId)?.state).toBe("done"); + expect(f.daemon.missionControlActionLog.listForQitem(askB2.qitemId, 5)).toHaveLength(1); + const b2Replies = f.daemon.db + .prepare("SELECT count(*) as n FROM queue_items WHERE reply_to = ?") + .get(askB2.qitemId) as { n: number }; + expect(b2Replies.n).toBe(1); + const b2CloseTransitions = f.daemon.queueRepo + .listTransitions(askB2.qitemId) + .filter((t) => t.ownerNotificationKind === "human-decision-resolved"); + expect(b2CloseTransitions).toHaveLength(1); + + // Case C: Crash after resolve commit before processed mark, AND qitem is subsequently re-parked in a new episode! + // Retry of the original event must reconcile its OWN committed resolve and mark processed without unparking the new episode. + const parkC = await createParkedItemWithAcceptedRelayRef(f); + const bodyC = { + eventId: "evt-crash-c", + sender: { handle: "founder-handle" }, + replyRef: parkC.replyRef, + text: "Approved episode 1 before crash", + ordering: "after-question" as const, + }; + // Simulate that attempt 1 reserved 'received' and committed MC resolve, then crashed before marking 'processed' + f.daemon.missionControlWriteContract.act = async (input) => { + const res = await origAct(input); + throw new Error("simulated crash after MC resolve commit before processed mark"); + return res; + }; + const failedAttemptC = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyC), + }); + expect(failedAttemptC.status).toBe(500); + expect(f.daemon.queueRepo.getById(parkC.qitemId)?.state).toBe("in-progress"); + + // Restore normal act() and re-park the item into Episode 2 + f.daemon.missionControlWriteContract.act = origAct; + f.daemon.queueRepo.update({ + qitemId: parkC.qitemId, + actorSession: "builder@rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "founder@external", + blockedOn: "founder@external", + transitionNote: "Re-parked into episode 2 while retry of episode 1 is in flight", + }); + + // Retry of bodyC reconciles its own episode 1 operation, marks processed, returns 200, and leaves Episode 2 blocked! + const retryC = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyC), + }); + expect(retryC.status).toBe(200); + const keyC = computeRelayEventKey("relay-main", "evt-crash-c"); + expect(seen.statuses().get(keyC)).toMatch(/^processed:/); + expect(f.daemon.queueRepo.getById(parkC.qitemId)?.state).toBe("blocked"); + + // Case D: Seen-ledger scoping: + // D1. Keyed records with valid received:/processed: still vouch for identity/result even with + // missing/invalid ts or extra diagnostic metadata; truly missing/unknown status or conflicting fingerprints + // refuse ONLY that key (503, never unseen), while unrelated keys remain available. + const askProcessedNoTs = await createAskWithAcceptedRelayRef(f); + const eventProcessedNoTs = "evt-processed-missing-ts"; + const keyProcessedNoTs = computeRelayEventKey("relay-main", eventProcessedNoTs); + const fpProcessedNoTs = computeRelayEventFingerprint({ + connectorRef: "relay-main", + eventId: eventProcessedNoTs, + senderHandle: "founder-handle", + actorSession: "founder@external", + replyRef: askProcessedNoTs.replyRef, + qitemId: askProcessedNoTs.qitemId, + episodeKey: askProcessedNoTs.payload.episodeKey, + decisionRevision: askProcessedNoTs.payload.decisionRevision, + mode: askProcessedNoTs.payload.mode, + ordering: "after-question", + text: "Already processed with missing ts", + }); + + const askReceivedNoTs = await createAskWithAcceptedRelayRef(f); + const eventReceivedNoTs = "evt-received-missing-ts"; + const keyReceivedNoTs = computeRelayEventKey("relay-main", eventReceivedNoTs); + const fpReceivedNoTs = computeRelayEventFingerprint({ + connectorRef: "relay-main", + eventId: eventReceivedNoTs, + senderHandle: "founder-handle", + actorSession: "founder@external", + replyRef: askReceivedNoTs.replyRef, + qitemId: askReceivedNoTs.qitemId, + episodeKey: askReceivedNoTs.payload.episodeKey, + decisionRevision: askReceivedNoTs.payload.decisionRevision, + mode: askReceivedNoTs.payload.mode, + ordering: "after-question", + text: "Reserved received with missing ts", + }); + + const suspectEventId1 = "evt-suspect-unknown-status"; + const suspectKey1 = computeRelayEventKey("relay-main", suspectEventId1); + const suspectEventId2 = "evt-suspect-missing-status"; + const suspectKey2 = computeRelayEventKey("relay-main", suspectEventId2); + const suspectEventId3 = "evt-suspect-conflicting-fps"; + const suspectKey3 = computeRelayEventKey("relay-main", suspectEventId3); + const existingSeenRaw = readFileSync(seenPath, "utf8"); + writeFileSync( + seenPath, + existingSeenRaw + + JSON.stringify({ id: keyProcessedNoTs, status: `processed:${fpProcessedNoTs}`, extraDiag: 123 }) + + "\n" + + JSON.stringify({ id: keyReceivedNoTs, ts: null, status: `received:${fpReceivedNoTs}`, note: "diag" }) + + "\n" + + JSON.stringify({ id: suspectKey1, ts: "2026-11-07T00:00:00.000Z", status: "unknown:status" }) + + "\n" + + JSON.stringify({ id: suspectKey2, ts: "2026-11-07T00:00:00.000Z" }) + + "\n" + + JSON.stringify({ id: suspectKey3, status: `received:${"a".repeat(64)}` }) + + "\n" + + JSON.stringify({ id: suspectKey3, status: `processed:${"b".repeat(64)}` }) + + "\n", + "utf8", + ); + + // Valid processed record missing ts still deduplicates safely (200, zero new effects) and rejects conflict (409) + const processedNoTsRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: eventProcessedNoTs, + sender: { handle: "founder-handle" }, + replyRef: askProcessedNoTs.replyRef, + text: "Already processed with missing ts", + ordering: "after-question", + }), + }); + expect(processedNoTsRes.status).toBe(200); + expect(await processedNoTsRes.json()).toMatchObject({ ok: true, deduplicated: true }); + expect(f.daemon.queueRepo.getById(askProcessedNoTs.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(askProcessedNoTs.qitemId, 5)).toHaveLength(0); + + const processedNoTsConflict = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: eventProcessedNoTs, + sender: { handle: "founder-handle" }, + replyRef: askProcessedNoTs.replyRef, + text: "Different text under processed missing-ts key", + ordering: "after-question", + }), + }); + expect(processedNoTsConflict.status).toBe(409); + + // Valid received record missing ts can safely complete/reconcile (200) + const receivedNoTsRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: eventReceivedNoTs, + sender: { handle: "founder-handle" }, + replyRef: askReceivedNoTs.replyRef, + text: "Reserved received with missing ts", + ordering: "after-question", + }), + }); + expect(receivedNoTsRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(askReceivedNoTs.qitemId)?.state).toBe("done"); + expect(seen.statuses().get(keyReceivedNoTs)).toBe(`processed:${fpReceivedNoTs}`); + + // Unrelated already-processed keyA still deduplicates (200) and rejects equivocation (409) + const dupA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(dupA.status).toBe(200); + expect(await dupA.json()).toMatchObject({ ok: true, deduplicated: true }); + + const conflictA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...bodyA, text: "Conflicting text for keyA" }), + }); + expect(conflictA.status).toBe(409); + + // Unrelated brand-new key on a fresh ask still succeeds (200) + const askUnrelated = await createAskWithAcceptedRelayRef(f); + const unrelatedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-unrelated-healthy", + sender: { handle: "founder-handle" }, + replyRef: askUnrelated.replyRef, + text: "Unrelated key succeeds despite keyed suspect records", + ordering: "after-question", + }), + }); + expect(unrelatedRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(askUnrelated.qitemId)?.state).toBe("done"); + + // Request for the keyed suspect eventIds (unknown status, missing status, conflicting fingerprints) is refused with 503 (never treated as unseen!) + const askSuspect = await createAskWithAcceptedRelayRef(f); + for (const badEventId of [suspectEventId1, suspectEventId2, suspectEventId3]) { + const suspectRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: badEventId, + sender: { handle: "founder-handle" }, + replyRef: askSuspect.replyRef, + text: "Should be refused because its keyed seen record is unvouched", + ordering: "after-question", + }), + }); + expect(suspectRes.status, `eventId=${badEventId}`).toBe(503); + const suspectJson = (await suspectRes.json()) as Record; + expect(suspectJson).toMatchObject({ + error: "seen_store_unavailable", + seenPath, + }); + expect(String(suspectJson.message)).toContain(seenPath); + } + expect(f.daemon.queueRepo.getById(askSuspect.qitemId)?.state).toBe("pending"); + + // D2. Unkeyable record (missing id) and unparseable JSONL line refuse with scoped 503 even for unseen keys + writeFileSync( + seenPath, + existingSeenRaw + JSON.stringify({ ts: "2026-11-07T00:00:00.000Z", status: `processed:${"b".repeat(64)}` }) + "\n", + "utf8", + ); + const unkeyableRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-unseen-during-unkeyable-history", + sender: { handle: "founder-handle" }, + replyRef: askSuspect.replyRef, + text: "Must not count as unseen when history has unkeyable line", + ordering: "after-question", + }), + }); + expect(unkeyableRes.status).toBe(503); + expect(await unkeyableRes.json()).toMatchObject({ + error: "seen_store_unavailable", + seenPath, + }); + expect(f.daemon.queueRepo.getById(askSuspect.qitemId)?.state).toBe("pending"); + + writeFileSync(seenPath, "{corrupt-jsonl-line\n", "utf8"); + const corruptRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(corruptRes.status).toBe(503); + expect(await corruptRes.json()).toMatchObject({ + error: "seen_store_unavailable", + seenPath, + }); + }); + + it("distinguishes normal noHumans (401) from broken or thrown registry reads (503) and preserves receipt-first verification when dispatch buffer is unreadable (503 for un-receipted refs)", async () => { + const f = await createRelayFixture(); + const receiptedAsk = await createAskWithAcceptedRelayRef(f); + const unreceiptedAsk = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + + // 1. Normal noHumans (empty registry directory with zero fragments) -> 401 unauthorized (not 503 outage) + const emptyHome = mkdtempSync(join(tmpdir(), "relay-empty-humans-")); + try { + const emptyHandler = createRelayInboundHandler({ + home: emptyHome, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const noHumansRes = await emptyHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: "evt-no-humans-1", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "No humans configured", + }), + }); + expect(noHumansRes.status).toBe(401); + expect(noHumansRes.body.error).toBe("unauthorized"); + } finally { + rmSync(emptyHome, { recursive: true, force: true }); + } + + // 2. Returned broken registry (ok: false without noHumans) -> 503 human_registry_unavailable + const brokenRegistryHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + loadEntities: () => ({ ok: false, error: "corrupt human fragment YAML" }), + }); + const brokenRegRes = await brokenRegistryHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: "evt-broken-reg-1", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "Broken registry", + }), + }); + expect(brokenRegRes.status).toBe(503); + expect(brokenRegRes.body.error).toBe("human_registry_unavailable"); + + // 3. Thrown registry read exception -> 503 human_registry_unavailable + const throwingRegistryHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + loadEntities: () => { + throw new Error("EIO: disk read failure on human registry"); + }, + }); + const thrownRegRes = await throwingRegistryHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: "evt-thrown-reg-1", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "Thrown registry", + }), + }); + expect(thrownRegRes.status).toBe(503); + expect(thrownRegRes.body.error).toBe("human_registry_unavailable"); + + // 4. Corrupt the local DispatchBuffer file: + // - A replyRef already vouched for by a durable accepted receipt still succeeds (200 OK) + // - An un-receipted replyRef that requires reading DispatchBuffer returns 503 dispatch_buffer_unavailable + // with zero seen-store reservation and zero action side effects. + const bufferPath = join(f.home, "gateway", "dispatch-buffer.json"); + writeFileSync(bufferPath, "{corrupt-dispatch-buffer-json\n", "utf8"); + + const receiptedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-receipt-wins-over-corrupt-buffer", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "Durable receipt vouches for this replyRef despite corrupt buffer", + ordering: "after-question", + }), + }); + expect(receiptedRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(receiptedAsk.qitemId)?.state).toBe("done"); + + const unreceiptedEventId = "evt-unreceipted-corrupt-buffer"; + const unreceiptedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: unreceiptedEventId, + sender: { handle: "founder-handle" }, + replyRef: unreceiptedAsk.replyRef, + text: "Should return 503 because buffer cannot be read to verify un-receipted ref", + ordering: "after-question", + }), + }); + expect(unreceiptedRes.status).toBe(503); + expect(await unreceiptedRes.json()).toMatchObject({ + error: "dispatch_buffer_unavailable", + }); + const seen = new SeenStore(join(f.home, "state", RELAY_INBOUND_SEEN_FILE)); + expect(seen.statuses().get(computeRelayEventKey("relay-main", unreceiptedEventId))).toBeUndefined(); + expect(f.daemon.queueRepo.getById(unreceiptedAsk.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(unreceiptedAsk.qitemId, 5)).toHaveLength(0); + }); + + it("exposes sanitized relay readiness on /api/gateway/human/:entityId/readiness and /api/gateway/connections without leaking secrets", async () => { + const f = await createRelayFixture(); + + const readinessRes = await fetch(`${f.baseUrl}/api/gateway/human/founder/readiness`); + expect(readinessRes.status).toBe(200); + const readinessBody = (await readinessRes.json()) as Record; + expect(readinessBody).toMatchObject({ + ok: true, + readiness: { + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + connector: { + kind: "relay", + ref: "relay-main", + }, + }, + }); + const readinessText = JSON.stringify(readinessBody); + expect(readinessText).not.toContain(f.inboundTokenMain); + expect(readinessText).not.toContain(f.outboundTokenMain); + + const connectionsRes = await fetch(`${f.baseUrl}/api/gateway/connections`); + expect(connectionsRes.status).toBe(200); + const connectionsBody = (await connectionsRes.json()) as Record; + const connectionsText = JSON.stringify(connectionsBody); + expect(connectionsText).not.toContain(f.inboundTokenMain); + expect(connectionsText).not.toContain(f.outboundTokenMain); + expect(connectionsBody.relay).toMatchObject({ + configured: true, + ready: true, + }); + }); + + it("keeps generated replyRef fixed-size (<= 4096 chars and fitting 64 KiB body) for arbitrary high-entropy qitemId, connectorRef, episodeKey, and decisionId across pending DispatchBuffer, active/archived accepted receipts, digest items, and 503/dedup target guards", async () => { + const f = await createRelayFixture({ registerSecondaryRelay: true }); + + const highEntropyStream = (label: string, blocks: number) => + Array.from({ length: blocks }, (_, idx) => + createHash("sha512").update(`${label}:${idx}`).digest("base64url"), + ).join("-"); + + // Register a relay connector profile with a > 19,000-char high-entropy connectorRef and two bound humans + const hugeConnectorRef = `relay-${highEntropyStream("connector-ref", 220)}`; + const hugeInboundToken = "in-tok-huge-connector-654321"; + const hugeOutboundToken = "out-tok-huge-connector-123456"; + const hugeEnvPath = join(f.home, "secrets", "relay-huge.env"); + writeFileSync( + hugeEnvPath, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:43199/outbound", + `OPENRIG_RELAY_OUTBOUND_TOKEN=${hugeOutboundToken}`, + `OPENRIG_RELAY_INBOUND_TOKEN=${hugeInboundToken}`, + ].join("\n") + "\n", + { mode: 0o600 }, + ); + chmodSync(hugeEnvPath, 0o600); + + addHumanFragment( + { + entityId: "longfounder", + class: "human", + displayName: "Long Founder", + address: "longfounder@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: hugeConnectorRef, + handle: "longfounder-handle", + secretsRef: `file:${hugeEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + f.home, + ); + addHumanFragment( + { + entityId: "longpartner", + class: "human", + displayName: "Long Partner", + address: "longpartner@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: hugeConnectorRef, + handle: "longpartner-handle", + secretsRef: `file:${hugeEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + f.home, + ); + + const rekeyQitemId = (oldId: string, newId: string) => { + f.db.pragma("foreign_keys = OFF"); + try { + f.db.prepare("UPDATE queue_items SET qitem_id = ? WHERE qitem_id = ?").run(newId, oldId); + f.db.prepare("UPDATE queue_transitions SET qitem_id = ? WHERE qitem_id = ?").run(newId, oldId); + f.db.prepare("UPDATE outbox_entries SET audit_pointer = ? WHERE audit_pointer = ?").run(newId, oldId); + } finally { + f.db.pragma("foreign_keys = ON"); + } + }; + + // 1. Pending DispatchBuffer round-trip with > 19,000-char qitemId, episodeKey, connectorRef, and decisionId + // (old inline base64url encoding > 100,000 chars > 4096 chars and > 64 KiB body; new token is exactly 246 chars) + const seededPending = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "longfounder@external", + summary: "High-entropy pending ask", + body: "Approve high-entropy pending deployment?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-huge-pending", + header: "Deploy", + question: "Ship high-entropy pending build?", + options: [ + { id: "yes", label: "Yes", description: "Proceed" }, + { id: "no", label: "No", description: "Hold" }, + ], + }, + ], + nudge: false, + }); + const hugeQitemIdPending = `qi-${highEntropyStream("qitem-pending", 220)}`; + rekeyQitemId(seededPending.qitemId, hugeQitemIdPending); + + const hugeEpisodeKeyPending = f.daemon.queueRepo.currentHumanRequiredEpisode(hugeQitemIdPending)!.key; + const hugeDecisionIdPending = `dec-${highEntropyStream("decision-pending", 220)}`; + expect(hugeQitemIdPending.length).toBeGreaterThan(19_000); + expect(hugeConnectorRef.length).toBeGreaterThan(19_000); + expect(hugeEpisodeKeyPending.length).toBeGreaterThan(19_000); + expect(hugeDecisionIdPending.length).toBeGreaterThan(19_000); + + const preparedPendingHuge = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: hugeDecisionIdPending, + op: OUTBOUND_OP, + entityBindingRef: "longfounder@external", + payload: { + qitemId: hugeQitemIdPending, + notificationKey: hugeEpisodeKeyPending, + destinationSession: "longfounder@external", + summary: "High-entropy pending ask", + body: "Approve high-entropy pending deployment?", + humanIntent: "decision", + ownerNotificationKind: "human-required", + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "interrupt", + }, + ); + const pendingHugeEnv = (preparedPendingHuge.payload as { relayEnvelope?: { replyRef: string | null } }).relayEnvelope!; + expect(pendingHugeEnv.replyRef).not.toBeNull(); + expect(pendingHugeEnv.replyRef!.length).toBeLessThanOrEqual(4096); + expect(pendingHugeEnv.replyRef!.length).toBe(246); + + const buffer = new DispatchBuffer(f.home); + buffer.enqueue(preparedPendingHuge); + + // Cross-connector guard: relay-main token rejected (403) when trying to answer hugeConnectorRef's replyRef + const crossConnRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-huge-cross-conn-1", + sender: { handle: "founder-handle" }, + replyRef: pendingHugeEnv.replyRef, + text: "Cross connector attempt", + ordering: "after-question", + }), + }); + expect(crossConnRes.status).toBe(403); + expect(await crossConnRes.json()).toMatchObject({ error: "connector_ref_mismatch" }); + + const inboundPendingBody = JSON.stringify({ + eventId: "evt-huge-pending-1", + sender: { handle: "longfounder-handle" }, + replyRef: pendingHugeEnv.replyRef, + text: "Yes, ship from pending buffer.", + ordering: "after-question", + }); + expect(Buffer.byteLength(inboundPendingBody, "utf8")).toBeLessThanOrEqual(65_536); + + const pendingRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${hugeInboundToken}`, + "Content-Type": "application/json", + }, + body: inboundPendingBody, + }); + expect(pendingRes.status).toBe(200); + expect(await pendingRes.json()).toMatchObject({ + ok: true, + qitemId: hugeQitemIdPending, + verb: "answer", + actorSession: "longfounder@external", + deduplicated: false, + }); + expect(f.daemon.queueRepo.getById(hugeQitemIdPending)?.state).toBe("done"); + buffer.ack(hugeDecisionIdPending); + + // 2. Accepted receipt round-trip after buffer drain (with corrupt dispatch-buffer.json + fresh handler + registered partner attribution + archive) + const seededReceipt = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "longfounder@external", + summary: "High-entropy receipt ask", + body: "Approve high-entropy receipt deployment?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-huge-receipt", + header: "Deploy Receipt", + question: "Ship high-entropy receipt build?", + options: [ + { id: "yes", label: "Yes", description: "Proceed" }, + { id: "no", label: "No", description: "Hold" }, + ], + }, + ], + nudge: false, + }); + const hugeQitemIdReceipt = `qi-${highEntropyStream("qitem-receipt", 220)}`; + rekeyQitemId(seededReceipt.qitemId, hugeQitemIdReceipt); + + const receiptHugeRow = f.daemon.queueRepo.getById(hugeQitemIdReceipt)!; + const hugeEpisodeKeyReceipt = f.daemon.queueRepo.currentHumanRequiredEpisode(hugeQitemIdReceipt)!.key; + const hugeRevisionReceipt = f.daemon.queueRepo.decisionRevision(receiptHugeRow); + const hugeDecisionIdReceipt = `dec-${highEntropyStream("decision-receipt", 220)}`; + + const preparedReceiptHuge = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: hugeDecisionIdReceipt, + op: OUTBOUND_OP, + entityBindingRef: "longfounder@external", + payload: { + qitemId: hugeQitemIdReceipt, + notificationKey: hugeEpisodeKeyReceipt, + destinationSession: "longfounder@external", + summary: "High-entropy receipt ask", + body: "Approve high-entropy receipt deployment?", + humanIntent: "decision", + ownerNotificationKind: "human-required", + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "interrupt", + }, + ); + const receiptHugeEnv = (preparedReceiptHuge.payload as { relayEnvelope?: { replyRef: string | null } }).relayEnvelope!; + expect(receiptHugeEnv.replyRef).not.toBeNull(); + expect(receiptHugeEnv.replyRef!.length).toBeLessThanOrEqual(4096); + expect(receiptHugeEnv.replyRef!.length).toBe(246); + + // Record durable accepted receipt (no pending buffer entry) and corrupt dispatch-buffer.json on disk + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: hugeQitemIdReceipt, + actorSession: "relay-connector", + notificationKey: hugeEpisodeKeyReceipt, + connectorRef: hugeConnectorRef, + deliveryId: hugeDecisionIdReceipt, + replyRef: receiptHugeEnv.replyRef, + replyRefHash: hashRelayReplyRef(receiptHugeEnv.replyRef!), + revision: hugeRevisionReceipt, + episodeKey: hugeEpisodeKeyReceipt, + level: "ALERT", + kind: "human-required", + closeUpdate: false, + }); + const bufferPath = join(f.home, "gateway", "dispatch-buffer.json"); + writeFileSync(bufferPath, "{corrupt-buffer-while-receipt-valid\n", "utf8"); + + const inboundReceiptBody = JSON.stringify({ + eventId: "evt-huge-receipt-partner-1", + sender: { handle: "longpartner-handle" }, + replyRef: receiptHugeEnv.replyRef, + text: "Partner approves from durable receipt with corrupt buffer.", + ordering: "after-question", + }); + expect(Buffer.byteLength(inboundReceiptBody, "utf8")).toBeLessThanOrEqual(65_536); + + // Fresh handler instance (simulating restart) resolves from durable accepted receipt despite corrupt buffer + const restartedHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const receiptPartnerRes = await restartedHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: inboundReceiptBody, + }); + expect(receiptPartnerRes.status).toBe(200); + expect(receiptPartnerRes.body).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + verb: "answer", + actorSession: "longpartner@external", + deduplicated: false, + }); + expect(f.daemon.queueRepo.getById(hugeQitemIdReceipt)?.state).toBe("done"); + + // Restore valid empty dispatch-buffer.json, archive the terminal transitions into queue_transitions_archive, and verify HTTP replay + writeFileSync(bufferPath, "[]\n", "utf8"); + const oldIso = "2025-01-01T00:00:00.000Z"; + f.db.prepare("UPDATE queue_items SET ts_updated = ? WHERE qitem_id = ?").run(oldIso, hugeQitemIdReceipt); + f.db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ?").run(oldIso, hugeQitemIdReceipt); + expect( + archiveAgedTerminalTransitions(f.db, { nowIso: "2026-11-07T00:00:00.000Z" }).archivedQitems, + ).toBeGreaterThanOrEqual(1); + + const archivedReplayRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${hugeInboundToken}`, + "Content-Type": "application/json", + }, + body: inboundReceiptBody, + }); + expect(archivedReplayRes.status).toBe(200); + expect(await archivedReplayRes.json()).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + actorSession: "longpartner@external", + deduplicated: true, + }); + + // 3. Processed-event dedup runs BEFORE current-target getById/listTransitions verification: + // - When getById returns null (target missing) or throws (target DB read error) while receipt + processed seen ledger exist: + // * exact duplicate replay still returns 200 OK (deduplicated: true) without touching getById + // * fresh new eventId with the same replyRef returns 404 (when missing) or 503 (when throwing) with zero seen reservation + let getByIdCalls = 0; + const missingTargetRepo = new Proxy(f.daemon.queueRepo, { + get(target, prop, receiver) { + if (prop === "getById") { + return () => { + getByIdCalls++; + return null; + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const missingTargetHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: missingTargetRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const dedupWhenMissingRes = await missingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: inboundReceiptBody, + }); + expect(dedupWhenMissingRes.status).toBe(200); + expect(dedupWhenMissingRes.body).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + deduplicated: true, + }); + expect(getByIdCalls).toBe(0); + + const seen = new SeenStore(join(f.home, "state", RELAY_INBOUND_SEEN_FILE)); + const freshMissingEventId = "evt-fresh-when-target-missing-1"; + const freshWhenMissingRes = await missingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: JSON.stringify({ + eventId: freshMissingEventId, + sender: { handle: "longpartner-handle" }, + replyRef: receiptHugeEnv.replyRef, + text: "Fresh event when target row is gone", + ordering: "after-question", + }), + }); + expect(freshWhenMissingRes.status).toBe(404); + expect(freshWhenMissingRes.body.error).toBe("qitem_not_found"); + expect(seen.statuses().get(computeRelayEventKey(hugeConnectorRef, freshMissingEventId))).toBeUndefined(); + + const throwingTargetRepo = new Proxy(f.daemon.queueRepo, { + get(target, prop, receiver) { + if (prop === "getById") { + return () => { + throw new Error("SQLITE_IOERR: simulated getById failure"); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const throwingTargetHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: throwingTargetRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const dedupWhenThrowingRes = await throwingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: inboundReceiptBody, + }); + expect(dedupWhenThrowingRes.status).toBe(200); + expect(dedupWhenThrowingRes.body).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + deduplicated: true, + }); + + const freshThrowingEventId = "evt-fresh-when-target-throws-1"; + const freshWhenThrowingRes = await throwingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: JSON.stringify({ + eventId: freshThrowingEventId, + sender: { handle: "longpartner-handle" }, + replyRef: receiptHugeEnv.replyRef, + text: "Fresh event when target getById throws", + ordering: "after-question", + }), + }); + expect(freshWhenThrowingRes.status).toBe(503); + expect(freshWhenThrowingRes.body.error).toBe("queue_store_unavailable"); + expect(seen.statuses().get(computeRelayEventKey(hugeConnectorRef, freshThrowingEventId))).toBeUndefined(); + + // 4. Multi-item digest with per-item fixed-size replyRef tokens (264 chars for ask-digest) scopes each reply to its individual question + const digAsk1 = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "founder@external", + summary: "Digest ask 1", + body: "Question 1?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + const digAsk2 = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "founder@external", + summary: "Digest ask 2", + body: "Question 2?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + const ep1 = f.daemon.queueRepo.currentHumanRequiredEpisode(digAsk1.qitemId)!.key; + const ep2 = f.daemon.queueRepo.currentHumanRequiredEpisode(digAsk2.qitemId)!.key; + const digestDecisionId = `digest:${highEntropyStream("digest-id", 60)}`; + const preparedDigest = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: digestDecisionId, + op: OUTBOUND_OP, + entityBindingRef: "founder@external", + payload: { + deliveryDigestPost: true, + digestId: "multi-huge", + qitemId: digAsk1.qitemId, + destinationSession: "founder@external", + summary: "Digest summary", + body: "• Question 1?\n• Question 2?", + memberReceipts: [ + { qitemId: digAsk1.qitemId, notificationKey: ep1, level: "ALERT", kind: "human-required" }, + { qitemId: digAsk2.qitemId, notificationKey: ep2, level: "ALERT", kind: "human-required" }, + ], + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "digest", + }, + ); + const digestEnv = ( + preparedDigest.payload as { + relayEnvelope?: { replyRef: string | null; items?: Array<{ qitemId: string; replyRef: string | null }> }; + } + ).relayEnvelope!; + expect(digestEnv.replyRef).toBeNull(); + const itemRef1 = digestEnv.items![0]!.replyRef!; + const itemRef2 = digestEnv.items![1]!.replyRef!; + expect(itemRef1.length).toBeLessThanOrEqual(4096); + expect(itemRef2.length).toBeLessThanOrEqual(4096); + expect(itemRef1.length).toBe(264); + expect(itemRef2.length).toBe(264); + + // Record accepted receipts for both digest member items + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: digAsk1.qitemId, + actorSession: "relay-connector", + notificationKey: ep1, + connectorRef: "relay-main", + deliveryId: digestDecisionId, + replyRef: itemRef1, + replyRefHash: hashRelayReplyRef(itemRef1), + revision: f.daemon.queueRepo.decisionRevision(digAsk1), + episodeKey: ep1, + level: "ALERT", + kind: "human-required", + }); + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: digAsk2.qitemId, + actorSession: "relay-connector", + notificationKey: ep2, + connectorRef: "relay-main", + deliveryId: digestDecisionId, + replyRef: itemRef2, + replyRefHash: hashRelayReplyRef(itemRef2), + revision: f.daemon.queueRepo.decisionRevision(digAsk2), + episodeKey: ep2, + level: "ALERT", + kind: "human-required", + }); + + const digReply1 = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-digest-item-1", + sender: { handle: "founder-handle" }, + replyRef: itemRef1, + text: "Answer for digest item 1 only", + ordering: "after-question", + }), + }); + expect(digReply1.status).toBe(200); + expect(f.daemon.queueRepo.getById(digAsk1.qitemId)?.state).toBe("done"); + expect(f.daemon.queueRepo.getById(digAsk2.qitemId)?.state).toBe("pending"); + + // 5. Unavailable DB read during receipt lookup returns 503 queue_store_unavailable with zero seen-store reservation or action side effects + const dbThrowingRepo = new Proxy(f.daemon.queueRepo, { + get(target, prop, receiver) { + if (prop === "findRelayOwnerNotificationReceiptByReplyRef") { + return () => { + throw new Error("SQLITE_IOERR: simulated receipt lookup failure"); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const dbFailureHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: dbThrowingRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const dbFailEventId = "evt-db-unavailable-1"; + const dbFailRes = await dbFailureHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: dbFailEventId, + sender: { handle: "founder-handle" }, + replyRef: itemRef2, + text: "Should fail 503 before seen reservation", + ordering: "after-question", + }), + }); + expect(dbFailRes.status).toBe(503); + expect(dbFailRes.body.error).toBe("queue_store_unavailable"); + expect(seen.statuses().get(computeRelayEventKey("relay-main", dbFailEventId))).toBeUndefined(); + expect(f.daemon.queueRepo.getById(digAsk2.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(digAsk2.qitemId, 5)).toHaveLength(0); + }); +}); diff --git a/packages/daemon/test/gateway-relay-integration.test.ts b/packages/daemon/test/gateway-relay-integration.test.ts new file mode 100644 index 000000000..29809d933 --- /dev/null +++ b/packages/daemon/test/gateway-relay-integration.test.ts @@ -0,0 +1,1261 @@ +/** + * Whole-Capability Production HTTP Integration Tests for Gateway Relay (Capability 3 / #2329) + * + * Justification for dedicated integration file: + * Existing unit/route tests (`relay-config.test.ts`, `relay-outbound.test.ts`, + * `gateway-relay-inbound.test.ts`) exercise individual layers or in-memory DBs. + * This suite proves the whole-capability operational seam that unit tests lack: + * 1. Production `createDaemon` composition over a file-backed SQLite DB (`dbPath`) + * with `test/hermetic-env.setup.ts` fixture-home isolation (`.openrig-fixture` preserved). + * 2. Post-bind network discipline (`startServices()` after TCP bind) + real loopback + * Hono HTTP server (`127.0.0.1:0`) paired with `./fixtures/recording-relay.js`. + * 3. Durable bridge custody before HTTP `2xx`, intentional lost-ACK fault injection, + * and actual daemon + bridge restart over the same SQLite DB and custody file. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { serve, type ServerType } from "@hono/node-server"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import { OPENRIG_HOME } from "../src/openrig-compat.js"; +import { createDaemon } from "../src/startup.js"; +import { parseRelayOwnerNotificationTransportFailed } from "../src/domain/queue-transition-log.js"; +import { + addHumanFragment, + loadHumanRegistry, + parseBindingSpec, + setHumanField, +} from "../src/domain/gateway/human-registry.js"; +import { setDefaultPerson } from "../src/domain/gateway/participants.js"; +import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js"; +import { SlackOutboundDriver } from "../src/domain/gateway/slack/outbound-driver.js"; +import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js"; +import { loadConfig } from "../src/domain/gateway/slack/config.js"; +import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { encodeRelayReplyRef } from "../src/domain/gateway/relay-outbound.js"; +import { + computeRelayEventKey, + RELAY_INBOUND_SEEN_FILE, +} from "../src/domain/gateway/relay-inbound.js"; +import { + startRecordingRelay, + type RecordingRelayHandle, +} from "./fixtures/recording-relay.js"; + +type DaemonInstance = Awaited>; + +interface RunningDaemon { + daemon: DaemonInstance; + server: ServerType; + baseUrl: string; + dbPath: string; + stop: () => Promise; +} + +const activeDaemons: RunningDaemon[] = []; +const activeBridges: RecordingRelayHandle[] = []; +let testScratchDir = ""; + +function cleanFixtureGatewayState(): void { + // Preserve `.openrig-fixture` and runtime stubs established by `test/hermetic-env.setup.ts` + for (const sub of ["gateway", "state"]) { + rmSync(join(OPENRIG_HOME, sub), { recursive: true, force: true }); + } +} + +async function waitForCondition( + predicate: () => boolean | Promise, + timeoutMs = 4000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +} + +function writeRelaySecretsFile( + filePath: string, + vars: { url: string; outboundToken: string; inboundToken: string }, + mode = 0o600, +): string { + const body = [ + `OPENRIG_RELAY_URL=${vars.url}`, + `OPENRIG_RELAY_OUTBOUND_TOKEN=${vars.outboundToken}`, + `OPENRIG_RELAY_INBOUND_TOKEN=${vars.inboundToken}`, + "", + ].join("\n"); + writeFileSync(filePath, body, { encoding: "utf8", mode }); + chmodSync(filePath, mode); + return filePath; +} + +function registerRelayPerson(opts: { + entityId: string; + displayName: string; + connectorRef: string; + envFilePath: string; + handle: string; +}): void { + const spec = `relay:${opts.connectorRef}:file:${opts.envFilePath}:primary:handle=${opts.handle}`; + const parsed = parseBindingSpec(spec); + expect(parsed.ok, `parseBindingSpec(${spec}) must succeed`).toBe(true); + if (!parsed.ok) throw new Error(parsed.error); + + const res = addHumanFragment( + { + entityId: opts.entityId, + class: "human", + displayName: opts.displayName, + address: `${opts.entityId}@external`, + connectorBindings: [ + { + kind: "relay", + connectorRef: parsed.binding.connectorRef, + secretsRef: parsed.binding.secretsRef, + role: "primary", + handle: parsed.binding.handle, + }, + ], + prefs: { deliveryClass: "A" }, + }, + OPENRIG_HOME, + ); + expect(res.ok, `addHumanFragment(${opts.entityId}) must succeed`).toBe(true); +} + +async function bootProductionDaemon(opts?: { + dbPath?: string; + startPostBind?: boolean; +}): Promise { + const dbPath = opts?.dbPath ?? join(testScratchDir, "daemon-e2e.sqlite"); + const daemon = await createDaemon({ + dbPath, + // Labeled fake terminal adapters: no live tmux/cmux server or native provider is touched. + tmuxExec: async () => "", + cmuxFactory: async () => { + throw Object.assign( + new Error("fake-cmux-adapter: no live terminal socket in relay integration test"), + { code: "ENOENT" }, + ); + }, + }); + + if (daemon.deps.sessionTransport) { + daemon.deps.sessionTransport.send = async (sessionName: string) => ({ + ok: true, + sessionName, + sent: true, + verified: true, + }); + } + + let server!: ServerType; + const port = await new Promise((resolve) => { + server = serve( + { fetch: daemon.app.fetch, hostname: "127.0.0.1", port: 0 }, + (info: AddressInfo) => resolve(info.port), + ); + }); + + if (opts?.startPostBind !== false) { + daemon.deps.gatewaySubsystem?.startServices(); + } + + let stopped = false; + const stop = async (): Promise => { + if (stopped) return; + stopped = true; + try { daemon.contextMonitor.stop(); } catch {} + try { daemon.eventLoopMonitor.stop(); } catch {} + try { daemon.deps.watchdogScheduler?.stop(); } catch {} + try { daemon.deps.modelDivergenceMonitor?.stopPolling(); } catch {} + try { daemon.deps.gatewaySubsystem?.stop(); } catch {} + await new Promise((resolve) => server.close(() => resolve())); + try { daemon.db.close(); } catch {} + }; + + const handle: RunningDaemon = { + daemon, + server, + baseUrl: `http://127.0.0.1:${port}`, + dbPath, + stop, + }; + activeDaemons.push(handle); + return handle; +} + +async function sweepOutboundOnce(daemon: DaemonInstance): Promise { + const queueRepo = daemon.deps.queueRepo!; + const subsystem = daemon.deps.gatewaySubsystem!; + const cfg = loadConfig(OPENRIG_HOME); + const seen = new SeenStore( + join(OPENRIG_HOME, "state", "slack-outbound-seen.jsonl"), + ); + const driver = new SlackOutboundDriver({ + home: OPENRIG_HOME, + queue: makeQueuePorts(queueRepo, { + loadHumanRegistry: () => loadHumanRegistry(OPENRIG_HOME), + }), + seen, + filter: { minimumLevel: cfg.minimumLevelThatPosts }, + dispatch: (op, ref, payload) => subsystem.dispatch(op, ref, payload), + }); + await driver.sweepOnce(); +} + +describe("Capability 3 gateway relay — whole-capability production HTTP integration", () => { + beforeAll(() => { + process.env.OPENRIG_NO_KERNEL = "1"; + }); + + afterAll(() => { + delete process.env.OPENRIG_NO_KERNEL; + cleanFixtureGatewayState(); + }); + + beforeEach(() => { + cleanFixtureGatewayState(); + const userHome = process.env.HOME ?? OPENRIG_HOME; + mkdirSync(userHome, { recursive: true }); + testScratchDir = mkdtempSync(join(userHome, "relay-int-")); + }); + + afterEach(async () => { + while (activeDaemons.length > 0) { + await activeDaemons.pop()!.stop(); + } + while (activeBridges.length > 0) { + await activeBridges.pop()!.stop(); + } + if (testScratchDir) { + rmSync(testScratchDir, { recursive: true, force: true }); + testScratchDir = ""; + } + cleanFixtureGatewayState(); + }); + + it( + "proves post-bind network discipline, single-POST > 64 KiB CJK/emoji ordinary ask outbound custody via real daemon poller, probe filtering, and inbound reply with ignored proxy headers/extras and real attribution", + async () => { + const outboundToken = "sec-relay-out-integration-01"; + const inboundToken = "sec-relay-in-integration-01"; + const custodyFilePath = join(testScratchDir, "bridge-custody.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-local.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + + // Configure alice-ops's Slack subscription to urgent-only; primary binding stays relay + const subRes = setHumanField("alice-ops", "slack-subscription", "urgent-only", OPENRIG_HOME); + expect(subRes.ok, "setHumanField slack-subscription urgent-only must succeed").toBe(true); + + const running = await bootProductionDaemon({ startPostBind: false }); + const { daemon } = running; + daemon.deps.rigRepo.createRig("ops-rig"); + + const longSummary = + "Production rollout gate — " + + "日本語確認🧪".repeat(165) + + " [SUMMARY-TAIL-MARKER-901]"; + expect(longSummary.length).toBeGreaterThan(1000); + + const longBody = + "Confirm whether we should promote release candidate v2.4.\n" + + "段落ブロック🚀漢字テスト-alpha-beta-gamma\n".repeat(2400) + + "[BODY-TAIL-MARKER-902]"; + const longDetail = + "Canary telemetry detail: p99=42ms, error_rate=0.00% 🛰️ [DETAIL-TAIL-MARKER-903]"; + + const ask = await daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "alice-ops@external", + summary: longSummary, + body: longBody, + humanIntent: "decision", + humanDetail: longDetail, + humanQuestions: [ + { + id: "q-promote", + question: "Promote release candidate v2.4? [QUESTION-TAIL-MARKER-904]", + options: [ + { + id: "yes", + label: "Promote now [OPT-YES-TAIL-905]", + recommended: true, + }, + { + id: "hold", + label: "Hold [OPT-HOLD-TAIL-906]", + }, + ], + }, + ], + nudge: false, + }); + + // Composition alone must never dial the bridge; GET / probes are ignored. + expect(daemon.deps.gatewaySubsystem?.status().state).toBe("active"); + expect(bridge.getSanitizedStats().relayPostCount).toBe(0); + const probeRes = await fetch(`${bridge.baseUrl}/`, { method: "GET" }); + expect(probeRes.status).toBe(404); + // The bridge fixture ignores non-POST probes; assert own probe was 404, + // zero actual relay POSTs before start, and ignoredProbeCount >= 1 in case + // host/environment sends unrelated probes to the local port. + expect(bridge.getSanitizedStats().ignoredProbeCount).toBeGreaterThanOrEqual(1); + expect(bridge.getSanitizedStats().relayPostCount).toBe(0); + + // Start post-bind services: do NOT call manual sweepOutboundOnce for this first ask. + // Wait for actual composition-owned default 30-second poller to produce the single complete > 64 KiB bridge custody record. + // We budget 38,000ms (38s) for the ~30s production polling cadence to fire. + daemon.deps.gatewaySubsystem?.startServices(); + await waitForCondition( + () => + bridge.getCustodyRecords().length === 1 && + daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(ask.qitemId), + 38_000, + ); + + const stats = bridge.getSanitizedStats(); + expect(stats.relayPostCount).toBe(1); + expect(stats.acceptedCount).toBe(1); + expect(stats.custodyCount).toBe(1); + + const custodyRecords = bridge.getCustodyRecords(); + expect(custodyRecords).toHaveLength(1); + + const receipt = daemon.deps.queueRepo!.getRelayOwnerNotificationReceipt(ask.qitemId); + expect(receipt?.connectorRef).toBe("local-bridge"); + const parentDeliveryId = receipt!.deliveryId; + + const loneSurrogateRe = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?).part).toBeUndefined(); + expect(rec.envelope.summary.length).toBeLessThanOrEqual(1000); + expect(rec.envelope.summary).not.toMatch(loneSurrogateRe); + expect(rec.envelope.body).not.toMatch(loneSurrogateRe); + + const deliveredEnvelope = rec.envelope; + expect(deliveredEnvelope).toMatchObject({ + v: 1, + deliveryId: parentDeliveryId, + recipient: { handle: "alice_ops" }, + intent: "decision", + outcome: "interrupt", + }); + expect(typeof deliveredEnvelope.replyRef).toBe("string"); + expect(deliveredEnvelope.body).toContain(longSummary); + expect(deliveredEnvelope.body).toContain("[SUMMARY-TAIL-MARKER-901]"); + expect(deliveredEnvelope.body).toContain(longBody); + expect(deliveredEnvelope.body).toContain("[BODY-TAIL-MARKER-902]"); + expect(deliveredEnvelope.body).toContain(longDetail); + expect(deliveredEnvelope.body).toContain("[DETAIL-TAIL-MARKER-903]"); + expect(deliveredEnvelope.body).toContain( + "Promote release candidate v2.4? [QUESTION-TAIL-MARKER-904]", + ); + expect(deliveredEnvelope.body).toContain( + "- Promote now [OPT-YES-TAIL-905] (recommended)", + ); + expect(deliveredEnvelope.body).toContain( + "- Hold [OPT-HOLD-TAIL-906]", + ); + // Receipt records bridge custody acceptance only; the ask remains pending until an inbound reply settles it. + expect(daemon.deps.queueRepo!.getById(ask.qitemId)?.state).toBe("pending"); + + // Custody log and sanitized diagnostics must never contain tokens. + const rawCustody = readFileSync(custodyFilePath, "utf8"); + expect(rawCustody).not.toContain(outboundToken); + expect(rawCustody).not.toContain(inboundToken); + expect(JSON.stringify(stats)).not.toContain(outboundToken); + expect(JSON.stringify(stats)).not.toContain(inboundToken); + + // Inbound reply with forwarding/proxy headers and harmless extra fields: + // proxy headers are ignored, extra fields are ignored and cannot spoof sender/actor, + // and event fingerprint is unchanged when replayed without extras. + const rawInboundRes = await fetch( + `${running.baseUrl}/api/gateway/relay/inbound`, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${inboundToken}`, + via: "1.1 local-bridge", + "x-forwarded-for": "203.0.113.9", + forwarded: "for=203.0.113.9;proto=http", + "x-real-ip": "203.0.113.9", + }, + body: JSON.stringify({ + eventId: "evt-ordinary-ask-1", + sender: { + handle: "alice_ops", + displayName: "Spoofed Display Name", + externalId: "ext-999", + }, + replyRef: deliveredEnvelope.replyRef!, + text: "Promote now — canary metrics are clean.", + ordering: "after-question", + actorSession: "spoofed-actor@external", + bridgeMetadata: { traceId: "trace-001" }, + }), + }, + ); + expect(rawInboundRes.status).toBe(200); + const replyBody = (await rawInboundRes.json()) as Record; + expect(replyBody).toMatchObject({ + ok: true, + qitemId: ask.qitemId, + verb: "answer", + actorSession: "alice-ops@external", + deduplicated: false, + }); + + const replayWithoutExtras = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ordinary-ask-1", + sender: { handle: "alice_ops" }, + replyRef: deliveredEnvelope.replyRef!, + text: "Promote now — canary metrics are clean.", + ordering: "after-question", + }); + expect(replayWithoutExtras.status).toBe(200); + expect(replayWithoutExtras.body).toMatchObject({ + ok: true, + qitemId: ask.qitemId, + actorSession: "alice-ops@external", + deduplicated: true, + }); + + const settledAsk = daemon.deps.queueRepo!.getById(ask.qitemId)!; + expect(settledAsk.state).toBe("done"); + expect(settledAsk.humanAnswers).toEqual({ + "q-promote": { + kind: "typed-reply", + placement: "first-unanswered", + text: "Promote now — canary metrics are clean.", + unansweredCount: 0, + }, + }); + + const seenRaw = readFileSync( + join(OPENRIG_HOME, "state", RELAY_INBOUND_SEEN_FILE), + "utf8", + ); + expect(seenRaw).not.toContain("Promote now — canary metrics are clean."); + expect(seenRaw).not.toContain(inboundToken); + expect(seenRaw).not.toContain(outboundToken); + }, + 50_000, + ); + + it("delivers operator notices without closing underlying blocked work, exposes accepted outcome on delivered human updates API, and rejects replies to informational updates", async () => { + const outboundToken = "sec-relay-out-integration-02"; + const inboundToken = "sec-relay-in-integration-02"; + const custodyFilePath = join(testScratchDir, "bridge-custody-2.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-local-2.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + setDefaultPerson("alice-ops", OPENRIG_HOME); + + const running = await bootProductionDaemon({ startPostBind: true }); + const { daemon } = running; + daemon.deps.rigRepo.createRig("ops-rig"); + + const stuckBaton = await daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "worker@ops-rig", + summary: "Worker baton stuck on wake", + body: "Need operator attention on worker@ops-rig.", + nudge: false, + }); + + const operatorEngine = makeOperatorDeliveryEngine({ + home: OPENRIG_HOME, + queueRepo: daemon.deps.queueRepo!, + gatewayState: () => + String(daemon.deps.gatewaySubsystem?.status().state ?? "unavailable"), + dispatch: (op, ref, payload, opts) => + daemon.deps.gatewaySubsystem!.dispatch(op, ref, payload, opts), + }); + + const opDispatch = await operatorEngine.dispatchEscalation( + stuckBaton, + "wake-ladder-exhausted", + ); + expect(opDispatch.dispatched).toBe(true); + + await waitForCondition(() => bridge.getCustodyRecords().length === 1); + const opRecord = bridge.getCustodyRecords()[0]!; + expect(opRecord.envelope.recipient.handle).toBe("alice_ops"); + expect(opRecord.envelope.replyRef).toBeNull(); + + await waitForCondition(() => + daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(stuckBaton.qitemId), + ); + expect(daemon.deps.queueRepo!.getById(stuckBaton.qitemId)?.state).toBe("pending"); + + const infoUpdate = await daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "alice-ops@external", + summary: "Deployment completed", + body: "FYI: deployment finished with 0 errors.", + humanIntent: "update", + nudge: false, + }); + + await sweepOutboundOnce(daemon); + await waitForCondition(() => bridge.getCustodyRecords().length === 2); + + const updateRecord = bridge.getCustodyRecords()[1]!; + expect(updateRecord.envelope.intent).toBe("update"); + expect(updateRecord.envelope.replyRef).toBeNull(); + + await waitForCondition( + () => daemon.deps.queueRepo!.getById(infoUpdate.qitemId)?.state === "done", + ); + + // Real serialized delivered-updates HTTP endpoint exposes deliveryOutcome: "accepted" for relay-accepted updates + const deliveredUpdatesRes = await fetch( + `${running.baseUrl}/api/queue/human-updates`, + ); + expect(deliveredUpdatesRes.status).toBe(200); + const deliveredUpdatesJson = (await deliveredUpdatesRes.json()) as { + items: Array>; + }; + const deliveredInfoItem = deliveredUpdatesJson.items.find( + (it) => it.qitemId === infoUpdate.qitemId, + ); + expect(deliveredInfoItem).toBeDefined(); + expect(deliveredInfoItem).toMatchObject({ + qitemId: infoUpdate.qitemId, + summary: "Deployment completed", + deliveryOutcome: "accepted", + }); + expect(String(deliveredInfoItem?.deliveryReceipt ?? "")).toMatch( + /^relay-owner-notification-accepted /, + ); + + const updateTransitions = daemon.deps.queueRepo!.listTransitions(infoUpdate.qitemId); + const forgedUpdateRef = encodeRelayReplyRef({ + v: 1, + qitemId: infoUpdate.qitemId, + episodeKey: `${infoUpdate.qitemId}:${updateTransitions[0]!.transitionId}`, + decisionRevision: daemon.deps.queueRepo!.decisionRevision( + daemon.deps.queueRepo!.getById(infoUpdate.qitemId)!, + ), + decisionId: updateRecord.deliveryId, + connectorRef: "local-bridge", + mode: "ask", + }); + + const updateReplyRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-update-reply-attempt", + sender: { handle: "alice_ops" }, + replyRef: forgedUpdateRef, + text: "Approved!", + ordering: "after-question", + }); + expect(updateReplyRes.status).toBe(409); + }); + + it("survives bridge lost ACK after durable custody commit, records relay-specific transport-failed note, and replays stable deliveryId/bytes across actual daemon + bridge restart", async () => { + const outboundToken = "sec-relay-out-integration-03"; + const inboundToken = "sec-relay-in-integration-03"; + const custodyFilePath = join(testScratchDir, "bridge-custody-restart.jsonl"); + const dbPath = join(testScratchDir, "daemon-restart.sqlite"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-restart.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + + const daemon1 = await bootProductionDaemon({ dbPath, startPostBind: true }); + daemon1.daemon.deps.rigRepo.createRig("ops-rig"); + + const ask = await daemon1.daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "alice-ops@external", + summary: "Database migration approval", + body: "Approve schema migration 150?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-mig", + header: "Migration", + question: "Run migration now?", + options: [ + { id: "run", label: "Run", description: "Execute migration" }, + { id: "defer", label: "Defer", description: "Hold until window" }, + ], + }, + ], + nudge: false, + }); + + bridge.setLoseNextAck(true); + await sweepOutboundOnce(daemon1.daemon); + await waitForCondition(() => bridge.getSanitizedStats().lostAckCount === 1); + await waitForCondition(() => + daemon1.daemon.deps + .queueRepo!.listTransitions(ask.qitemId) + .some((t) => + (t.transitionNote ?? "").startsWith( + "relay-owner-notification-transport-failed ", + ), + ), + ); + + const transitionNotes = daemon1.daemon.deps + .queueRepo!.listTransitions(ask.qitemId) + .map((t) => t.transitionNote ?? ""); + expect( + transitionNotes.some( + (n) => + n.startsWith("relay-owner-notification-transport-failed ") && + n.includes("connector_ref=local-bridge"), + ), + ).toBe(true); + expect( + transitionNotes + .map((n) => parseRelayOwnerNotificationTransportFailed(n)) + .find((f) => f !== null), + ).toMatchObject({ + connectorRef: "local-bridge", + failureClass: "http-502", + }); + expect( + transitionNotes.some((n) => + n.startsWith("slack-owner-notification-transport-failed"), + ), + ).toBe(false); + + const firstCustody = bridge.getCustodyRecords(); + expect(firstCustody).toHaveLength(1); + const firstDeliveryId = firstCustody[0]!.deliveryId; + const firstPayloadSha = firstCustody[0]!.payloadSha256; + const firstRawBytesSha = firstCustody[0]!.rawBytesSha256; + + expect( + new DispatchBuffer(OPENRIG_HOME).pending().map((d) => d.decisionId), + ).toContain(firstDeliveryId); + expect( + daemon1.daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(ask.qitemId), + ).toBe(false); + + // Bridge rejects changed payload under the same deliveryId (409 conflict). + const mutatedRes = await fetch(bridge.outboundUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${outboundToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...firstCustody[0]!.envelope, + body: "Mutated body under same deliveryId", + }), + }); + expect(mutatedRes.status).toBe(409); + expect(bridge.getSanitizedStats().conflictCount).toBe(1); + + // Shut down Daemon 1 AND restart the bridge server over the SAME custody file and port. + await daemon1.stop(); + activeDaemons.pop(); + await bridge.restart(); + + const daemon2 = await bootProductionDaemon({ dbPath, startPostBind: false }); + expect(bridge.getSanitizedStats().deduplicatedCount).toBe(0); + + daemon2.daemon.deps.gatewaySubsystem?.startServices(); + await waitForCondition(() => bridge.getSanitizedStats().deduplicatedCount === 1); + await waitForCondition(() => new DispatchBuffer(OPENRIG_HOME).pending().length === 0); + + const afterCustody = bridge.getCustodyRecords(); + expect(afterCustody).toHaveLength(1); + expect(afterCustody[0]!.deliveryId).toBe(firstDeliveryId); + expect(afterCustody[0]!.payloadSha256).toBe(firstPayloadSha); + expect(afterCustody[0]!.rawBytesSha256).toBe(firstRawBytesSha); + expect( + daemon2.daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(ask.qitemId), + ).toBe(true); + }); + + it("enforces inbound idempotency, equivocation conflict, stale episode/repark rejection, second-person attribution, uncertain ordering context, atomic close+audit recovery, and scoped seen-ledger handling", async () => { + const outboundToken = "sec-relay-out-integration-04"; + const inboundToken = "sec-relay-in-integration-04"; + const custodyFilePath = join(testScratchDir, "bridge-custody-inbound.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-inbound-matrix.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + registerRelayPerson({ + entityId: "bob-peer", + displayName: "Bob Peer", + connectorRef: "local-bridge", + envFilePath, + handle: "bob_peer", + }); + + const running = await bootProductionDaemon({ startPostBind: true }); + const { daemon } = running; + daemon.deps.rigRepo.createRig("ops-rig"); + + const parked = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "builder@ops-rig", + summary: "Cutover verification gate", + body: "Waiting on human confirmation before cutover.", + evidenceRef: "docs/cutover.md", + nudge: false, + }); + daemon.deps.queueRepo!.claim({ + qitemId: parked.qitemId, + destinationSession: "builder@ops-rig", + actorSession: "builder@ops-rig", + }); + daemon.deps.queueRepo!.update({ + qitemId: parked.qitemId, + actorSession: "builder@ops-rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "alice-ops@external", + blockedOn: "alice-ops@external", + transitionNote: "Parked on alice-ops for Episode 1", + ownerNotificationLevel: "ALERT", + ownerNotificationKind: "human-required", + }); + + await sweepOutboundOnce(daemon); + await waitForCondition(() => bridge.getCustodyRecords().length === 1); + const parkEp1Record = bridge.getCustodyRecords()[0]!; + expect(parkEp1Record.envelope.replyRef).not.toBeNull(); + + const uncertainRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-park-uncertain-1", + sender: { handle: "alice_ops" }, + replyRef: parkEp1Record.envelope.replyRef!, + text: "Check staging logs first, then proceed.", + ordering: "uncertain", + }); + expect(uncertainRes.status).toBe(200); + + const resumedPark = daemon.deps.queueRepo!.getById(parked.qitemId)!; + expect(resumedPark.state).toBe("in-progress"); + const latestParkNote = + daemon.deps.queueRepo!.listTransitions(parked.qitemId).at(-1)?.transitionNote ?? ""; + expect(latestParkNote).toContain("Check staging logs first, then proceed."); + expect(latestParkNote).toMatch(/ordering=uncertain/i); + + // Re-park into Episode 2 -> Episode 1 replyRef rejected (409) + daemon.deps.queueRepo!.update({ + qitemId: parked.qitemId, + actorSession: "builder@ops-rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "alice-ops@external", + blockedOn: "alice-ops@external", + transitionNote: "Re-parked on alice-ops for Episode 2", + ownerNotificationLevel: "ALERT", + ownerNotificationKind: "human-required", + }); + + const staleEp1Res = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-park-stale-ep1", + sender: { handle: "alice_ops" }, + replyRef: parkEp1Record.envelope.replyRef!, + text: "Late reply to Episode 1", + ordering: "after-question", + }); + expect(staleEp1Res.status).toBe(409); + expect(daemon.deps.queueRepo!.getById(parked.qitemId)?.state).toBe("blocked"); + + // Second registered person (`bob_peer`) answers `alice_ops`'s ask with real attribution + const ask1 = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "alice-ops@external", + summary: "Primary ask for Alice", + body: "Which region should we drain first?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-region", + header: "Region", + question: "Which region?", + options: [ + { id: "us-east", label: "us-east", description: "East" }, + { id: "eu-west", label: "eu-west", description: "West" }, + ], + }, + ], + nudge: false, + }); + + const askAtomic = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "alice-ops@external", + summary: "Atomic close+audit ask", + body: "Confirm atomic close and audit recovery?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + + const askLedger = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "alice-ops@external", + summary: "Scoped seen-ledger ask", + body: "Confirm healthy key works alongside keyed suspect entry?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + + await sweepOutboundOnce(daemon); + await waitForCondition(() => { + const summaries = new Set( + bridge.getCustodyRecords().map((r) => r.envelope.summary), + ); + return ( + summaries.has("Primary ask for Alice") && + summaries.has("Atomic close+audit ask") && + summaries.has("Scoped seen-ledger ask") + ); + }); + const ask1Record = bridge + .getCustodyRecords() + .find((r) => r.envelope.summary === "Primary ask for Alice")!; + const askAtomicRecord = bridge + .getCustodyRecords() + .find((r) => r.envelope.summary === "Atomic close+audit ask")!; + const askLedgerRecord = bridge + .getCustodyRecords() + .find((r) => r.envelope.summary === "Scoped seen-ledger ask")!; + + const bobRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-bob-answer-1", + sender: { handle: "bob_peer" }, + replyRef: ask1Record.envelope.replyRef!, + text: "Drain us-east first.", + ordering: "after-question", + }); + expect(bobRes.status).toBe(200); + expect(bobRes.body).toMatchObject({ + ok: true, + qitemId: ask1.qitemId, + actorSession: "bob-peer@external", + deduplicated: false, + }); + + const bobAnswerRow = daemon.deps.queueRepo!.getHumanAnswer( + ask1.qitemId, + "bob-peer@external", + ); + expect(bobAnswerRow?.sourceSession).toBe("bob-peer@external"); + expect(bobAnswerRow?.body).toContain( + "Answered by bob-peer@external, a registered person, on alice-ops@external's ask", + ); + + const bobDupRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-bob-answer-1", + sender: { handle: "bob_peer" }, + replyRef: ask1Record.envelope.replyRef!, + text: "Drain us-east first.", + ordering: "after-question", + }); + expect(bobDupRes.status).toBe(200); + expect(bobDupRes.body.deduplicated).toBe(true); + + const eqTextRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-bob-answer-1", + sender: { handle: "bob_peer" }, + replyRef: ask1Record.envelope.replyRef!, + text: "Drain eu-west instead.", + ordering: "after-question", + }); + expect(eqTextRes.status).toBe(409); + + // Atomic close+audit recovery: simulated audit write throw rolls back ask close while preserving + // the reply row and received:; retry under the original replyRef succeeds without duplicate reply rows. + const actionLog = daemon.deps.missionControlActionLog!; + const origRecord = actionLog.record.bind(actionLog); + let threwOnce = false; + actionLog.record = ((entry: Parameters[0]) => { + if (!threwOnce) { + threwOnce = true; + throw new Error("simulated audit failure inside atomic close+audit transaction"); + } + return origRecord(entry); + }) as typeof actionLog.record; + + try { + const crashRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-atomic-crash-1", + sender: { handle: "alice_ops" }, + replyRef: askAtomicRecord.envelope.replyRef!, + text: "Approve atomic close.", + ordering: "after-question", + }); + expect(crashRes.status).toBe(500); + expect(daemon.deps.queueRepo!.getById(askAtomic.qitemId)?.state).toBe("pending"); + expect( + daemon.deps.queueRepo!.listHumanAnswers(askAtomic.qitemId, "alice-ops@external"), + ).toHaveLength(1); + } finally { + actionLog.record = origRecord; + } + + const recoveredRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-atomic-crash-1", + sender: { handle: "alice_ops" }, + replyRef: askAtomicRecord.envelope.replyRef!, + text: "Approve atomic close.", + ordering: "after-question", + }); + expect(recoveredRes.status).toBe(200); + expect(recoveredRes.body).toMatchObject({ + ok: true, + qitemId: askAtomic.qitemId, + actorSession: "alice-ops@external", + deduplicated: false, + }); + expect(daemon.deps.queueRepo!.getById(askAtomic.qitemId)?.state).toBe("done"); + expect( + daemon.deps.queueRepo!.listHumanAnswers(askAtomic.qitemId, "alice-ops@external"), + ).toHaveLength(1); + + const postRecoveryDupRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-atomic-crash-1", + sender: { handle: "alice_ops" }, + replyRef: askAtomicRecord.envelope.replyRef!, + text: "Approve atomic close.", + ordering: "after-question", + }); + expect(postRecoveryDupRes.status).toBe(200); + expect(postRecoveryDupRes.body.deduplicated).toBe(true); + + // Scoped seen-ledger handling: keyed suspect record scopes 503 to that eventId only; + // unrelated keys still succeed; unkeyable/corrupt JSONL returns 503. + const seenFilePath = join(OPENRIG_HOME, "state", RELAY_INBOUND_SEEN_FILE); + const suspectKey = computeRelayEventKey("local-bridge", "evt-suspect-only"); + writeFileSync( + seenFilePath, + `${readFileSync(seenFilePath, "utf8")}${JSON.stringify({ id: suspectKey, status: "corrupt-status" })}\n`, + "utf8", + ); + + const suspectRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-suspect-only", + sender: { handle: "alice_ops" }, + replyRef: askLedgerRecord.envelope.replyRef!, + text: "Blocked by suspect record", + ordering: "after-question", + }); + expect(suspectRes.status).toBe(503); + expect(daemon.deps.queueRepo!.getById(askLedger.qitemId)?.state).toBe("pending"); + + const healthyRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ledger-healthy-1", + sender: { handle: "alice_ops" }, + replyRef: askLedgerRecord.envelope.replyRef!, + text: "Healthy event settles askLedger.", + ordering: "after-question", + }); + expect(healthyRes.status).toBe(200); + expect(healthyRes.body).toMatchObject({ + ok: true, + qitemId: askLedger.qitemId, + deduplicated: false, + }); + expect(daemon.deps.queueRepo!.getById(askLedger.qitemId)?.state).toBe("done"); + + writeFileSync( + seenFilePath, + `${readFileSync(seenFilePath, "utf8")}{corrupt-unparseable-jsonl\n`, + "utf8", + ); + const corruptLedgerRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ledger-after-corrupt", + sender: { handle: "alice_ops" }, + replyRef: askLedgerRecord.envelope.replyRef!, + text: "Refused due to corrupt ledger", + ordering: "after-question", + }); + expect(corruptLedgerRes.status).toBe(503); + }); + + it("warns on permissive 0644/shared-token relay config while remaining ready, localizes missing/ambiguous/invalid auth without secret leaks, and preserves default no-relay/Slack behavior", async () => { + const outboundToken = "sec-relay-out-integration-05"; + const inboundToken = "sec-relay-in-integration-05"; + const sharedToken = "sec-relay-shared-integration-05"; + const custodyFilePath = join(testScratchDir, "bridge-custody-auth.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + // Default no-relay daemon boots with active subsystem and inert Slack connector (see slack-subsystem-cutover.test.ts). + const defaultDaemon = await bootProductionDaemon({ startPostBind: true }); + const healthRes = await fetch(`${defaultDaemon.baseUrl}/api/health-summary/gateway`); + expect(healthRes.status).toBe(200); + expect(await healthRes.json()).toMatchObject({ + state: "active", + connector: { platform: "slack", outboundReady: false }, + }); + await defaultDaemon.stop(); + activeDaemons.pop(); + + // Provision relay with 0644 permissions + shared directional token -> ready: true with redacted warnings, no secret leak. + const envPath = writeRelaySecretsFile( + join(testScratchDir, "relay-permissive.env"), + { url: bridge.outboundUrl, outboundToken: sharedToken, inboundToken: sharedToken }, + 0o644, + ); + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath: envPath, + handle: "alice_ops", + }); + + const running = await bootProductionDaemon({ startPostBind: true }); + const permissiveRes = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(permissiveRes.status).toBe(200); + const permissiveJson = (await permissiveRes.json()) as { + ok: boolean; + readiness: { state: string; ready: boolean; reason?: string }; + }; + expect(permissiveJson).toMatchObject({ + ok: true, + readiness: { state: "ready", ready: true }, + }); + expect(permissiveJson.readiness.reason ?? "").toMatch(/warning/i); + expect(permissiveJson.readiness.reason ?? "").toMatch(/0644/); + expect(permissiveJson.readiness.reason ?? "").toMatch(/same token|separate tokens/i); + expect(JSON.stringify(permissiveJson)).not.toContain(sharedToken); + + // Strict 0400 permissions + distinct tokens -> ready: true with no warning. + writeRelaySecretsFile( + envPath, + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o400, + ); + const strict0400Res = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(strict0400Res.status).toBe(200); + const strict0400Json = (await strict0400Res.json()) as { + ok: boolean; + readiness: { state: string; ready: boolean; reason?: string }; + }; + expect(strict0400Json).toMatchObject({ + ok: true, + readiness: { state: "ready", ready: true }, + }); + expect(strict0400Json.readiness.reason ?? "").not.toMatch(/warning/i); + + // Missing required credential key -> localized not-ready, no secret leak. + chmodSync(envPath, 0o600); + writeFileSync( + envPath, + `OPENRIG_RELAY_URL=${bridge.outboundUrl}\nOPENRIG_RELAY_OUTBOUND_TOKEN=${outboundToken}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + const missingKeyRes = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(missingKeyRes.status).toBe(200); + const missingKeyJson = (await missingKeyRes.json()) as Record; + expect(missingKeyJson).toMatchObject({ + ok: true, + readiness: { state: "not-ready", ready: false }, + }); + expect(JSON.stringify(missingKeyJson)).not.toContain(outboundToken); + + // Restore valid distinct credentials on local-bridge; register second connector profile reusing the same inbound token -> ambiguous not-ready + 401. + writeRelaySecretsFile( + envPath, + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + const dupEnvPath = writeRelaySecretsFile( + join(testScratchDir, "relay-second-profile.env"), + { + url: bridge.outboundUrl, + outboundToken: "sec-relay-out-second-05", + inboundToken, + }, + 0o600, + ); + registerRelayPerson({ + entityId: "bob-dup", + displayName: "Bob Dup", + connectorRef: "second-bridge", + envFilePath: dupEnvPath, + handle: "bob_dup", + }); + + const ambigReadinessRes = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(ambigReadinessRes.status).toBe(200); + expect(await ambigReadinessRes.json()).toMatchObject({ + ok: true, + readiness: { state: "not-ready", ready: false }, + }); + + const ambigInboundRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ambig-1", + sender: { handle: "alice_ops" }, + replyRef: "forged", + text: "Ambiguous inbound token must fail", + }); + expect(ambigInboundRes.status).toBe(401); + + // Give second-bridge its own distinct inbound token so local-bridge is unambiguous again. + writeRelaySecretsFile( + dupEnvPath, + { + url: bridge.outboundUrl, + outboundToken: "sec-relay-out-second-05", + inboundToken: "sec-relay-in-second-05", + }, + 0o600, + ); + + const unauthRes = await bridge.sendInboundReply( + running.baseUrl, + { + eventId: "evt-unauth-1", + sender: { handle: "alice_ops" }, + replyRef: "forged", + text: "Should fail", + }, + { tokenOverride: null }, + ); + expect(unauthRes.status).toBe(401); + + const wrongTokenRes = await bridge.sendInboundReply( + running.baseUrl, + { + eventId: "evt-unauth-2", + sender: { handle: "alice_ops" }, + replyRef: "forged", + text: "Should fail", + }, + { tokenOverride: outboundToken }, + ); + expect(wrongTokenRes.status).toBe(401); + expect(JSON.stringify(wrongTokenRes.body)).not.toContain(outboundToken); + expect(JSON.stringify(wrongTokenRes.body)).not.toContain(inboundToken); + + const badBridgePost = await fetch(bridge.outboundUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${inboundToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + v: 1, + deliveryId: "dec-unauth", + recipient: { handle: "alice_ops" }, + intent: "decision", + outcome: "interrupt", + summary: "Test", + body: "Test", + replyRef: null, + }), + }); + expect(badBridgePost.status).toBe(401); + expect(bridge.getSanitizedStats().unauthorizedCount).toBe(1); + expect(bridge.getCustodyRecords()).toHaveLength(0); + }); +}); diff --git a/packages/daemon/test/gateway-subsystem-activation.test.ts b/packages/daemon/test/gateway-subsystem-activation.test.ts index 922995c2c..8ea638801 100644 --- a/packages/daemon/test/gateway-subsystem-activation.test.ts +++ b/packages/daemon/test/gateway-subsystem-activation.test.ts @@ -84,4 +84,107 @@ describe("S10 gateway subsystem activation (RED at tip: no boot-time subsystem c rmSync(home, { recursive: true, force: true }); } }); + + it("activates relay-only outbound polling post-bind with no Slack credentials while keeping Slack status unconfigured", async () => { + const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js"); + const { createDb } = await import("../src/db/connection.js"); + const { migrate } = await import("../src/db/migrate.js"); + const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js"); + const { EventBus } = await import("../src/domain/event-bus.js"); + const { QueueRepository } = await import("../src/domain/queue-repository.js"); + const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js"); + const { writeFileSync } = await import("node:fs"); + + const home = mkdtempSync(join(tmpdir(), "s10-relay-only-")); + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + const secretsFile = join(home, "relay.env"); + writeFileSync( + secretsFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44100/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + const registry = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${secretsFile}`, + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + const repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => registry, + }); + const row = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Please confirm.", + nudge: false, + }); + repo.update({ + qitemId: row.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "sam@kernel", + summary: "Approve deploy?", + evidenceRef: "/proof/deploy.md", + transitionNote: "parked", + }); + + const deliveredEnvelopes: Array> = []; + const wire = buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => registry, resolveSlackHandle }, + outboundIntervalMs: 15, + fetchImpl: async (_url, init) => { + deliveredEnvelopes.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + try { + // Composition alone must never start network polling or HTTP calls + await new Promise((r) => setTimeout(r, 30)); + expect(deliveredEnvelopes).toHaveLength(0); + expect(wire.status()).toMatchObject({ + outboundReady: false, + inboundReady: false, + }); + + // Post-bind startServices() starts outbound polling and delivers to the relay bridge + wire.startServices?.(); + await new Promise((r) => setTimeout(r, 60)); + expect(deliveredEnvelopes).toHaveLength(1); + expect(deliveredEnvelopes[0]).toMatchObject({ + v: 1, + recipient: { handle: "sam_wa" }, + intent: "decision", + }); + const episode = repo.currentHumanRequiredEpisode(row.qitemId)!; + expect(repo.hasRelayOwnerNotificationReceipt(row.qitemId, episode.key)).toBe(true); + } finally { + wire.stop(); + db.close(); + rmSync(home, { recursive: true, force: true }); + } + }); }); diff --git a/packages/daemon/test/gateway-subsystem-wire.test.ts b/packages/daemon/test/gateway-subsystem-wire.test.ts index b3edf3e08..b7a391271 100644 --- a/packages/daemon/test/gateway-subsystem-wire.test.ts +++ b/packages/daemon/test/gateway-subsystem-wire.test.ts @@ -112,4 +112,181 @@ describe("S10 in-process gateway wire — durability contract receipts", () => { expect(subsystem.status().state).toBe("active"); expect(subsystem.dispatch("post_message", "mike#slack", {}).ok).toBe(true); }); + + it("prepare(decision) runs synchronously BEFORE durable enqueue and replay sends the exact frozen payload without re-running prepare", async () => { + let prepareCalls = 0; + let bufferedDuringPrepare = -1; + let releaseFirst: (o: SubsystemDeliveryOutcome) => void = () => {}; + const firstGate = new Promise((r) => { releaseFirst = r; }); + + const w1 = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + prepareCalls++; + bufferedDuringPrepare = new DispatchBuffer(home).pending().length; + const p = (decision.payload ?? {}) as Record; + return { + ...decision, + payload: { + ...p, + relayEnvelope: { + v: 1, + deliveryId: decision.decisionId, + replyRef: `frozen-ref-${prepareCalls}`, + }, + }, + }; + }, + deliver: () => firstGate, + }); + + const r1 = w1.dispatcher.dispatch("post_message", "sam@external", { qitemId: "q-freeze-1" }, { decisionId: "d-freeze-1" }); + expect(r1.ok).toBe(true); + expect(prepareCalls).toBe(1); + expect(bufferedDuringPrepare, "prepare must run BEFORE durable buffer.enqueue").toBe(0); + + // Durable buffer immediately holds the frozen payload before delivery resolves. + const pendingBefore = new DispatchBuffer(home).pending(); + expect(pendingBefore).toHaveLength(1); + expect((pendingBefore[0]!.payload as { relayEnvelope?: { replyRef?: string } }).relayEnvelope?.replyRef).toBe("frozen-ref-1"); + + // Duplicate dispatch of same decisionId while pending does not re-run prepare or overwrite frozen payload. + const dup = w1.dispatcher.dispatch("post_message", "sam@external", { qitemId: "q-freeze-1", mutated: true }, { decisionId: "d-freeze-1" }); + expect(dup.ok).toBe(true); + expect(prepareCalls).toBe(1); + + // Fail first delivery so it stays in the buffer for replay. + releaseFirst({ ok: false, class: "transport", detail: "bridge down" }); + await tick(); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Run 2: replayPending sends the exact frozen payload from the buffer without calling prepare again. + const replayed: OutboundDecision[] = []; + const w2 = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + prepareCalls++; + return { + ...decision, + payload: { ...(decision.payload as object), relayEnvelope: { v: 1, deliveryId: decision.decisionId, replyRef: "recomputed-ref" } }, + }; + }, + deliver: async (d) => { + replayed.push(d); + return { ok: true }; + }, + }); + w2.startServices?.(); + await tick(); + + expect(prepareCalls, "replayPending must not re-run prepare").toBe(1); + expect(replayed).toHaveLength(1); + expect(replayed[0]!.decisionId).toBe("d-freeze-1"); + expect((replayed[0]!.payload as { relayEnvelope?: { replyRef?: string } }).relayEnvelope?.replyRef).toBe("frozen-ref-1"); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + }); + + describe("dispatcher prepare hook rails", () => { + it("preserves caller's advertised op, entityBindingRef, kind, and decisionId when prepare mutates argument in place and returns void", async () => { + let dispatchedDecision: OutboundDecision | undefined; + const wire = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + // Attempt malicious/accidental mutation of rails in place and return void + (decision as any).op = "unadvertised_bypassed_op"; + (decision as any).entityBindingRef = "hacked#channel"; + (decision as any).kind = "forged_kind"; + (decision as any).decisionId = "hacked_decision_id"; + // Valid payload preparation: + const p = (decision.payload ?? {}) as Record; + p.preparedPayloadField = "frozen_value"; + }, + deliver: async (d) => { + dispatchedDecision = d; + return { ok: true }; + }, + }); + + const res = wire.dispatcher.dispatch("post_message", "alice@external", { orig: 1 }, { decisionId: "d-fixed-1" }); + expect(res.ok).toBe(true); + expect((res as { decisionId: string }).decisionId).toBe("d-fixed-1"); + await tick(); + + expect(dispatchedDecision).toBeDefined(); + expect(dispatchedDecision!.op).toBe("post_message"); + expect(dispatchedDecision!.entityBindingRef).toBe("alice@external"); + expect(dispatchedDecision!.kind).toBe("outbound_decision"); + expect(dispatchedDecision!.decisionId).toBe("d-fixed-1"); + expect((dispatchedDecision!.payload as any).preparedPayloadField).toBe("frozen_value"); + expect((dispatchedDecision!.payload as any).orig).toBe(1); + + // Verify what was durably persisted in the buffer matches the rails + const bufferEntries = new DispatchBuffer(home).pending(); + expect(bufferEntries).toHaveLength(0); // Drained because delivery returned ok: true + }); + + it("preserves caller's advertised op, entityBindingRef, kind, and decisionId when prepare returns a mutated decision object", async () => { + let dispatchedDecision: OutboundDecision | undefined; + const wire = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + // Return new object attempting to alter op, entityBindingRef, kind, decisionId + return { + kind: "other_kind" as any, + decisionId: "hijacked_id", + op: "malicious_op", + entityBindingRef: "attacker#ref", + payload: { + ...(decision.payload as object), + frozenPayload: "persisted", + }, + }; + }, + deliver: async (d) => { + dispatchedDecision = d; + return { ok: true }; + }, + }); + + const res = wire.dispatcher.dispatch("post_message", "bob@external", { count: 42 }, { decisionId: "d-fixed-2" }); + expect(res.ok).toBe(true); + expect((res as { decisionId: string }).decisionId).toBe("d-fixed-2"); + await tick(); + + expect(dispatchedDecision).toBeDefined(); + expect(dispatchedDecision!.op).toBe("post_message"); + expect(dispatchedDecision!.entityBindingRef).toBe("bob@external"); + expect(dispatchedDecision!.kind).toBe("outbound_decision"); + expect(dispatchedDecision!.decisionId).toBe("d-fixed-2"); + expect((dispatchedDecision!.payload as any).frozenPayload).toBe("persisted"); + expect((dispatchedDecision!.payload as any).count).toBe(42); + }); + + it("refuses unadvertised original op before prepare or send is ever executed", async () => { + let prepareCalled = false; + let sendCalled = false; + const wire = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: () => { + prepareCalled = true; + }, + deliver: async () => { + sendCalled = true; + return { ok: true }; + }, + }); + + const res = wire.dispatcher.dispatch("unsupported_op", "alice@external", {}); + expect(res.ok).toBe(false); + expect((res as { error: string }).error).toContain("not advertised"); + expect(prepareCalled).toBe(false); + expect(sendCalled).toBe(false); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + }); + }); }); diff --git a/packages/daemon/test/queue-list-delivery-ledger.test.ts b/packages/daemon/test/queue-list-delivery-ledger.test.ts index e0fac5cdc..b66728445 100644 --- a/packages/daemon/test/queue-list-delivery-ledger.test.ts +++ b/packages/daemon/test/queue-list-delivery-ledger.test.ts @@ -214,4 +214,89 @@ describe("queue list: delivery ledgers", () => { expect(awaiting.length).toBeGreaterThan(0); expect(listed.map((item) => item.waiting)).toEqual(listed.map((item) => repo.waitingView(item.qitemId))); }); + + it("distinguishes relay-accepted asks and updates ('accepted by the bridge') from Slack-posted asks and updates ('posted to the human') in waitingView and getById", async () => { + const relayAsk = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Approve bridge migration?", + summary: "Approve bridge migration?", + humanIntent: "decision", + nudge: false, + }); + const relayAskKey = latestKey(relayAsk.qitemId); + repo.recordRelayOwnerNotificationAccepted({ + qitemId: relayAsk.qitemId, + notificationKey: relayAskKey, + deliveryId: "dec-relay-ask-1", + connectorRef: "wa-primary", + replyRef: "rref-ask-1", + revision: repo.decisionRevision(relayAsk), + episodeKey: relayAskKey, + level: "ALERT", + kind: "human-required", + }); + + const relayUpdate = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Bridge migration complete.", + summary: "Bridge migration complete", + humanIntent: "update", + nudge: false, + }); + const relayUpdateKey = latestKey(relayUpdate.qitemId); + repo.recordRelayOwnerNotificationAccepted({ + qitemId: relayUpdate.qitemId, + notificationKey: relayUpdateKey, + deliveryId: "dec-relay-update-1", + connectorRef: "wa-primary", + replyRef: null, + episodeKey: relayUpdateKey, + level: "NOTICE", + kind: "human-update", + closeUpdate: false, + }); + + const slackAsk = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Approve Slack migration?", + summary: "Approve Slack migration?", + humanIntent: "decision", + nudge: false, + }); + receipt(slackAsk.qitemId, "posted"); + + const slackUpdate = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Slack migration complete.", + summary: "Slack migration complete", + humanIntent: "update", + nudge: false, + }); + receipt(slackUpdate.qitemId, "posted"); + + const expectedRelayAsk = "none (accepted by the bridge; awaiting their decision)"; + const expectedRelayUpdate = "none (accepted by the bridge; informational correspondence requires no claim)"; + const expectedSlackAsk = "none (posted to the human; awaiting their decision)"; + const expectedSlackUpdate = "none (posted to the human; informational correspondence requires no claim)"; + + expect(repo.waitingView(relayAsk.qitemId)?.nextBackstop.mechanism).toBe(expectedRelayAsk); + expect(repo.getById(relayAsk.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedRelayAsk); + expect(repo.getById(relayAsk.qitemId)?.deliveryOutcome).toBe("accepted"); + + expect(repo.waitingView(relayUpdate.qitemId)?.nextBackstop.mechanism).toBe(expectedRelayUpdate); + expect(repo.getById(relayUpdate.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedRelayUpdate); + expect(repo.getById(relayUpdate.qitemId)?.deliveryOutcome).toBe("accepted"); + + expect(repo.waitingView(slackAsk.qitemId)?.nextBackstop.mechanism).toBe(expectedSlackAsk); + expect(repo.getById(slackAsk.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedSlackAsk); + expect(repo.getById(slackAsk.qitemId)?.deliveryOutcome).toBe("posted"); + + expect(repo.waitingView(slackUpdate.qitemId)?.nextBackstop.mechanism).toBe(expectedSlackUpdate); + expect(repo.getById(slackUpdate.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedSlackUpdate); + expect(repo.getById(slackUpdate.qitemId)?.deliveryOutcome).toBe("posted"); + }); }); diff --git a/packages/daemon/test/queue-wake-ladder.test.ts b/packages/daemon/test/queue-wake-ladder.test.ts index e751a8a22..d025b7410 100644 --- a/packages/daemon/test/queue-wake-ladder.test.ts +++ b/packages/daemon/test/queue-wake-ladder.test.ts @@ -275,6 +275,25 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension expect(calls.some((c) => c.target === "worker@r")).toBe(false); }); + it("DELIVERY MACHINERY IS NOT PICKUP: a relay-owner-notification-transport-failed note on an unconfirmed or held baton does not count as pickup evidence", async () => { + const mod = await ladderMod(); + for (const [idx, result] of ["gateway-owned: virtual destination", "retained:typing_guard"].entries()) { + const baton = await mkBaton(`worker-${idx}@r`); + setNudgeResult(baton.qitemId, result, 45); + ageCreated(baton.qitemId, 45); + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-transport-failed notification_key=${baton.qitemId}:1 connector_ref=wa-main class=http-503 error=http 503`, + }); + expect(mod.readWakeLadderBackstop(db, baton.qitemId)).not.toBeNull(); + await tick(); + expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(1); + expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)[0]).toMatch(/orchestrator/); + } + }); + // ── G4: F2 — SUSPENSION DERIVES FROM THE SWAP STATE ────────────────────────── it("SWAP SUSPENDS THE LADDER: a destination inside the post-swap grace gets ZERO wake attempts and one recorded suspend marker (the stale-wake-burst specimen)", async () => { @@ -507,6 +526,62 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX).some((r) => /dispatched-to-engine/.test(r))).toBe(true); }); + it("OPERATOR RUNG RELAY RECEIPT: pending dispatch resolves on matching post-marker relay-owner-notification-accepted", async () => { + const baton = await exhaustToSelfSkip(); + const notificationKey = "notif-key-123"; + let dispatchCount = 0; + const pendingEngine = { + dispatchEscalation: async (row: QueueItem) => { + dispatchCount++; + return { decision: "post", resolved: false, dispatched: true, notificationKey }; + }, + }; + + // Pre-marker receipt in history (simulates receipt from a previous episode before dispatch): + // Must NOT resolve the upcoming dispatch. + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-accepted notification_key=${notificationKey} delivery_id=d-pre`, + }); + + // 1. Initial tick with pending deliveryEngine outcome (resolved: false). + await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine }); + expect(dispatchCount).toBe(1); + expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX).some((r) => /dispatched-to-engine.*notification_key=notif-key-123/.test(r))).toBe(true); + // Pre-marker matching receipt did not resolve it; ladder not exhausted yet, underlying baton remains pending. + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(0); + expect(repo.getById(baton.qitemId)!.state).toBe("pending"); + + // 2. Wrong key post-marker relay receipt -> no resolution. + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: "relay-owner-notification-accepted notification_key=wrong-key delivery_id=d-1", + }); + ageMarkers(baton.qitemId, 30); + await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine }); + expect(dispatchCount).toBe(1); // No second dispatch/resume! + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(0); + + // 3. Matching post-marker relay receipt -> exhausts operator rung without second dispatch and without closing pending work. + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-accepted notification_key=${notificationKey} delivery_id=d-2`, + }); + ageMarkers(baton.qitemId, 30); + await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine }); + expect(dispatchCount).toBe(1); // Exactly-once dispatch preserved + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(1); + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)[0]).toContain("engine outcome resolved"); + // Underlying blocked/pending work is NOT closed/cancelled + expect(repo.getById(baton.qitemId)!.state).toBe("pending"); + }); + it("HUMAN RUNG EXPLICIT-ONLY: an agent-to-agent baton ends at the escalation row and nothing is posted to a person", async () => { const baton = await exhaustToSelfSkip(); const posted: string[] = []; diff --git a/packages/daemon/test/quiet-hold-custody.test.ts b/packages/daemon/test/quiet-hold-custody.test.ts index 5b467e161..98a334e93 100644 --- a/packages/daemon/test/quiet-hold-custody.test.ts +++ b/packages/daemon/test/quiet-hold-custody.test.ts @@ -38,17 +38,32 @@ describe("custody of asks held for quiet hours", () => { let failPosts = false; let rejectPosts = false; // the connector answers, but refuses the post const wires: Array> = []; + let useRelayBinding = false; + let relaySecretsPath = ""; const registry = () => ({ ok: true as const, entities: [{ entityId: "person", class: "human" as const, displayName: "Person", address: "person@external", prefs, - connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UPERSON" }], + connectorBindings: useRelayBinding + ? [{ kind: "relay" as const, connectorRef: "relay-primary", secretsRef: `file:${relaySecretsPath}`, role: "primary" as const, handle: "person_relay" }] + : [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UPERSON" }], }] }); beforeEach(() => { db = createDb(); migrate(db, ALL_MIGRATIONS); prefs = { deliveryClass: "B", availability: "available" }; + useRelayBinding = false; repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: registry as never }); home = mkdtempSync(join(tmpdir(), "quiet-custody-")); + relaySecretsPath = join(home, "relay.env"); + writeFileSync( + relaySecretsPath, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44199/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); quiet = new HumanQuietStore(db); posts.length = 0; failPosts = false; @@ -69,6 +84,12 @@ describe("custody of asks held for quiet hours", () => { if (rejectPosts) return new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ ok: true, ts: `1724.${100 + posts.length}` }), { status: 200, headers: { "content-type": "application/json" } }); } + if (String(url).includes("127.0.0.1:44199/outbound")) { + if (failPosts) throw new Error("relay bridge down before the post"); + posts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + if (rejectPosts) return new Response(JSON.stringify({ ok: false, error: "bridge_rejected" }), { status: 503, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }); + } return new Response(JSON.stringify({ ok: true, messages: [] }), { status: 200, headers: { "content-type": "application/json" } }); }, }); @@ -438,4 +459,90 @@ describe("custody of asks held for quiet hours", () => { expect(findingFor(r, digest).length).toBeGreaterThan(0); expect(findingWhy(digest)).not.toMatch(/quiet hours/); }); + + it("Relay quiet release failure yields release-failed, named in waitingView and sweep, and the ladder takes it up", async () => { + useRelayBinding = true; + const failedRow = await held(); + asBaton(failedRow); + quiet.end("person", { endedBy: "t", now: new Date() }); + rejectPosts = true; + const w = wires[0]!; + const [job] = releaseJobs(); + await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: job!.jobId, + registry: { loadHumanRegistry: registry as never }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }); + await settle(); + expect(posts).toHaveLength(1); + expect( + repo.listTransitions(failedRow.qitemId).some((t) => + (t.transitionNote ?? "").startsWith("relay-owner-notification-transport-failed "), + ), + ).toBe(true); + expect(quietHoldCustody(db, failedRow.qitemId)).toMatchObject({ + state: "release-failed", + failure: "http-503", + }); + expect(String((repo.waitingView(failedRow.qitemId)!.nextBackstop as { note?: string }).note)).toMatch( + /^Released when quiet ended, but its post failed \(http-503\); the release stays queued for redelivery\./, + ); + const rFailed = await sweep(); + expect(findingFor(rFailed, failedRow)).toHaveLength(1); + expect(findingFor(rFailed, failedRow)[0]!.kind).toBe("undelivered-human-notice"); + expect(findingWhy(failedRow)).toMatch( + /released after quiet hours; its post failed \(http-503\); the release stays queued for redelivery/, + ); + await ladder(); + expect(ladderTouched(failedRow)).toBe(true); + }); + + it("Relay bridge acceptance resolves quiet-release ownership so the release job retires and custody ends without becoming orphaned", async () => { + useRelayBinding = true; + const acceptedRow = await held(); + quiet.end("person", { endedBy: "t", now: new Date() }); + const w = wires[0]!; + const [job] = releaseJobs(); + // Pass 1 dispatches the release to the relay bridge, which accepts it + expect( + await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: job!.jobId, + registry: { loadHumanRegistry: registry as never }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }), + ).toMatchObject({ state: "pending", released: 1 }); + await settle(); + expect(posts).toHaveLength(1); + expect( + repo.listTransitions(acceptedRow.qitemId).some((t) => + (t.transitionNote ?? "").startsWith("relay-owner-notification-accepted "), + ), + ).toBe(true); + // Pass 2 sees the relay acceptance receipt via resolved(), retires the release job, and leaves custody null (never orphaned) + expect( + await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: job!.jobId, + registry: { loadHumanRegistry: registry as never }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }), + ).toMatchObject({ state: "released", released: 0 }); + expect(releaseJobs()).toHaveLength(0); + expect(new WatchdogJobsRepository(db).getById(job!.jobId)?.terminalReason).toBe( + "quiet release complete: every held ask posted, accepted by its bridge, or given a disposition", + ); + expect(quietHoldCustody(db, acceptedRow.qitemId)).toBeNull(); + expect(repo.waitingView(acceptedRow.qitemId)!.nextBackstop).toMatchObject({ + mechanism: "none (accepted by the bridge; awaiting their decision)", + }); + expect(findingFor(await sweep(), acceptedRow)).toEqual([]); + }); }); diff --git a/packages/daemon/test/quiet-hold-release.test.ts b/packages/daemon/test/quiet-hold-release.test.ts index cc55815b9..ad7da98d9 100644 --- a/packages/daemon/test/quiet-hold-release.test.ts +++ b/packages/daemon/test/quiet-hold-release.test.ts @@ -1098,3 +1098,243 @@ describe("after review: the outbound sweep when a dispatch throws", () => { expect(buffer.pending().filter((d) => (d.payload as { qitemId?: string }).qitemId === "q-1")).toHaveLength(1); }); }); + +describe("Capability 3 — relay-bound human reuses quiet hold/release, digest, and deferral policies", () => { + let db: Database.Database; + let repo: QueueRepository; + let home: string; + let quiet: HumanQuietStore; + let prefs: Record; + const relayPosts: Array> = []; + + function relayRegistry(p: Record, secretsFile: string) { + return { + ok: true as const, + entities: [ + { + entityId: HUMAN, + class: "human" as const, + displayName: "Founder", + address: `${HUMAN}@external`, + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-founder", + secretsRef: `file:${secretsFile}`, + role: "primary" as const, + handle: "founder_wa", + }, + ], + prefs: p, + }, + ], + }; + } + + beforeEach(() => { + db = createDb(); + migrate(db, ALL_MIGRATIONS); + home = mkdtempSync(join(tmpdir(), "quiet-relay-")); + const secretsFile = join(home, "relay.env"); + writeFileSync( + secretsFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44102/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + prefs = { deliveryClass: "B", availability: "available" }; + repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => relayRegistry(prefs, secretsFile), + }); + quiet = new HumanQuietStore(db); + relayPosts.length = 0; + }); + + afterEach(() => { + db.close(); + rmSync(home, { recursive: true, force: true }); + }); + + it("holds routine relay asks during quiet hours, releases them after quiet ends, and flushes digest asks with per-question replyRefs", async () => { + const secretsFile = join(home, "relay.env"); + const w = buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile), resolveSlackHandle }, + outboundIntervalMs: 60_000, + fetchImpl: async (_url, init) => { + relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + try { + w.startServices?.(); + const now = new Date(); + quiet.setUntil(HUMAN, { until: new Date(now.getTime() + 3_600_000), timezone: "UTC", setBy: "test", now }); + + const row = await repo.create({ + sourceSession: "dev-qa@r", + destinationSession: "orch-lead@r", + body: "Relay park body", + nudge: false, + }); + repo.update({ + qitemId: row.qitemId, + actorSession: "orch-lead@r", + state: "blocked", + blockedOn: `${HUMAN}@kernel`, + summary: "Approve relay park", + evidenceRef: "/proof/relay.md", + transitionNote: "parked", + }); + + const ports = makeQueuePorts(repo, { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) } as never); + const alert = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((a) => a.qitemId === row.qitemId)!; + + w.dispatcher.dispatch(OUTBOUND_OP, alert.destinationSession!, alert); + await settle(); + expect(relayPosts).toHaveLength(0); + expect(repo.listTransitions(row.qitemId).some((t) => (t.transitionNote ?? "").startsWith("delivery-decision: quiet-hold "))).toBe(true); + + // End quiet hours and run release -> delivers via relay transport and records relay-owner-notification-accepted + quiet.end(HUMAN, { endedBy: "test", now: new Date() }); + const jobs = new WatchdogJobsRepository(db).listActive().filter((j) => j.policy === QUIET_RELEASE_POLICY); + expect(jobs).toHaveLength(1); + const rel = await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: jobs[0]!.jobId, + now: new Date(), + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }); + expect(rel).toMatchObject({ released: 1 }); + await settle(); + expect(relayPosts).toHaveLength(1); + expect(relayPosts[0]).toMatchObject({ + v: 1, + recipient: { handle: "founder_wa" }, + intent: "decision", + outcome: "notify", + }); + expect(typeof relayPosts[0]!.replyRef).toBe("string"); + expect(repo.hasRelayOwnerNotificationReceipt(row.qitemId, alert.notificationKey!)).toBe(true); + } finally { + w.stop(); + } + }); + + it("reuses digest flush (with distinct per-question replyRefs) and away-escalation deferral for a relay-bound human", async () => { + const secretsFile = join(home, "relay.env"); + writeFileSync( + secretsFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44100/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + let prefs: Record = { deliveryClass: "C", availability: "available" }; + repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => relayRegistry(prefs, secretsFile), + }); + + const relayPosts: Array> = []; + const w = buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile), resolveSlackHandle }, + outboundIntervalMs: 60_000, + fetchImpl: async (_url, init) => { + relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + try { + w.startServices?.(); + const [r1, r2] = [ + await repo.create({ sourceSession: "dev@r", destinationSession: "orch@r", body: "Digest ask 1", nudge: false }), + await repo.create({ sourceSession: "dev@r", destinationSession: "orch@r", body: "Digest ask 2", nudge: false }), + ]; + repo.update({ qitemId: r1.qitemId, actorSession: "orch@r", state: "blocked", blockedOn: `${HUMAN}@kernel`, summary: "Ask 1", evidenceRef: "/proof/1.md", transitionNote: "parked" }); + repo.update({ qitemId: r2.qitemId, actorSession: "orch@r", state: "blocked", blockedOn: `${HUMAN}@kernel`, summary: "Ask 2", evidenceRef: "/proof/2.md", transitionNote: "parked" }); + + const ports = makeQueuePorts(repo, { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) } as never); + const alerts = await ports.listHumanAlerts({ minimumLevel: "NOTICE" }); + const a1 = alerts.find((a) => a.qitemId === r1.qitemId)!; + const a2 = alerts.find((a) => a.qitemId === r2.qitemId)!; + + // Dispatch both as Class C available -> recorded into 4h digest, not posted immediately + w.dispatcher.dispatch(OUTBOUND_OP, a1.destinationSession!, a1); + w.dispatcher.dispatch(OUTBOUND_OP, a2.destinationSession!, a2); + await settle(); + expect(relayPosts).toHaveLength(0); + + // Flush 4h digest -> dispatches both asks as outcome="digest" with distinct per-question replyRefs + const flushed = await runDeliveryDigestFlush({ + queueRepo: repo, + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) }, + home, + window: "4h", + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }); + await settle(); + expect(flushed.dispatched).toBe(2); + expect(relayPosts).toHaveLength(2); + expect(relayPosts[0]).toMatchObject({ + v: 1, + recipient: { handle: "founder_wa" }, + intent: "decision", + outcome: "digest", + }); + expect(relayPosts[1]).toMatchObject({ + v: 1, + recipient: { handle: "founder_wa" }, + intent: "decision", + outcome: "digest", + }); + expect(typeof relayPosts[0]!.replyRef).toBe("string"); + expect(typeof relayPosts[1]!.replyRef).toBe("string"); + expect(relayPosts[0]!.replyRef).not.toBe(relayPosts[1]!.replyRef); + expect(repo.hasRelayOwnerNotificationReceipt(r1.qitemId, a1.notificationKey!)).toBe(true); + expect(repo.hasRelayOwnerNotificationReceipt(r2.qitemId, a2.notificationKey!)).toBe(true); + + // Switch human to Class B + away, and dispatch an escalation -> deferred for 30 minutes + prefs = { deliveryClass: "B", availability: "away" }; + const urgent = await repo.create({ + sourceSession: "dev@r", + destinationSession: "orch@r", + body: "Urgent away ask", + tags: ["escalation"], + nudge: false, + }); + repo.update({ + qitemId: urgent.qitemId, + actorSession: "orch@r", + state: "blocked", + blockedOn: `${HUMAN}@kernel`, + summary: "Urgent ask", + evidenceRef: "/proof/urgent.md", + transitionNote: "parked", + }); + const urgentAlert = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((a) => a.qitemId === urgent.qitemId)!; + w.dispatcher.dispatch(OUTBOUND_OP, urgentAlert.destinationSession!, urgentAlert); + await settle(); + expect(relayPosts).toHaveLength(2); + expect( + new WatchdogJobsRepository(db).listActive().some((j) => JSON.stringify(j).includes(urgent.qitemId)), + ).toBe(true); + } finally { + w.stop(); + } + }); +}); diff --git a/packages/daemon/test/relay-config.test.ts b/packages/daemon/test/relay-config.test.ts new file mode 100644 index 000000000..1926c9fdf --- /dev/null +++ b/packages/daemon/test/relay-config.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { stringify as stringifyYaml } from "yaml"; +import { + addHumanFragment, + projectionPath, + type HumanFragment, +} from "../src/domain/gateway/human-registry.js"; +import { + loadRelayProfiles, + validateRelayUrl, + parseRelaySecretsRef, + authenticateRelayInboundToken, +} from "../src/domain/gateway/relay-config.js"; + +function relayHuman( + entityId: string, + connectorRef: string, + secretsRef: string, + handle: string = entityId, +): Record { + return { + entityId, + class: "human", + displayName: entityId, + address: `${entityId}@external`, + connectorBindings: [ + { + kind: "relay", + connectorRef, + secretsRef, + role: "primary", + handle, + }, + ], + prefs: { deliveryClass: "B" }, + }; +} + +describe("relay-config: URL and secretsRef validation", () => { + it("accepts file: and rejects relative or non-file secretsRef", () => { + expect(parseRelaySecretsRef("file:/etc/openrig/relay.env")).toEqual({ + ok: true, + envFile: "/etc/openrig/relay.env", + }); + expect(parseRelaySecretsRef("/etc/openrig/relay.env").ok).toBe(false); + expect(parseRelaySecretsRef("file:relative/relay.env").ok).toBe(false); + expect(parseRelaySecretsRef("vault://slack/main").ok).toBe(false); + expect(parseRelaySecretsRef("file://localhost/etc/relay.env").ok).toBe(false); + }); + + it("validates http loopback URLs (127.0.0.0/8, ::1, localhost) and rejects non-http, non-loopback, or embedded credentials without echoing secrets", () => { + expect(validateRelayUrl("http://127.0.0.1:8787/relay").ok).toBe(true); + expect(validateRelayUrl("http://127.255.0.1:9000/hook").ok).toBe(true); + expect(validateRelayUrl("http://[::1]:8787/relay").ok).toBe(true); + expect(validateRelayUrl("http://localhost:8787/relay").ok).toBe(true); + + expect(validateRelayUrl("https://127.0.0.1:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://0.0.0.0:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://192.168.1.10:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://example.com:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://2130706433:8787/relay").ok).toBe(false); + + const credUrl = validateRelayUrl("http://admin:ultra-secret-pass@127.0.0.1:8787/relay"); + expect(credUrl.ok).toBe(false); + if (!credUrl.ok) { + expect(credUrl.reason).toMatch(/credentials/i); + expect(credUrl.reason).not.toContain("ultra-secret-pass"); + expect(credUrl.reason).not.toContain("admin"); + } + }); +}); + +describe("relay-config: loadRelayProfiles(home)", () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "relay-cfg-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("handles an empty human registry (noHumans) cleanly as ok with zero relay profiles", () => { + const loaded = loadRelayProfiles(home); + expect(loaded.ok).toBe(true); + expect(loaded.registryError).toBeNull(); + expect(loaded.profiles.size).toBe(0); + }); + + it("reports registryError when the human registry projection is corrupt", () => { + const envFile = join(home, "relay.env"); + writeFileSync( + envFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-1\nOPENRIG_RELAY_INBOUND_TOKEN=in-1\n", + { mode: 0o600 }, + ); + expect(addHumanFragment(relayHuman("mike", "bridge-1", `file:${envFile}`), home).ok).toBe(true); + writeFileSync(projectionPath(home), "corrupted: hand-edited\n"); + + const loaded = loadRelayProfiles(home); + expect(loaded.ok).toBe(false); + expect(loaded.registryError).toMatch(/HAND-EDITED|DRIFT/i); + }); + + it("loads a valid 0600 relay profile, exposes private credentials only via non-enumerable property, and never serializes secrets", () => { + const envFile = join(home, "relay.env"); + writeFileSync( + envFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out", + "OPENRIG_RELAY_OUTBOUND_TOKEN=secret-outbound-token-xyz", + "OPENRIG_RELAY_INBOUND_TOKEN=secret-inbound-token-abc", + ].join("\n"), + { mode: 0o600 }, + ); + expect(addHumanFragment(relayHuman("mike", "bridge-1", `file:${envFile}`, "mike-alias"), home).ok).toBe(true); + + const loaded = loadRelayProfiles(home); + expect(loaded.ok).toBe(true); + const profile = loaded.get("bridge-1"); + expect(profile).toBeDefined(); + expect(profile!.ready).toBe(true); + expect(profile!.state).toBe("ready"); + expect(profile!.configured).toBe(true); + expect(profile!.endpointUrl).toBe("http://127.0.0.1:8787/out"); + expect(profile!.credentials).toEqual({ + url: "http://127.0.0.1:8787/out", + outboundToken: "secret-outbound-token-xyz", + inboundToken: "secret-inbound-token-abc", + }); + + const json = JSON.stringify({ loaded, profile }); + const yaml = stringifyYaml(profile); + expect(json).not.toContain("secret-outbound-token-xyz"); + expect(json).not.toContain("secret-inbound-token-abc"); + expect(yaml).not.toContain("secret-outbound-token-xyz"); + expect(yaml).not.toContain("secret-inbound-token-abc"); + expect(Object.keys(profile!)).not.toContain("credentials"); + + const authOk = authenticateRelayInboundToken(loaded, "secret-inbound-token-abc"); + expect(authOk.ok).toBe(true); + if (authOk.ok) expect(authOk.connectorRef).toBe("bridge-1"); + + expect(authenticateRelayInboundToken(loaded, "secret-outbound-token-xyz").ok).toBe(false); + expect(authenticateRelayInboundToken(loaded, "wrong").ok).toBe(false); + }); + + it("warns on group/world accessible secret file or identical directional tokens while keeping the profile ready (0600 and 0400 produce no warning), and still rejects missing keys/files without falling back to process.env", () => { + const mode0400File = join(home, "mode0400.env"); + writeFileSync( + mode0400File, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=o-400\nOPENRIG_RELAY_INBOUND_TOKEN=i-400\n", + { mode: 0o400 }, + ); + chmodSync(mode0400File, 0o400); + + const insecureFile = join(home, "insecure.env"); + writeFileSync( + insecureFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=o1-secret\nOPENRIG_RELAY_INBOUND_TOKEN=i1-secret\n", + { mode: 0o644 }, + ); + chmodSync(insecureFile, 0o644); + + const equalTokensFile = join(home, "equal.env"); + writeFileSync( + equalTokensFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=same-secret-tok\nOPENRIG_RELAY_INBOUND_TOKEN=same-secret-tok\n", + { mode: 0o600 }, + ); + + const missingKeyFile = join(home, "missing-key.env"); + writeFileSync( + missingKeyFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=only-out\n", + { mode: 0o600 }, + ); + + const savedUrl = process.env.OPENRIG_RELAY_URL; + const savedOut = process.env.OPENRIG_RELAY_OUTBOUND_TOKEN; + const savedIn = process.env.OPENRIG_RELAY_INBOUND_TOKEN; + process.env.OPENRIG_RELAY_URL = "http://127.0.0.1:9999/env-fallback"; + process.env.OPENRIG_RELAY_OUTBOUND_TOKEN = "env-out-token"; + process.env.OPENRIG_RELAY_INBOUND_TOKEN = "env-in-token"; + + try { + expect(addHumanFragment(relayHuman("u0", "r-0400", `file:${mode0400File}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u1", "r-insecure", `file:${insecureFile}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u2", "r-equal", `file:${equalTokensFile}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u3", "r-missing-key", `file:${missingKeyFile}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u4", "r-missing-file", `file:${join(home, "nope.env")}`), home).ok).toBe(true); + + const loaded = loadRelayProfiles(home); + + // 0400 has zero group/other bits -> ready with zero warnings + const p0400 = loaded.get("r-0400")!; + expect(p0400.ready).toBe(true); + expect(p0400.warnings).toEqual([]); + expect(p0400.reason).not.toMatch(/warning|insecure/i); + + // 0644 has group/other bits -> warns like Slack, does NOT make profile unready + const pInsecure = loaded.get("r-insecure")!; + expect(pInsecure.ready).toBe(true); + expect(pInsecure.state).toBe("ready"); + expect(pInsecure.credentials).toEqual({ + url: "http://127.0.0.1:8787/out", + outboundToken: "o1-secret", + inboundToken: "i1-secret", + }); + expect(pInsecure.warnings.length).toBeGreaterThan(0); + expect(pInsecure.warnings[0]).toMatch(/0644/); + expect(pInsecure.warnings[0]).toMatch(/group\/other/i); + expect(pInsecure.reason).toMatch(/warning.*0644/i); + expect(pInsecure.reason).not.toContain("o1-secret"); + expect(pInsecure.reason).not.toContain("i1-secret"); + + // Same token in both directions -> allowed and ready, with a useful redacted warning + const pEqual = loaded.get("r-equal")!; + expect(pEqual.ready).toBe(true); + expect(pEqual.state).toBe("ready"); + expect(pEqual.credentials).toEqual({ + url: "http://127.0.0.1:8787/out", + outboundToken: "same-secret-tok", + inboundToken: "same-secret-tok", + }); + expect(pEqual.warnings.length).toBeGreaterThan(0); + expect(pEqual.warnings[0]).toMatch(/OPENRIG_RELAY_OUTBOUND_TOKEN.*OPENRIG_RELAY_INBOUND_TOKEN|separate/i); + expect(pEqual.reason).toMatch(/warning/i); + expect(pEqual.reason).not.toContain("same-secret-tok"); + expect(authenticateRelayInboundToken(loaded, "same-secret-tok")).toMatchObject({ + ok: true, + connectorRef: "r-equal", + }); + + // Missing key or missing file still not-ready + expect(loaded.get("r-missing-key")!.ready).toBe(false); + expect(loaded.get("r-missing-key")!.reason).toMatch(/OPENRIG_RELAY_INBOUND_TOKEN/); + expect(loaded.get("r-missing-key")!.credentials).toBeNull(); + + expect(loaded.get("r-missing-file")!.ready).toBe(false); + expect(loaded.get("r-missing-file")!.reason).toMatch(/missing or unreadable/i); + expect(loaded.get("r-missing-file")!.credentials).toBeNull(); + } finally { + if (savedUrl === undefined) delete process.env.OPENRIG_RELAY_URL; + else process.env.OPENRIG_RELAY_URL = savedUrl; + if (savedOut === undefined) delete process.env.OPENRIG_RELAY_OUTBOUND_TOKEN; + else process.env.OPENRIG_RELAY_OUTBOUND_TOKEN = savedOut; + if (savedIn === undefined) delete process.env.OPENRIG_RELAY_INBOUND_TOKEN; + else process.env.OPENRIG_RELAY_INBOUND_TOKEN = savedIn; + } + }); + + it("enforces consistent shared profiles across people and rejects ambiguous reused inbound tokens across refs", () => { + const fileA = join(home, "a.env"); + const fileB = join(home, "b.env"); + const fileC = join(home, "c.env"); + writeFileSync( + fileA, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/a\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-a\nOPENRIG_RELAY_INBOUND_TOKEN=dup-in-secret\n", + { mode: 0o600 }, + ); + writeFileSync( + fileB, + "OPENRIG_RELAY_URL=http://127.0.0.1:8788/b\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-b\nOPENRIG_RELAY_INBOUND_TOKEN=dup-in-secret\n", + { mode: 0o600 }, + ); + writeFileSync( + fileC, + "OPENRIG_RELAY_URL=http://localhost:8789/c\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-c\nOPENRIG_RELAY_INBOUND_TOKEN=unique-in-secret\n", + { mode: 0o600 }, + ); + + const h1 = relayHuman("mike", "shared-inconsistent", `file:${fileA}`, "mike") as unknown as HumanFragment; + const h2 = relayHuman("dana", "shared-inconsistent", `file:${fileB}`, "dana") as unknown as HumanFragment; + const inconsistent = loadRelayProfiles(home, { entities: [h1, h2] }); + expect(inconsistent.get("shared-inconsistent")!.ready).toBe(false); + expect(inconsistent.get("shared-inconsistent")!.reason).toMatch(/inconsistent provisioning/i); + + const a1 = relayHuman("mike", "relay-a", `file:${fileA}`, "mike") as unknown as HumanFragment; + const b1 = relayHuman("dana", "relay-b", `file:${fileB}`, "dana") as unknown as HumanFragment; + const c1 = relayHuman("alex", "relay-c", `file:${fileC}`, "alex") as unknown as HumanFragment; + const c2 = relayHuman("sam", "relay-c", `file:${fileC}`, "sam") as unknown as HumanFragment; + const ambiguous = loadRelayProfiles(home, { entities: [a1, b1, c1, c2] }); + + expect(ambiguous.get("relay-a")!.ready).toBe(false); + expect(ambiguous.get("relay-a")!.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + expect(ambiguous.get("relay-a")!.reason).not.toContain("dup-in-secret"); + + expect(ambiguous.get("relay-b")!.ready).toBe(false); + expect(ambiguous.get("relay-b")!.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + + expect(ambiguous.get("relay-c")!.ready).toBe(true); + expect(ambiguous.get("relay-c")!.humans.map((h) => h.entityId)).toEqual(["alex", "sam"]); + expect(authenticateRelayInboundToken(ambiguous, "dup-in-secret").ok).toBe(false); + expect(authenticateRelayInboundToken(ambiguous, "unique-in-secret")).toMatchObject({ + ok: true, + connectorRef: "relay-c", + }); + }); +}); diff --git a/packages/daemon/test/relay-outbound.test.ts b/packages/daemon/test/relay-outbound.test.ts new file mode 100644 index 000000000..835ee7f39 --- /dev/null +++ b/packages/daemon/test/relay-outbound.test.ts @@ -0,0 +1,1580 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildInProcessWire } from "../src/domain/gateway/gateway-subsystem.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { OUTBOUND_OP, type OutboundPostPayload } from "../src/domain/gateway/slack/outbound-driver.js"; +import { SeenStore, type StateFsOps } from "../src/domain/gateway/slack/state-store.js"; +import type { OutboundDecision } from "../src/domain/gateway/protocol.js"; +import { + RELAY_PROTOCOL_VERSION, + encodeRelayReplyRef, + decodeRelayReplyRef, + hashRelayConnectorRef, + hashRelayReplyRef, + hashRelayReplyRefPayload, + validateRelayOutboundEnvelope, + findPendingRelayReplyRef, + resolvePrimaryRelayBinding, + prepareRelayDecision, + postRelayEnvelope, + createRelayOutboundTransport, + type RelayAcceptedReceiptInfo, + type RelayOutboundEnvelope, + type RelayPayloadCarry, + type RelayReplyRefPayload, +} from "../src/domain/gateway/relay-outbound.js"; +import { startRecordingRelay } from "./fixtures/recording-relay.js"; + +function memFs(): StateFsOps & { files: Map; failReadsFor: Set } { + const files = new Map(); + const failReadsFor = new Set(); + return { + files, + failReadsFor, + readFileSync: (p) => { + if (failReadsFor.has(p)) { + throw Object.assign(new Error(`EACCES: permission denied, open '${p}'`), { code: "EACCES" }); + } + if (!files.has(p)) { + throw Object.assign(new Error(`ENOENT: no such file or directory, open '${p}'`), { code: "ENOENT" }); + } + return files.get(p)!; + }, + appendFileSync: (p, d) => files.set(p, (files.get(p) ?? "") + d), + writeFileSync: (p, d) => files.set(p, d), + rename: (a, b) => { + files.set(b, files.get(a) ?? ""); + files.delete(a); + }, + mkdirp: () => {}, + }; +} + +const tick = () => new Promise((r) => setTimeout(r, 10)); + +/** Recording in-memory bridge fixture modeling operator bridge custody + deliveryId deduplication. */ +function recordingBridgeFixture(opts?: { + failFirstHttp?: boolean; + commitBeforeDropOnFirst?: boolean; + redirectTo?: string; + statusCode?: number; + responseBody?: string; +}) { + const custodyByDeliveryId = new Map(); + const attempts: Array<{ url: string; auth: string | null; envelope: RelayOutboundEnvelope }> = []; + let callCount = 0; + + const fetchImpl = async (url: string, init?: RequestInit): Promise => { + callCount++; + if (opts?.redirectTo) { + if (init?.redirect === "error") { + throw new TypeError("fetch failed: redirect mode is set to error"); + } + return new Response("", { status: 302, headers: { location: opts.redirectTo } }); + } + const headers = new Headers(init?.headers); + const auth = headers.get("authorization"); + const envelope = JSON.parse(String(init?.body ?? "{}")) as RelayOutboundEnvelope; + attempts.push({ url, auth, envelope }); + + if (opts?.failFirstHttp && callCount === 1) { + if (opts.commitBeforeDropOnFirst) { + // Bridge committed custody of deliveryId, but the HTTP response was lost in flight. + if (!custodyByDeliveryId.has(envelope.deliveryId)) { + custodyByDeliveryId.set(envelope.deliveryId, envelope); + } + } + throw new Error("socket hang up after bridge custody commit"); + } + + if (opts?.statusCode && opts.statusCode >= 400) { + return new Response(opts.responseBody ?? JSON.stringify({ error: "bridge_error" }), { + status: opts.statusCode, + headers: { "content-type": "application/json" }, + }); + } + + // Bridge deduplicates by deliveryId and commits custody before returning 2xx. + const deduplicated = custodyByDeliveryId.has(envelope.deliveryId); + if (!deduplicated) { + custodyByDeliveryId.set(envelope.deliveryId, envelope); + } + return new Response(JSON.stringify({ ok: true, deliveryId: envelope.deliveryId, deduplicated }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + return { + fetchImpl, + custodyByDeliveryId, + attempts, + callCount: () => callCount, + }; +} + +describe("Capability 3 — relay-outbound protocol v1, replyRef, and pure transport", () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "relay-outbound-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("encodes, decodes, validates, and hashes fixed-size opaque base64url replyRef tokens with closed schema for arbitrary high-entropy ID lengths", () => { + const payload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: "qitem-ask-1", + episodeKey: "qitem-ask-1:42", + decisionRevision: "dr1-abcdef0123456789abcdef01", + decisionId: "dec-101", + connectorRef: "wa-main", + mode: "park", + digest: true, + }; + const encoded = encodeRelayReplyRef(payload); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(encoded.length).toBe(266); + expect(encoded.length).toBeLessThanOrEqual(4096); + + const decoded = decodeRelayReplyRef(encoded); + expect(decoded).toEqual({ + ok: true, + ref: { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(payload), + connectorHash: hashRelayConnectorRef(payload.connectorRef), + mode: "park", + digest: true, + }, + }); + expect(hashRelayReplyRef(encoded)).toMatch(/^[0-9a-f]{64}$/); + + // High-entropy IDs (> 6,000 chars each) that cannot compress below 4,096 chars still produce the exact same constant token length + const highEntropyBlock = Array.from({ length: 200 }, (_, i) => + hashRelayReplyRef(`entropy-seed-${i}-${i * 7919}`), + ).join(""); + expect(highEntropyBlock.length).toBe(12_800); + const hugePayload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: `q-${highEntropyBlock}`, + episodeKey: `q-${highEntropyBlock}:99999`, + decisionRevision: "dr1-abcdef0123456789abcdef01", + decisionId: `dec-${highEntropyBlock}`, + connectorRef: `conn-${highEntropyBlock}`, + mode: "park", + digest: true, + }; + const hugeEncoded = encodeRelayReplyRef(hugePayload); + expect(hugeEncoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(hugeEncoded.length).toBe(encoded.length); + expect(hugeEncoded.length).toBeLessThanOrEqual(4096); + expect(decodeRelayReplyRef(hugeEncoded)).toEqual({ + ok: true, + ref: { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(hugePayload), + connectorHash: hashRelayConnectorRef(hugePayload.connectorRef), + mode: "park", + digest: true, + }, + }); + + // Closed schema: reject unknown keys, wrong version, bad hashes, bad mode, or non-base64url + const validTokenObj = { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(payload), + connectorHash: hashRelayConnectorRef(payload.connectorRef), + mode: "park", + digest: true, + }; + const withUnknownKey = Buffer.from(JSON.stringify({ ...validTokenObj, extra: "nope" }), "utf8").toString("base64url"); + expect(decodeRelayReplyRef(withUnknownKey).ok).toBe(false); + + const wrongVersion = Buffer.from(JSON.stringify({ ...validTokenObj, v: 2 }), "utf8").toString("base64url"); + expect(decodeRelayReplyRef(wrongVersion).ok).toBe(false); + + const shortHash = Buffer.from(JSON.stringify({ ...validTokenObj, payloadHash: "abc123" }), "utf8").toString("base64url"); + expect(decodeRelayReplyRef(shortHash).ok).toBe(false); + + expect(decodeRelayReplyRef("not valid json base64!").ok).toBe(false); + }); + + it("freezes protocol v1 closed envelope before enqueue: decision carries replyRef, update/notice has replyRef=null, digest carries per-question refs", () => { + const reg = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-primary", + secretsRef: "file:/tmp/wa.env", + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + + const items = new Map([ + ["q-park-1", { qitemId: "q-park-1", state: "blocked", humanIntent: "decision", revision: "dr1-rev-park-1", episodeKey: "q-park-1:7" }], + ["q-ask-2", { qitemId: "q-ask-2", state: "pending", humanIntent: "decision", revision: "dr1-rev-ask-2", episodeKey: "q-ask-2:3" }], + ["q-upd-1", { qitemId: "q-upd-1", state: "pending", humanIntent: "update", revision: "dr1-rev-upd-1", episodeKey: "q-upd-1:1" }], + ]); + + const prepareOpts = { + loadHumanRegistry: () => reg, + lookupQuestionState: (qitemId: string, fallbackKey?: string | null) => { + const found = items.get(qitemId); + if (!found) return null; + return { + qitemId, + mode: (found.state === "blocked" ? "park" : "ask") as "park" | "ask", + episodeKey: fallbackKey ?? found.episodeKey, + decisionRevision: found.revision, + humanIntent: found.humanIntent ?? null, + }; + }, + }; + + // 1. Decision ask/park -> replyRef non-null, structured questions rendered as plain text, secrets redacted + const parkDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-park-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-park-1", + notificationKey: "q-park-1:7", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + summary: "Approve release?", + body: "Token xoxb-1234567890-abcdef should be redacted.", + humanDetail: "Extra detail line.", + humanQuestions: [ + { + id: "q1", + question: "Which target?", + options: [ + { id: "staging", label: "Staging", recommended: true }, + { id: "prod", label: "Production" }, + ], + }, + ], + destinationSession: "sam@external", + sourceSession: "lead@rig", + state: "blocked", + } satisfies OutboundPostPayload, + }; + + const preparedPark = prepareRelayDecision(parkDecision, { ...prepareOpts, resolveOutcome: () => "interrupt" }); + const envPark = (preparedPark.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envPark).toBeDefined(); + expect(validateRelayOutboundEnvelope(envPark)).toEqual({ ok: true, envelope: envPark }); + expect(envPark.deliveryId).toBe("dec-park-1"); + expect(envPark.recipient).toEqual({ handle: "sam_wa" }); + expect(envPark.intent).toBe("decision"); + expect(envPark.outcome).toBe("interrupt"); + expect(envPark.body).toContain("[redacted-secret]"); + expect(envPark.body).not.toContain("xoxb-1234567890-abcdef"); + expect(envPark.body).toContain("Which target?"); + expect(envPark.body).toContain("Staging (recommended)"); + expect(envPark.body).toContain("Extra detail line."); + expect(envPark.replyRef).not.toBeNull(); + const decodedRef = decodeRelayReplyRef(envPark.replyRef!); + const expectedParkPayload: RelayReplyRefPayload = { + v: 1, + qitemId: "q-park-1", + episodeKey: "q-park-1:7", + decisionRevision: "dr1-rev-park-1", + decisionId: "dec-park-1", + connectorRef: "wa-primary", + mode: "park", + }; + expect(decodedRef).toEqual({ + ok: true, + ref: { + v: 1, + payloadHash: hashRelayReplyRefPayload(expectedParkPayload), + connectorHash: hashRelayConnectorRef("wa-primary"), + mode: "park", + }, + }); + expect((preparedPark.payload as { relayReplyRefs?: Record }).relayReplyRefs?.[envPark.replyRef!]).toEqual( + expectedParkPayload, + ); + + // 2. Informational update -> replyRef is strictly null + const updateDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-upd-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-upd-1", + notificationKey: "q-upd-1:1", + ownerNotificationKind: "human-update", + ownerNotificationLevel: "NOTICE", + humanIntent: "update", + summary: "FYI build green", + body: "All checks passed.", + destinationSession: "sam@external", + } satisfies OutboundPostPayload, + }; + const preparedUpdate = prepareRelayDecision(updateDecision, { ...prepareOpts, resolveOutcome: () => "notify" }); + const envUpdate = (preparedUpdate.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envUpdate.intent).toBe("update"); + expect(envUpdate.outcome).toBe("notify"); + expect(envUpdate.replyRef).toBeNull(); + + // 3. Multi-item digest -> top-level replyRef is null; per-question refs in items[] prevent one reply from approving several + const digestDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "digest:multi-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + deliveryDigestPost: true, + digestId: "multi-1", + qitemId: "q-park-1", + destinationSession: "sam@external", + summary: "Delivery digest (4h) — 2 item(s)", + body: "• Approve release? [q-park-1]\n• Confirm API? [q-ask-2]", + memberReceipts: [ + { qitemId: "q-park-1", notificationKey: "q-park-1:7", level: "ALERT", kind: "human-required" }, + { qitemId: "q-ask-2", notificationKey: "q-ask-2:3", level: "ALERT", kind: "human-required" }, + ], + }, + }; + const preparedDigest = prepareRelayDecision(digestDecision, { ...prepareOpts, resolveOutcome: () => "digest" }); + const envDigest = (preparedDigest.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envDigest.outcome).toBe("digest"); + expect(envDigest.replyRef, "multi-question digest top-level replyRef must be null so one reply cannot resolve both").toBeNull(); + expect(envDigest.items).toHaveLength(2); + expect(envDigest.items![0]!.replyRef).not.toBeNull(); + expect(envDigest.items![1]!.replyRef).not.toBeNull(); + expect(envDigest.items![0]!.replyRef).not.toBe(envDigest.items![1]!.replyRef); + const dRef1 = decodeRelayReplyRef(envDigest.items![0]!.replyRef!); + const dRef2 = decodeRelayReplyRef(envDigest.items![1]!.replyRef!); + const digestReplyRefs = (preparedDigest.payload as { relayReplyRefs?: Record }).relayReplyRefs!; + expect(dRef1.ok && dRef1.ref.digest === true && dRef1.ref.mode === "park").toBe(true); + expect(dRef2.ok && dRef2.ref.digest === true && dRef2.ref.mode === "ask").toBe(true); + expect(digestReplyRefs[envDigest.items![0]!.replyRef!]?.qitemId).toBe("q-park-1"); + expect(digestReplyRefs[envDigest.items![1]!.replyRef!]?.qitemId).toBe("q-ask-2"); + + // 4. Missing queue row or noncurrent episode NEVER mints a synthetic replyRef + const missingRowDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-missing-row", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-missing-row", + notificationKey: "q-missing-row:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + summary: "Missing queue row", + body: "Should not get a synthetic dr1 revision", + destinationSession: "sam@external", + } satisfies OutboundPostPayload, + }; + const preparedMissing = prepareRelayDecision(missingRowDecision, { + loadHumanRegistry: () => reg, + queueRepo: { + getById: () => null, + currentHumanRequiredEpisode: () => null, + decisionRevision: () => "dr1-never", + }, + resolveOutcome: () => "interrupt", + }); + const envMissing = (preparedMissing.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envMissing.replyRef, "unavailable/noncurrent question state must never mint a replyRef").toBeNull(); + }); + + it("recovers exact offered replyRef from pending DispatchBuffer before acceptance receipt lands", async () => { + let releaseDeliver: () => void = () => {}; + const gate = new Promise<{ ok: true }>((r) => { + releaseDeliver = () => r({ ok: true }); + }); + + const pendingPayload: RelayReplyRefPayload = { + v: 1, + qitemId: "q-pending-1", + episodeKey: "q-pending-1:2", + decisionRevision: "dr1-pending-rev-1", + decisionId: "dec-pending-1", + connectorRef: "wa-main", + mode: "park", + }; + const offeredRef = encodeRelayReplyRef(pendingPayload); + + const wire = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + prepare: (d) => ({ + ...d, + payload: { + ...(d.payload as object), + relayConnectorRef: "wa-main", + relayReplyRefs: { + [offeredRef]: pendingPayload, + }, + relayEnvelope: { + v: 1, + deliveryId: d.decisionId, + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Pending ask", + body: "Pending body", + replyRef: offeredRef, + } satisfies RelayOutboundEnvelope, + }, + }), + deliver: () => gate, + }); + + wire.dispatcher.dispatch(OUTBOUND_OP, "sam@external", { qitemId: "q-pending-1" }, { decisionId: "dec-pending-1" }); + + // Exact offered replyRef is recoverable from the pending buffer + const found = findPendingRelayReplyRef(home, offeredRef); + expect(found).toEqual({ + found: true, + decisionId: "dec-pending-1", + connectorRef: "wa-main", + qitemId: "q-pending-1", + episodeKey: "q-pending-1:2", + decisionRevision: "dr1-pending-rev-1", + mode: "park", + }); + + // Forged ref with valid structure but not actually offered is rejected + const forgedRef = encodeRelayReplyRef({ + v: 1, + qitemId: "q-pending-1", + episodeKey: "q-pending-1:2", + decisionRevision: "dr1-forged-rev", + decisionId: "dec-pending-1", + connectorRef: "wa-main", + mode: "park", + }); + expect(findPendingRelayReplyRef(home, forgedRef)).toEqual({ found: false }); + + releaseDeliver(); + await tick(); + expect(findPendingRelayReplyRef(home, offeredRef)).toEqual({ found: false }); + }); + + it("enforces loopback http endpoint, rejects redirects, bounds timeouts, and sanitizes secret tokens on error", async () => { + const sampleEnvelope: RelayOutboundEnvelope = { + v: 1, + deliveryId: "dec-sec-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Test", + body: "Body", + replyRef: null, + }; + + // 1. Non-loopback or non-http or URL credentials rejected without calling fetch + let fetchCalled = false; + const neverFetch = async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }; + const nonLoopback = await postRelayEnvelope({ + url: "http://192.168.1.50:8080/outbound", + outboundToken: "secret-outbound-token-12345", + envelope: sampleEnvelope, + fetchImpl: neverFetch, + }); + expect(nonLoopback.ok).toBe(false); + expect(nonLoopback.class).toBe("invalid-endpoint"); + expect(fetchCalled).toBe(false); + + const urlCreds = await postRelayEnvelope({ + url: "http://user:pass@127.0.0.1:8080/outbound", + outboundToken: "secret-outbound-token-12345", + envelope: sampleEnvelope, + fetchImpl: neverFetch, + }); + expect(urlCreds.ok).toBe(false); + expect(urlCreds.class).toBe("invalid-endpoint"); + expect(fetchCalled).toBe(false); + + // 2. Redirect rejected + const redirectBridge = recordingBridgeFixture({ redirectTo: "http://example.com/steal" }); + const redirected = await postRelayEnvelope({ + url: "http://127.0.0.1:9090/outbound", + outboundToken: "secret-outbound-token-12345", + envelope: sampleEnvelope, + fetchImpl: redirectBridge.fetchImpl, + }); + expect(redirected.ok).toBe(false); + + // 3. Error message or bridge HTTP response body is never echoed; fixed sanitized status/reason only + const secretToken = "relay-secret-token-9988776655"; + const leakyFetch = async () => { + throw new Error(`request failed with raw credential ${secretToken} and Authorization: Bearer ${secretToken}`); + }; + const leaked = await postRelayEnvelope({ + url: "http://localhost:9090/outbound", + outboundToken: secretToken, + envelope: sampleEnvelope, + fetchImpl: leakyFetch, + }); + expect(leaked.ok).toBe(false); + expect(leaked.detail).not.toContain(secretToken); + + const bridge502 = recordingBridgeFixture({ + statusCode: 502, + responseBody: `upstream failed with inboundToken=${secretToken} and extra details`, + }); + const badStatus = await postRelayEnvelope({ + url: "http://127.0.0.1:9090/outbound", + outboundToken: secretToken, + envelope: sampleEnvelope, + fetchImpl: bridge502.fetchImpl, + }); + expect(badStatus).toEqual({ + ok: false, + status: 502, + class: "http-502", + detail: "http 502", + }); + }); + + it("proves durable-before-network, stable replay on lost ACK with recording fake bridge, and no plaintext answers/secrets in SeenStore metadata", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + + const bridge = recordingBridgeFixture({ failFirstHttp: true, commitBeforeDropOnFirst: true }); + const receipts: RelayAcceptedReceiptInfo[] = []; + const failures: Array<{ failureClass: string; detail: string }> = []; + + let mutableRevision = "dr1-initial-rev-001"; + const reg = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-main", + secretsRef: "file:/tmp/wa.env", + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => + connectorRef === "wa-main" + ? { ok: true, connectorRef, url: "http://127.0.0.1:43110/outbound", outboundToken: "out-tok-secret-123456" } + : { ok: false, reason: "unknown profile" }, + onAccepted: (info) => { + receipts.push(info); + }, + onTransportFailed: (_p, failureClass, detail) => { + failures.push({ failureClass, detail }); + }, + }); + + const wire = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + prepare: (d) => + prepareRelayDecision(d, { + loadHumanRegistry: () => reg, + lookupQuestionState: (qitemId, fallbackKey) => ({ + qitemId, + mode: "park", + episodeKey: fallbackKey ?? `${qitemId}:1`, + decisionRevision: mutableRevision, + humanIntent: "decision", + }), + resolveOutcome: () => "interrupt", + }), + deliver: (d) => transport.deliver(d), + }); + + const payload: OutboundPostPayload = { + qitemId: "q-replay-1", + notificationKey: "q-replay-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + summary: "Deploy v2?", + body: "Sensitive body text with Bearer secret-token-abcdef123456", + destinationSession: "sam@external", + sourceSession: "lead@rig", + state: "blocked", + }; + + // Dispatch: prepare freezes envelope, enqueues to DispatchBuffer, then attempts HTTP 1 (which drops after bridge custody commit) + const disp = wire.dispatcher.dispatch(OUTBOUND_OP, "sam@external", payload, { decisionId: "dec-replay-1" }); + expect(disp.ok).toBe(true); + await tick(); + + // Bridge has custody of 1 message, but OpenRig lost the ACK -> retained in DispatchBuffer, attempted marked, NOT delivered + expect(bridge.custodyByDeliveryId.size).toBe(1); + expect(bridge.callCount()).toBe(1); + expect(attempted.load().has("dec-replay-1")).toBe(true); + expect(delivered.load().has("dec-replay-1")).toBe(false); + expect(receipts).toHaveLength(0); + expect(failures).toHaveLength(1); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Mutate current revision before replay to prove replay sends the EXACT frozen envelope and does NOT recompute refs + mutableRevision = "dr1-mutated-later-rev-999"; + + // Replay: sends identical deliveryId + identical replyRef; bridge deduplicates; receipt written; buffer drained + wire.startServices?.(); + await tick(); + + expect(bridge.callCount()).toBe(2); + expect(bridge.custodyByDeliveryId.size, "bridge deduplicates same deliveryId").toBe(1); + expect(bridge.attempts[1]!.envelope).toEqual(bridge.attempts[0]!.envelope); + expect(bridge.attempts[1]!.envelope.replyRef).toBe( + encodeRelayReplyRef({ + v: 1, + qitemId: "q-replay-1", + episodeKey: "q-replay-1:1", + decisionRevision: "dr1-initial-rev-001", + decisionId: "dec-replay-1", + connectorRef: "wa-main", + mode: "park", + }), + ); + + expect(receipts).toHaveLength(1); + expect(receipts[0]).toMatchObject({ + qitemId: "q-replay-1", + notificationKey: "q-replay-1:1", + connectorRef: "wa-main", + deliveryId: "dec-replay-1", + refHash: hashRelayReplyRef(bridge.attempts[0]!.envelope.replyRef!), + revision: "dr1-initial-rev-001", + }); + + expect(delivered.load().has("dec-replay-1")).toBe(true); + expect(outboundSeen.load().has("q-replay-1:1")).toBe(true); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + + // Verify no secrets or plaintext body/answers in SeenStore metadata files + for (const content of fsx.files.values()) { + expect(content).not.toContain("out-tok-secret-123456"); + expect(content).not.toContain("Sensitive body text"); + expect(content).not.toContain("secret-token-abcdef123456"); + } + }); + + it("retains decision in DispatchBuffer on profile failure, receipt write failure, or unreadable strict SeenStore", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + + let profileReady = false; + let failReceiptWrite = true; + const receipts: RelayAcceptedReceiptInfo[] = []; + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => + profileReady + ? { ok: true, connectorRef, url: "http://127.0.0.1:43110/outbound", outboundToken: "out-tok-valid-123456" } + : { ok: false, reason: "relay secrets file missing or permissions insecure" }, + onAccepted: (info) => { + if (failReceiptWrite) throw new Error("sqlite busy: receipt write failed"); + receipts.push(info); + }, + }); + + const decision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-retain-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-retain-1", + notificationKey: "q-retain-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + relayEnvelope: { + v: 1, + deliveryId: "dec-retain-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Check retention", + body: "Body", + replyRef: encodeRelayReplyRef({ + v: 1, + qitemId: "q-retain-1", + episodeKey: "q-retain-1:1", + decisionRevision: "dr1-ret-1", + decisionId: "dec-retain-1", + connectorRef: "wa-main", + mode: "park", + }), + } satisfies RelayOutboundEnvelope, + }, + }; + + const wire = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport.deliver(d), + }); + + // 1. Profile unready -> retained in buffer, zero HTTP calls + wire.dispatcher.dispatch(OUTBOUND_OP, "sam@external", decision.payload, { decisionId: decision.decisionId }); + await tick(); + expect(bridge.callCount()).toBe(0); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // 2. Profile ready, but receipt write throws after HTTP 200 -> retained in buffer, not marked delivered + profileReady = true; + wire.startServices?.(); + await tick(); + expect(bridge.callCount()).toBe(1); + expect(receipts).toHaveLength(0); + expect(delivered.load().has("dec-retain-1")).toBe(false); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // 3. Strict SeenStore unreadable -> retained in buffer, no blind action + fsx.failReadsFor.add("/state/relay-attempted-decisions.jsonl"); + const unreadableOutcome = await transport.deliver(decision); + expect(unreadableOutcome.ok).toBe(false); + expect(unreadableOutcome.class).toBe("relay-state-unreadable"); + fsx.failReadsFor.delete("/state/relay-attempted-decisions.jsonl"); + + // 4. Receipt write succeeds on next replay -> receipt persisted, buffer drained + failReceiptWrite = false; + const w2 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport.deliver(d), + }); + w2.startServices?.(); + await tick(); + expect(receipts).toHaveLength(1); + expect(delivered.load().has("dec-retain-1")).toBe(true); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + }); + + it("repairs failed outboundSeen mark/release on retry using durable receipt without reposting or leaving poll guard inconsistent", async () => { + const fsx = memFs(); + let failOutboundSeenAppend = true; + const baseAppend = fsx.appendFileSync.bind(fsx); + fsx.appendFileSync = (p, d) => { + if (failOutboundSeenAppend && p === "/state/slack-outbound-seen.jsonl") { + throw new Error("simulated disk error writing outbound-seen"); + } + baseAppend(p, d); + }; + + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + const acceptedKeys = new Set(); + const releasedKeys: string[] = []; + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + hasAcceptedReceipt: ({ qitemId, notificationKey }) => acceptedKeys.has(`${qitemId}:${notificationKey}`), + onAccepted: (info) => { + acceptedKeys.add(`${info.qitemId}:${info.notificationKey}`); + }, + release: (key) => { + releasedKeys.push(key); + }, + }); + + const decision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-seen-repair-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-seen-1", + notificationKey: "q-seen-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + relayEnvelope: { + v: 1, + deliveryId: "dec-seen-repair-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Check seen repair", + body: "Body", + replyRef: null, + } satisfies RelayOutboundEnvelope, + }, + }; + + // Attempt 1: HTTP POST + onAccepted succeed, but outboundSeen.mark throws -> must NOT mark delivered or ack + const first = await transport.deliver(decision); + expect(first.ok).toBe(false); + expect(bridge.callCount()).toBe(1); + expect(acceptedKeys.has("q-seen-1:q-seen-1:1")).toBe(true); + expect(outboundSeen.load().has("q-seen-1:1")).toBe(false); + expect(delivered.load().has("dec-seen-repair-1")).toBe(false); + expect(releasedKeys).toEqual([]); + + // Attempt 2: outboundSeen write repaired -> reconciles from durable receipt without a second HTTP POST + failOutboundSeenAppend = false; + const second = await transport.deliver(decision); + expect(second.ok).toBe(true); + expect(bridge.callCount(), "durable receipt reconciles metadata without repost").toBe(1); + expect(outboundSeen.load().has("q-seen-1:1")).toBe(true); + expect(delivered.load().has("dec-seen-repair-1")).toBe(true); + expect(releasedKeys).toEqual(["q-seen-1:1"]); + }); + + describe("resolvePrimaryRelayBinding exact address requirements", () => { + const reg = { + ok: true as const, + entities: [ + { + entityId: "alice", + class: "human" as const, + displayName: "Alice", + address: "alice@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-alice", + secretsRef: "file:/tmp/alice.env", + role: "primary" as const, + handle: "alice_relay", + }, + ], + prefs: { deliveryClass: "A" as const }, + }, + { + entityId: "bob", + class: "human" as const, + displayName: "Bob", + address: "bob@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-bob", + secretsRef: "file:/tmp/bob.env", + role: "primary" as const, + }, + ], + prefs: { deliveryClass: "A" as const }, + }, + ], + }; + + it("resolves valid stable registered person's address exactly", () => { + const resolved = resolvePrimaryRelayBinding("alice@external", reg); + expect(resolved).toEqual({ + entity: reg.entities[0], + connectorRef: "relay-alice", + handle: "alice_relay", + secretsRef: "file:/tmp/alice.env", + }); + }); + + it("allows exact bare entityId as fallback per existing caller contract", () => { + const resolved = resolvePrimaryRelayBinding("alice", reg); + expect(resolved).toEqual({ + entity: reg.entities[0], + connectorRef: "relay-alice", + handle: "alice_relay", + secretsRef: "file:/tmp/alice.env", + }); + }); + + it("rejects different domain / same-name agent / different rig destination", () => { + expect(resolvePrimaryRelayBinding("alice@different-rig", reg)).toBeNull(); + expect(resolvePrimaryRelayBinding("alice@some-rig", reg)).toBeNull(); + expect(resolvePrimaryRelayBinding("alice@externalx", reg)).toBeNull(); + }); + + it("rejects shared channel ref with #", () => { + expect(resolvePrimaryRelayBinding("alice#slack", reg)).toBeNull(); + expect(resolvePrimaryRelayBinding("alice#general", reg)).toBeNull(); + }); + + it("leaves binding without handle as outbound-only fallback entityId per current contract", () => { + const resolved = resolvePrimaryRelayBinding("bob@external", reg); + expect(resolved).toEqual({ + entity: reg.entities[1], + connectorRef: "relay-bob", + handle: "bob", + secretsRef: "file:/tmp/bob.env", + }); + }); + }); + + describe("R3 — single complete JSON envelope (> 64 KiB), full content preservation, whole-decision replay, and stopSignal handling", () => { + const reg = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: "file:/tmp/wa.env", + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + + it("freezes and POSTs a single complete > 64 KiB JSON envelope with full ASCII, multibyte CJK, emoji, summary overflow, humanDetail, and humanQuestions/options without multipart splitting or clipping", async () => { + const asciiBlock = "ASCII-LINE-0123456789-abcdefghijklmnopqrstuvwxyz\n".repeat(900) + "ASCII-TAIL-MARKER"; + const cjkBlock = "日本語と中国語と한국어の長文テスト検証データ".repeat(1400) + "CJK-TAIL-終端"; + const emojiBlock = "🚀👩‍💻🎉🔥🧪".repeat(1200) + "EMOJI-TAIL-🏁"; + const detailBlock = "DETAIL-SECTION-" + "D".repeat(12_000) + "-DETAIL-TAIL"; + + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-long-cjk-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-long-1", + notificationKey: "q-long-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + state: "blocked", + summary: "S".repeat(1200) + "-SUMMARY-TAIL", + body: `${asciiBlock}\n${cjkBlock}\n${emojiBlock}`, + humanDetail: detailBlock, + humanQuestions: [ + { + id: "q-rollout", + header: "Deployment Gate", + question: "Which rollout strategy should we execute for the multi-region cluster?", + options: [ + { + id: "canary", + label: "Canary 5%", + description: "Roll out to 5% of edge nodes first and monitor p99 latency for 30m", + recommended: true, + }, + { + id: "blue-green", + label: "Blue/Green Cutover", + description: "Switch full traffic to green standby with instant DNS rollback", + }, + ], + }, + ], + } satisfies OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: () => ({ + qitemId: "q-long-1", + mode: "park", + episodeKey: "q-long-1:1", + decisionRevision: "dr1-long-1", + humanIntent: "decision", + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + + const fullEnvelope = carry.relayEnvelope!; + expect(fullEnvelope).toBeDefined(); + expect(fullEnvelope.deliveryId).toBe("dec-long-cjk-1"); + expect((fullEnvelope as Record).part).toBeUndefined(); + expect(fullEnvelope.summary.length).toBeLessThanOrEqual(1000); + expect(fullEnvelope.summary).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 64 KiB whole-decision envelope across bridge and transport restart with identical deliveryId and bytes without duplicate custody", async () => { + const fsx = memFs(); + const relayDir = mkdtempSync(join(tmpdir(), "openrig-relay-whole-")); + const relay = await startRecordingRelay({ + custodyFilePath: join(relayDir, "bridge-state.jsonl"), + outboundToken: "out-tok-valid-123456", + inboundToken: "in-tok-valid-654321", + }); + + try { + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const receipts: RelayAcceptedReceiptInfo[] = []; + const releasedKeys: string[] = []; + + const cjkBody = + "第一段内容:多字节UTF-8编码边界与整包恢复测试。".repeat(950) + + "\nMIDDLE-MARKER-中間標記\n" + + "第二段内容:确保在整包丢失ACK后重启不会丢失尾部也不会重复接管。".repeat(950) + + "\nFINAL-TAIL-MARKER-最終尾部"; + + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-whole-ack-lost-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-whole-1", + notificationKey: "q-whole-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + state: "blocked", + summary: "Whole-decision lost ACK > 64 KiB test", + body: cjkBody, + } satisfies OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: () => ({ + qitemId: "q-whole-1", + mode: "park", + episodeKey: "q-whole-1:1", + decisionRevision: "dr1-whole-1", + humanIntent: "decision", + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + const frozenEnvelope = carry.relayEnvelope!; + expect(Buffer.byteLength(JSON.stringify(frozenEnvelope), "utf8")).toBeGreaterThan(65_536); + + // Fail HTTP ACK on the first whole-envelope POST AFTER the bridge persists custody + relay.setLoseNextAck(true); + + const transport1 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: fetch, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: relay.outboundUrl, + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + release: (key) => releasedKeys.push(key), + }); + + const wire1 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport1.deliver(d), + }); + + wire1.dispatcher.dispatch(OUTBOUND_OP, "sam@external", prepared.payload, { + decisionId: "dec-whole-ack-lost-1", + }); + for (let i = 0; i < 40 && relay.getSanitizedStats().lostAckCount === 0; i++) { + await tick(); + } + await tick(); + + // Single POST committed custody on bridge then returned 502 (lost ACK) + expect(relay.getSanitizedStats().relayPostCount).toBe(1); + expect(relay.getSanitizedStats().custodyCount).toBe(1); + expect(relay.getSanitizedStats().lostAckCount).toBe(1); + expect(receipts, "logical custody receipt must NOT fire when HTTP ACK was lost").toHaveLength(0); + expect(outboundSeen.load().has("q-whole-1:1")).toBe(false); + expect(delivered.load().has("dec-whole-ack-lost-1")).toBe(false); + expect(releasedKeys).toEqual([]); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Pending replyRef lookup correlates parent decisionId while buffered + const parentReplyRef = frozenEnvelope.replyRef!; + const pendingRef = findPendingRelayReplyRef(home, parentReplyRef); + expect(pendingRef.found).toBe(true); + expect(pendingRef.decisionId).toBe("dec-whole-ack-lost-1"); + + // Restart the recording relay server (reloads persisted custody from disk) and create fresh transport/wire + await relay.restart(); + + const transport2 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: fetch, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: relay.outboundUrl, + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + release: (key) => releasedKeys.push(key), + }); + + const wire2 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport2.deliver(d), + }); + wire2.startServices?.(); + for (let i = 0; i < 40 && receipts.length === 0; i++) { + await tick(); + } + await tick(); + + // Replay sent the exact same whole deliveryId and bytes, deduplicated cleanly by bridge + expect(relay.getSanitizedStats().custodyCount).toBe(1); + expect(relay.getSanitizedStats().deduplicatedCount).toBe(1); + expect(receipts).toHaveLength(1); + expect(receipts[0]?.deliveryId).toBe("dec-whole-ack-lost-1"); + expect(outboundSeen.load().has("q-whole-1:1")).toBe(true); + expect(delivered.load().has("dec-whole-ack-lost-1")).toBe(true); + expect(releasedKeys).toEqual(["q-whole-1:1"]); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + + const committedRecords = relay.getCustodyRecords(); + expect(committedRecords).toHaveLength(1); + const committed = committedRecords[0]!.envelope; + expect(committed.deliveryId).toBe("dec-whole-ack-lost-1"); + expect((committed as Record).part).toBeUndefined(); + expect(committed.body).toBe(cjkBody); + expect(committed.body).toContain("MIDDLE-MARKER-中間標記"); + expect(committed.body).toContain("FINAL-TAIL-MARKER-最終尾部"); + } finally { + await relay.stop(); + rmSync(relayDir, { recursive: true, force: true }); + } + }); + + it("delivers an oversized > 64 KiB multi-item digest in a single POST with full item content and records all member receipts on acceptance", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + const receipts: RelayAcceptedReceiptInfo[] = []; + + const item1Body = "DIGEST-ITEM-1-" + "甲乙丙丁戊己庚辛壬癸".repeat(2500) + "-ITEM-1-TAIL"; + const item2Body = "DIGEST-ITEM-2-" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ".repeat(1600) + "-ITEM-2-TAIL"; + + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-digest-long-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + deliveryDigestPost: true, + digestId: "long-1", + qitemId: "q-dig-1", + notificationKey: "digest:sam@external:long", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "INFO", + humanIntent: "update", + destinationSession: "sam@external", + summary: "Digest summary header", + body: `${item1Body}\n\n${item2Body}`, + memberReceipts: [ + { + qitemId: "q-dig-1", + notificationKey: "q-dig-1:1", + level: "ALERT", + kind: "human-required", + summary: "Item 1 summary", + body: item1Body, + }, + { + qitemId: "q-dig-2", + notificationKey: "q-dig-2:1", + level: "ALERT", + kind: "human-required", + summary: "Item 2 summary", + body: item2Body, + }, + ], + } as OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: (qitemId) => ({ + qitemId, + mode: "ask", + episodeKey: `${qitemId}:1`, + decisionRevision: `dr1-${qitemId}`, + humanIntent: "decision", + summary: qitemId === "q-dig-1" ? "Item 1 summary" : "Item 2 summary", + body: qitemId === "q-dig-1" ? item1Body : item2Body, + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + const digestEnvelope = carry.relayEnvelope!; + expect((digestEnvelope as Record).part).toBeUndefined(); + expect(Buffer.byteLength(JSON.stringify(digestEnvelope), "utf8")).toBeGreaterThan(65_536); + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + }); + + const outcome = await transport.deliver({ + kind: "outbound_decision", + decisionId: "dec-digest-long-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: prepared.payload, + }); + + expect(outcome.ok).toBe(true); + expect(bridge.callCount()).toBe(1); + expect(bridge.custodyByDeliveryId.size).toBe(1); + expect(receipts.map((r) => `${r.qitemId}:${r.notificationKey}`)).toEqual([ + "q-dig-1:q-dig-1:1", + "q-dig-2:q-dig-2:1", + ]); + + const deliveredEnvelope = bridge.custodyByDeliveryId.get("dec-digest-long-1")!; + expect(deliveredEnvelope).toBeDefined(); + expect((deliveredEnvelope as Record).part).toBeUndefined(); + expect(deliveredEnvelope.body).toContain("ITEM-1-TAIL"); + expect(deliveredEnvelope.body).toContain("ITEM-2-TAIL"); + expect(deliveredEnvelope.items).toHaveLength(2); + expect(deliveredEnvelope.items?.[0]?.body).toContain("ITEM-1-TAIL"); + expect(deliveredEnvelope.items?.[1]?.body).toContain("ITEM-2-TAIL"); + const ref1 = decodeRelayReplyRef(deliveredEnvelope.items?.[0]?.replyRef ?? ""); + const ref2 = decodeRelayReplyRef(deliveredEnvelope.items?.[1]?.replyRef ?? ""); + expect(ref1.ok && ref1.ref.digest === true && ref1.ref.mode === "ask").toBe(true); + expect(ref2.ok && ref2.ref.digest === true && ref2.ref.mode === "ask").toBe(true); + expect(receipts[0]?.replyRef).toBe(deliveredEnvelope.items?.[0]?.replyRef); + expect(receipts[1]?.replyRef).toBe(deliveredEnvelope.items?.[1]?.replyRef); + }); + + it("distinguishes stopSignal abort from genuine timeout on a whole > 64 KiB envelope: in-flight stop suppresses failure ledger, records no false acceptance, retains pending decision, replays identical ID/bytes on restart, while genuine timeout records failure", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + const receipts: RelayAcceptedReceiptInfo[] = []; + const failures: Array<{ failureClass: string; detail: string }> = []; + + const longBody = "STOP-RESUME-BLOCK-".repeat(4500) + "\nSTOP-RESUME-TAIL"; + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-stop-whole-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-stop-1", + notificationKey: "q-stop-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + state: "blocked", + summary: "Stop during in-flight whole-envelope send", + body: longBody, + } satisfies OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: () => ({ + qitemId: "q-stop-1", + mode: "park", + episodeKey: "q-stop-1:1", + decisionRevision: "dr1-stop-1", + humanIntent: "decision", + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + const frozenEnvelope = carry.relayEnvelope!; + const frozenBodyJson = JSON.stringify(frozenEnvelope); + expect(Buffer.byteLength(frozenBodyJson, "utf8")).toBeGreaterThan(65_536); + + const stopCtl1 = new AbortController(); + let postCalls = 0; + let firstAttemptRawBody = ""; + const stopInFlightFetch = async (_url: string, init?: RequestInit): Promise => { + postCalls += 1; + firstAttemptRawBody = typeof init?.body === "string" ? init.body : ""; + return await new Promise((_resolve, reject) => { + const sig = init?.signal; + const onAbort = () => { + reject(sig?.reason ?? new DOMException("The operation was aborted", "AbortError")); + }; + if (sig?.aborted) { + onAbort(); + return; + } + sig?.addEventListener("abort", onAbort, { once: true }); + // Simulate subsystem stop while the single whole-envelope HTTP POST is in flight + queueMicrotask(() => stopCtl1.abort(new Error("subsystem stop requested"))); + }); + }; + + const transport1 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: stopInFlightFetch, + stopSignal: stopCtl1.signal, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + onTransportFailed: (_p, failureClass, detail) => failures.push({ failureClass, detail }), + }); + + const wire1 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport1.deliver(d), + }); + + wire1.dispatcher.dispatch(OUTBOUND_OP, "sam@external", prepared.payload, { + decisionId: "dec-stop-whole-1", + }); + await tick(); + + // Whole POST was aborted in flight by stopSignal -> NO failure ledger entry, NO acceptance, decision retained + expect(postCalls).toBe(1); + expect(firstAttemptRawBody).toBe(frozenBodyJson); + expect(bridge.custodyByDeliveryId.size).toBe(0); + expect(attempted.load().has("dec-stop-whole-1")).toBe(true); + expect(delivered.load().has("dec-stop-whole-1")).toBe(false); + expect(failures, "stopSignal abort must not record transport failure").toEqual([]); + expect(receipts, "stopSignal abort must not record false acceptance").toEqual([]); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Pre-aborted stopSignal also returns stopped without calling fetch or recording failure + const callsBeforePreAborted = postCalls; + const preAbortedOutcome = await transport1.deliver({ + kind: "outbound_decision", + decisionId: "dec-stop-whole-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: prepared.payload, + }); + expect(preAbortedOutcome).toEqual({ + ok: false, + class: "stopped", + detail: "relay transport stopped", + }); + expect(postCalls).toBe(callsBeforePreAborted); + expect(failures).toEqual([]); + + // Restart with a fresh transport/wire: replays the exact same whole deliveryId and bytes in 1 POST + const stopCtl2 = new AbortController(); + const transport2 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + stopSignal: stopCtl2.signal, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + onTransportFailed: (_p, failureClass, detail) => failures.push({ failureClass, detail }), + }); + + const wire2 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport2.deliver(d), + }); + wire2.startServices?.(); + await tick(); + + expect(bridge.callCount()).toBe(1); + expect(bridge.custodyByDeliveryId.size).toBe(1); + expect(bridge.attempts[0]!.envelope.deliveryId).toBe("dec-stop-whole-1"); + expect(bridge.attempts[0]!.envelope).toEqual(frozenEnvelope); + expect(JSON.stringify(bridge.attempts[0]!.envelope)).toBe(firstAttemptRawBody); + expect(receipts).toHaveLength(1); + expect(failures).toEqual([]); + expect(delivered.load().has("dec-stop-whole-1")).toBe(true); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + + // Genuine timeout (when stopSignal is NOT aborted) still reports class "timeout" and invokes onTransportFailed + const hangingFetch = async (_url: string, init?: RequestInit): Promise => + await new Promise((_resolve, reject) => { + const sig = init?.signal; + sig?.addEventListener( + "abort", + () => reject(sig.reason ?? new DOMException("The operation was aborted", "AbortError")), + { once: true }, + ); + }); + + const timeoutTransport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: hangingFetch, + timeoutMs: 20, + stopSignal: stopCtl2.signal, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + onTransportFailed: (_p, failureClass, detail) => failures.push({ failureClass, detail }), + }); + + const timeoutOutcome = await timeoutTransport.deliver({ + kind: "outbound_decision", + decisionId: "dec-genuine-timeout-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-timeout-1", + notificationKey: "q-timeout-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + relayEnvelope: { + v: 1, + deliveryId: "dec-genuine-timeout-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Timeout test", + body: "Body", + replyRef: null, + }, + }, + }); + + expect(timeoutOutcome).toEqual({ + ok: false, + class: "timeout", + detail: "relay request timed out after 20ms", + }); + expect(failures).toEqual([ + { + failureClass: "timeout", + detail: "relay request timed out after 20ms", + }, + ]); + }); + }); +}); diff --git a/packages/daemon/test/slack-post-open-asks.test.ts b/packages/daemon/test/slack-post-open-asks.test.ts index 919b6ba31..3609b6851 100644 --- a/packages/daemon/test/slack-post-open-asks.test.ts +++ b/packages/daemon/test/slack-post-open-asks.test.ts @@ -19,6 +19,7 @@ import { OUTBOUND_OP, toPayload } from "../src/domain/gateway/slack/outbound-dri import { DispatchBuffer, dispatchBufferPath } from "../src/domain/gateway/dispatch-buffer.js"; import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; import { resolveSlackHandle, addHumanFragment } from "../src/domain/gateway/human-registry.js"; +import { setSharedChannel } from "../src/domain/gateway/participants.js"; import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js"; import { HumanQuietStore } from "../src/domain/gateway/human-quiet-windows.js"; import { QUIET_RELEASE_POLICY, runQuietRelease } from "../src/domain/policies/quiet-release.js"; @@ -331,6 +332,146 @@ describe("rig slack post-open-asks through the gateway", () => { expect(notes(superseded)).toContain(`slack-re-present-disposition notification_key=${items.get(superseded.qitemId)!.notificationKey} reason=superseded`); expect(notes(closed)).toContain(`slack-re-present-disposition notification_key=${items.get(closed.qitemId)!.notificationKey} reason=row-not-active`); }); + + it("ignores Relay-primary asks even if seen state marks them seeded, while re-presenting Slack-primary and shared-channel asks", async () => { + expect( + addHumanFragment( + { + entityId: HUMAN, + class: "human", + displayName: "Alex", + address: `${HUMAN}@external`, + connectorBindings: [ + { + kind: "slack", + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary", + handle: "UALEX", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + home, + ).ok, + ).toBe(true); + expect( + addHumanFragment( + { + entityId: "human-sam", + class: "human", + displayName: "Sam", + address: "human-sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-main", + secretsRef: `file:${join(home, "relay.env")}`, + role: "primary", + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + home, + ).ok, + ).toBe(true); + expect( + setSharedChannel( + "ops-room", + { + displayName: "Ops Room", + channel: { + connector: "slack", + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + channelId: "C-SHARED", + }, + }, + {}, + home, + ).ok, + ).toBe(true); + + registry = { + ok: true as const, + entities: [ + { + entityId: HUMAN, + class: "human" as const, + displayName: "Alex", + address: `${HUMAN}@external`, + connectorBindings: [ + { + kind: "slack" as const, + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary" as const, + handle: "UALEX", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + { + entityId: "human-sam", + class: "human" as const, + displayName: "Sam", + address: "human-sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${join(home, "relay.env")}`, + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + ], + }; + + const slackAsk = await park(); + const channelAsk = await repo.create({ + sourceSession: "dev-qa@r", + destinationSession: "ops-room@external", + summary: "Shared channel ask", + body: "shared channel body", + evidenceRef: "/proof/shared.md", + nudge: false, + }); + db.prepare( + `INSERT INTO queue_transitions(qitem_id,ts,state,actor_session,owner_notification_level,owner_notification_kind) + VALUES (?, '2026-01-01', 'pending', 'dev-qa@r', 'ALERT', 'human-required')`, + ).run(channelAsk.qitemId); + const relayAsk = await repo.create({ + sourceSession: "dev-qa@r", + destinationSession: "orch-lead@r", + body: "relay decision body", + nudge: false, + }); + repo.update({ + qitemId: relayAsk.qitemId, + actorSession: "orch-lead@r", + state: "blocked", + blockedOn: "human-sam@kernel", + summary: "Relay choice", + evidenceRef: "/proof/relay.md", + transitionNote: "parked on relay human", + }); + + // Even if all three episodes (including the Relay ask) are marked seeded-at-enable in seen store + new SeenStore(seenPath()).seed([keyOf(slackAsk), keyOf(channelAsk), keyOf(relayAsk)], "seeded-at-enable"); + + const w = wire(); + const result = await run(w); + expect(result.asks.map((a) => a.qitemId).sort()).toEqual([channelAsk.qitemId, slackAsk.qitemId].sort()); + expect(resultFor(result, relayAsk)).toBeUndefined(); + expect(resultFor(result, slackAsk)).toMatchObject({ outcome: "posted", reshown: true, recipient: `${HUMAN}@external` }); + expect(resultFor(result, channelAsk)).toMatchObject({ outcome: "posted", reshown: true, recipient: "ops-room@external" }); + expect(posts).toHaveLength(2); + expect(notes(relayAsk).some((n) => n.includes("slack-re-present-"))).toBe(false); + }); }); describe("POST /slack/post-open-asks", () => { diff --git a/packages/daemon/test/slack-subsystem-cutover.test.ts b/packages/daemon/test/slack-subsystem-cutover.test.ts index cde3e330e..0012b5fcd 100644 --- a/packages/daemon/test/slack-subsystem-cutover.test.ts +++ b/packages/daemon/test/slack-subsystem-cutover.test.ts @@ -352,3 +352,413 @@ describe("P28 — ignore-path telemetry discriminates the rejection branch (unch expect(out).not.toContain("secret body text"); }); }); + +describe("Capability 3 — single-wire relay port isolation and attempted-replay bypass", () => { + let home: string; + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "s10-relay-cut-")); }); + afterEach(() => { rmSync(home, { recursive: true, force: true }); }); + + it("never falls through to Slack when a human's primary binding is relay and the relay profile is unready, and bypasses re-decision on attempted replay", async () => { + const { writeFileSync } = await import("node:fs"); + const { createDb } = await import("../src/db/connection.js"); + const { migrate } = await import("../src/db/migrate.js"); + const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js"); + const { EventBus } = await import("../src/domain/event-bus.js"); + const { QueueRepository } = await import("../src/domain/queue-repository.js"); + const { DEFAULT_CONFIG, saveConfig } = await import("../src/domain/gateway/slack/config.js"); + const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js"); + const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js"); + const { DispatchBuffer } = await import("../src/domain/gateway/dispatch-buffer.js"); + const { HumanQuietStore } = await import("../src/domain/gateway/human-quiet-windows.js"); + + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + + // Configure Slack as READY so a fallthrough bug would hit Slack chat.postMessage + const slackSecrets = join(home, "slack.env"); + writeFileSync(slackSecrets, "SLACK_BOT_TOKEN=xoxb-EXAMPLE-fake\n", { mode: 0o600 }); + saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-FALLTHROUGH", secretsEnvFile: slackSecrets }, home); + + const relaySecrets = join(home, "relay.env"); + // Start with missing relay file -> unready relay profile + const registry = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${relaySecrets}`, + role: "primary" as const, + handle: "sam_wa", + }, + { + kind: "slack" as const, + connectorRef: "slack-secondary", + secretsRef: `file:${slackSecrets}`, + role: "secondary" as const, + handle: "USAM", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + + const repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => registry, + }); + const ask = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Must never fall through to Slack", + nudge: false, + }); + repo.update({ + qitemId: ask.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "sam@kernel", + summary: "Relay-bound question", + evidenceRef: "/proof/relay-ask.md", + transitionNote: "parked", + }); + const askEpisodeKey = `${ask.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(ask.qitemId)!.transitionId}`; + // Seed an earlier Slack-named transport-failed note for the same episode (as baseline 5d188a43b wrote before the fix); + // a subsequent Relay failure for this episode must still record a relay-owner-notification-transport-failed note. + repo.update({ + qitemId: ask.qitemId, + actorSession: "daemon@kernel", + transitionNote: `slack-owner-notification-transport-failed notification_key=${askEpisodeKey} class=relay-profile-unready error=legacy-slack-named-failure`, + }); + + const slackCalls: string[] = []; + const relayCalls: Array> = []; + let failFirstRelayHttp = true; + + const makeWire = () => + buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => registry, resolveSlackHandle }, + outboundIntervalMs: 15, + fetchImpl: async (url, init) => { + if (String(url).includes("slack.com")) { + slackCalls.push(String(url)); + return new Response(JSON.stringify({ ok: true, ts: "1724.0001" }), { status: 200 }); + } + relayCalls.push(JSON.parse(String(init?.body ?? "{}")) as Record); + if (failFirstRelayHttp) { + failFirstRelayHttp = false; + throw new Error("simulated connection drop after attempt"); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + const w1 = makeWire(); + try { + w1.startServices?.(); + await new Promise((r) => setTimeout(r, 50)); + // Unready relay profile is retained in DispatchBuffer and NEVER falls through to Slack + expect(slackCalls).toHaveLength(0); + expect(relayCalls).toHaveLength(0); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + const w1Notes = repo.listTransitions(ask.qitemId).map((t) => t.transitionNote ?? ""); + const relayFailureNotes = w1Notes.filter((n) => n.startsWith("relay-owner-notification-transport-failed ")); + expect(relayFailureNotes).toHaveLength(1); + expect(relayFailureNotes[0]).toContain("connector_ref=wa-main"); + expect(relayFailureNotes[0]).toContain("class=relay-profile-unready"); + expect(repo.getById(ask.qitemId)).toMatchObject({ + deliveryOutcome: "transport-failed", + deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"), + }); + expect(repo.findUndelivered().map((q) => q.qitemId)).toContain(ask.qitemId); + } finally { + w1.stop(); + } + + // If the relay connectorRef changes while still failing, it must record a new note naming the new connector rather than suppressing + registry.entities[0]!.connectorBindings[0]!.connectorRef = "wa-failover"; + const originalPending = new DispatchBuffer(home).pending()[0]!; + const { + relayConnectorRef: _oldRef, + relayEnvelope: _oldEnv, + ...unpreparedPayload + } = (originalPending.payload ?? {}) as Record; + const w1b = makeWire(); + try { + w1b.dispatcher.dispatch("post_message", "sam@kernel", unpreparedPayload, { + decisionId: "failover-probe", + }); + await new Promise((r) => setTimeout(r, 30)); + const w1bRelayFailureNotes = repo + .listTransitions(ask.qitemId) + .map((t) => t.transitionNote ?? "") + .filter((n) => n.startsWith("relay-owner-notification-transport-failed ")); + expect(w1bRelayFailureNotes).toHaveLength(2); + expect(w1bRelayFailureNotes[1]).toContain("connector_ref=wa-failover"); + expect(repo.getById(ask.qitemId)?.deliveryFailureDetail).toContain("connector_ref=wa-failover"); + } finally { + new DispatchBuffer(home).ack("failover-probe"); + w1b.stop(); + registry.entities[0]!.connectorBindings[0]!.connectorRef = "wa-main"; + } + + // Write valid relay secrets and replay: first HTTP attempt drops after recording attempt metadata + writeFileSync( + relaySecrets, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44101/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + + const w2 = makeWire(); + try { + w2.startServices?.(); + await new Promise((r) => setTimeout(r, 50)); + expect(slackCalls).toHaveLength(0); + expect(relayCalls).toHaveLength(1); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + } finally { + w2.stop(); + } + + // Turn ON quiet hours for sam BEFORE the next replay: because the decision was already attempted, + // replay must bypass policy re-decision (must NOT hold for quiet hours) and deliver the exact frozen envelope + const now = new Date(); + new HumanQuietStore(db).setUntil("sam", { + until: new Date(now.getTime() + 3_600_000), + timezone: "UTC", + setBy: "test", + now, + }); + + const w3 = makeWire(); + try { + w3.startServices?.(); + await new Promise((r) => setTimeout(r, 50)); + expect(slackCalls).toHaveLength(0); + expect(relayCalls).toHaveLength(2); + expect(relayCalls[1]).toEqual(relayCalls[0]); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + const episode = repo.currentHumanRequiredEpisode(ask.qitemId)!; + expect(repo.hasRelayOwnerNotificationReceipt(ask.qitemId, episode.key)).toBe(true); + } finally { + w3.stop(); + db.close(); + } + }); + + it("polls and delivers Relay-primary alerts when Slack is disabled without minting new Slack buffer decisions, then enables Slack without replaying unbuffered Slack backlog while preserving earlier durable Slack buffer entries", async () => { + const { writeFileSync } = await import("node:fs"); + const { createDb } = await import("../src/db/connection.js"); + const { migrate } = await import("../src/db/migrate.js"); + const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js"); + const { EventBus } = await import("../src/domain/event-bus.js"); + const { QueueRepository } = await import("../src/domain/queue-repository.js"); + const { DEFAULT_CONFIG, saveConfig } = await import("../src/domain/gateway/slack/config.js"); + const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js"); + const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js"); + const { DispatchBuffer } = await import("../src/domain/gateway/dispatch-buffer.js"); + const { makeQueuePorts, seedBacklogAsHistory } = await import("../src/domain/gateway/slack/queue-access.js"); + const { toPayload } = await import("../src/domain/gateway/slack/outbound-driver.js"); + + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + + const slackSecrets = join(home, "slack.env"); + writeFileSync(slackSecrets, "SLACK_BOT_TOKEN=xoxb-EXAMPLE-fake\n", { mode: 0o600 }); + // Slack starts DISABLED + saveConfig({ ...DEFAULT_CONFIG, enabled: false, channel: "C-MIXED", secretsEnvFile: slackSecrets }, home); + + const relaySecrets = join(home, "relay.env"); + writeFileSync( + relaySecrets, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44102/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + + const registry = { + ok: true as const, + entities: [ + { + entityId: "alex", + class: "human" as const, + displayName: "Alex", + address: "alex@external", + connectorBindings: [ + { + kind: "slack" as const, + connectorRef: "slack-main", + secretsRef: `file:${slackSecrets}`, + role: "primary" as const, + handle: "UALEX", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${relaySecrets}`, + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + + const repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => registry, + }); + + // 1. Earlier durable Slack ask already in DispatchBuffer before Slack was disabled + const earlierSlackRow = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Earlier durable Slack ask body", + nudge: false, + }); + repo.update({ + qitemId: earlierSlackRow.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "alex@kernel", + summary: "Earlier durable Slack ask", + evidenceRef: "/proof/earlier-slack.md", + transitionNote: "parked earlier", + }); + const [earlierAlert] = await makeQueuePorts(repo, { loadHumanRegistry: () => registry }).listHumanAlerts({ + minimumLevel: "NOTICE", + }); + new DispatchBuffer(home).enqueue({ + kind: "outbound_decision", + decisionId: "earlier-durable-slack-1", + op: OUTBOUND_OP, + entityBindingRef: "alex@external", + payload: toPayload(earlierAlert!), + }); + + // 2. Fresh Slack-primary ask and fresh Relay-primary ask created while Slack is disabled + const freshSlackRow = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Fresh Slack backlog while disabled", + nudge: false, + }); + repo.update({ + qitemId: freshSlackRow.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "alex@kernel", + summary: "Slack backlog ask", + evidenceRef: "/proof/fresh-slack.md", + transitionNote: "parked while slack disabled", + }); + + const relayRow = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Fresh Relay ask while Slack disabled", + nudge: false, + }); + repo.update({ + qitemId: relayRow.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "sam@kernel", + summary: "Relay active ask", + evidenceRef: "/proof/relay-active.md", + transitionNote: "parked for relay", + }); + + const slackPosts: Array> = []; + const relayPosts: Array> = []; + + const makeWire = () => + buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => registry, resolveSlackHandle }, + outboundIntervalMs: 15, + fetchImpl: async (url, init) => { + if (String(url).includes("slack.com")) { + slackPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true, ts: "1724.0002" }), { status: 200 }); + } + relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + const w1 = makeWire(); + try { + w1.startServices?.(); + await new Promise((r) => setTimeout(r, 60)); + // Relay ask delivered; Slack did not post + expect(relayPosts).toHaveLength(1); + expect(relayPosts[0]).toMatchObject({ + recipient: { handle: "sam_wa" }, + summary: "Relay active ask", + }); + expect(slackPosts).toHaveLength(0); + // Fresh Slack ask was NOT dispatched into the buffer while Slack was disabled; + // only the pre-existing durable Slack decision remains in the buffer. + expect(new DispatchBuffer(home).pending().map((d) => d.decisionId)).toEqual(["earlier-durable-slack-1"]); + } finally { + w1.stop(); + } + + // Now enable Slack: seed Slack-primary backlog as history, flip enabled=true, and restart wire + const slackOnlyRegistry = { + ...registry, + entities: registry.entities.filter((e) => + e.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"), + ), + }; + await seedBacklogAsHistory({ + queue: makeQueuePorts(repo, { loadHumanRegistry: () => slackOnlyRegistry }), + seen: new SeenStore(join(home, "state", "slack-outbound-seen.jsonl")), + filter: { minimumLevel: "NOTICE" }, + buffer: new DispatchBuffer(home), + }); + saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-MIXED", secretsEnvFile: slackSecrets }, home); + + const w2 = makeWire(); + try { + w2.startServices?.(); + await new Promise((r) => setTimeout(r, 60)); + // Only the pre-existing durable Slack decision replays and posts; the fresh Slack backlog ask was seeded and never buffered + expect(slackPosts).toHaveLength(1); + expect(JSON.stringify(slackPosts[0])).toContain("Earlier durable Slack ask"); + expect(JSON.stringify(slackPosts)).not.toContain("Fresh Slack backlog while disabled"); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + } finally { + w2.stop(); + db.close(); + } + }); +}); diff --git a/packages/daemon/test/steering-only-asks.test.ts b/packages/daemon/test/steering-only-asks.test.ts index 12499958e..81a8d0d16 100644 --- a/packages/daemon/test/steering-only-asks.test.ts +++ b/packages/daemon/test/steering-only-asks.test.ts @@ -28,25 +28,68 @@ import { missionControlRoutes } from "../src/routes/mission-control.js"; import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js"; import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js"; import { runWakeLadderTick } from "../src/domain/queue-wake-ladder.js"; -import type { AttentionRead } from "../src/attention-surface.js"; +import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js"; const HUMAN = "human-founder"; /** The people, with their Slack subscription's preset (absent = the default). */ -const registryWith = (preset?: "every-ask" | "urgent-only") => ({ - ok: true as const, - entities: [{ - entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`, - connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UFOUNDER" }], - prefs: { deliveryClass: "B" as const, availability: "available" as const }, - ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), - }, { - // A registered person addressed as a kernel alias, which no human-* name pattern matches. - entityId: "operator-admin", class: "human" as const, displayName: "Operator", address: "operator-admin@external", - connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UOPERATOR" }], - prefs: { deliveryClass: "B" as const, availability: "available" as const }, - ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), - }], -}); +const registryWith = ( + preset?: "every-ask" | "urgent-only", + options?: { primaryKind?: "slack" | "relay"; secondaryKind?: "slack" | "relay" }, +) => { + const primaryKind = options?.primaryKind ?? "slack"; + const bindings: any[] = []; + if (primaryKind === "relay") { + bindings.push({ + kind: "relay" as const, + connectorRef: "relay-main", + secretsRef: "file:/tmp/relay.env", + role: "primary" as const, + handle: "founder-relay", + }); + } else { + bindings.push({ + kind: "slack" as const, + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary" as const, + handle: "UFOUNDER", + }); + } + + if (options?.secondaryKind === "relay") { + bindings.push({ + kind: "relay" as const, + connectorRef: "relay-sec", + secretsRef: "file:/tmp/relay.env", + role: "secondary" as const, + handle: "founder-relay-sec", + }); + } else if (options?.secondaryKind === "slack") { + bindings.push({ + kind: "slack" as const, + connectorRef: "slack-sec", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "secondary" as const, + handle: "UFOUNDER_SEC", + }); + } + + return { + ok: true as const, + entities: [{ + entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`, + connectorBindings: bindings, + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), + }, { + // A registered person addressed as a kernel alias, which no human-* name pattern matches. + entityId: "operator-admin", class: "human" as const, displayName: "Operator", address: "operator-admin@external", + connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UOPERATOR" }], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), + }], + }; +}; // Most cases here are a person whose Slack subscription is urgent-only (plain asks wait on Steering); the default cases reset it. let registry = registryWith("urgent-only"); const QUESTION = [{ id: "q1", question: "A or B?", options: [{ id: "a", label: "A" }, { id: "b", label: "B" }] }]; @@ -545,4 +588,39 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their expect(post).toBeDefined(); expect(JSON.stringify(post)).not.toContain("<@UOPERATOR>"); }); + + it("proves relay primary uses every-ask default independently of authored Slack urgent-only subscription", async () => { + // 1. Relay primary with authored Slack urgent-only: plain ask must classify as ALERT and be eligible through real queue ports + registry = registryWith("urgent-only", { primaryKind: "relay", secondaryKind: "slack" }); + const relayPlain = await ask(); + expect(owner(relayPlain)).toMatchObject({ + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + }); + const relayPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry }); + const relayAlerts = await relayPorts.listHumanAlerts({}); + expect(relayAlerts.some((item) => item.qitemId === relayPlain.qitemId)).toBe(true); + + // 2. Slack primary with authored Slack urgent-only: plain ask stays RECORD + registry = registryWith("urgent-only", { primaryKind: "slack" }); + const slackPlain = await ask(); + expect(owner(slackPlain)).toMatchObject({ + ownerNotificationKind: "human-required", + ownerNotificationLevel: "RECORD", + }); + const slackPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry }); + const slackAlerts = await slackPorts.listHumanAlerts({}); + expect(slackAlerts.some((item) => item.qitemId === slackPlain.qitemId)).toBe(false); + + // 3. Slack primary with secondary Relay: does not change primary routing, retains Slack urgent-only behavior + registry = registryWith("urgent-only", { primaryKind: "slack", secondaryKind: "relay" }); + const slackSecRelayPlain = await ask(); + expect(owner(slackSecRelayPlain)).toMatchObject({ + ownerNotificationKind: "human-required", + ownerNotificationLevel: "RECORD", + }); + const slackSecRelayPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry }); + const slackSecAlerts = await slackSecRelayPorts.listHumanAlerts({}); + expect(slackSecAlerts.some((item) => item.qitemId === slackSecRelayPlain.qitemId)).toBe(false); + }); }); diff --git a/packages/tui/src/attention/attention-model.ts b/packages/tui/src/attention/attention-model.ts index b97607bdc..8ae3eaf0f 100644 --- a/packages/tui/src/attention/attention-model.ts +++ b/packages/tui/src/attention/attention-model.ts @@ -2,34 +2,49 @@ import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention"; import type { Action, FleetSnapshot, ViewState } from "../types.js"; import { listItem, wrapDetailLines, type ContentLine } from "../detail.js"; -/** The bounded, confirmed-delivery projection served by S02; no client classification. */ +/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */ export interface DeliveredHumanUpdates { items: Array<{ qitemId: string; summary: string | null; body: string; humanDetail: string | null; destinationSession: string; sourceSession: string; tags: string[] | null; evidenceRef: string | null; - deliveredAt: string; deliveryReceipt: string }>; + deliveredAt: string; deliveryReceipt: string; deliveryOutcome?: "posted" | "accepted" | null }>; limit: number; truncated: boolean; } +function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean { + return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted"); +} + export function composeHumanUpdates(attention: AttentionRead | null, updates: DeliveredHumanUpdates | null, wanted?: string | null): AttentionRead | null { if (!attention && !updates) return null; const read: AttentionRead = attention ? { ...attention, items: [...attention.items], sources: [...attention.sources] } : { scope: "instance", readAt: new Date().toISOString(), items: [], detail: null, detailError: null, sources: [{ source: "queue", state: "unavailable", detail: "Human requests have not answered." }, { source: "outcomes and health", state: "unavailable", detail: "Feed source unavailable." }], }; + const hasAccepted = Boolean(updates?.items.some(isBridgeAcceptedUpdate)); + const hasPosted = Boolean(updates?.items.some(q => !isBridgeAcceptedUpdate(q))); + const sourceDetail = !updates + ? "Delivered updates have not answered." + : hasAccepted && !hasPosted + ? `Latest ${updates.limit} updates accepted by the bridge; retained receipt window${updates.truncated ? ", more omitted" : ""}.` + : hasAccepted + ? `Latest ${updates.limit} confirmed delivered or accepted-by-bridge updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.` + : `Latest ${updates.limit} confirmed delivered updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.`; read.sources.push({ source: "delivered updates", state: !updates ? "unavailable" : updates.truncated ? "partial" : "available", - detail: updates ? `Latest ${updates.limit} confirmed delivered updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.` : "Delivered updates have not answered." }); + detail: sourceDetail }); for (const q of updates?.items ?? []) { + const accepted = isBridgeAcceptedUpdate(q); const projects = [...new Set((q.tags ?? []).filter(t => t.startsWith("project:")).map(t => t.slice(8)))]; - const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Delivered update", + const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown"; + const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"), recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt, - scope: projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown", project: null, + scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` }; if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item); if (wanted === item.id && read.detail?.item.id !== item.id) { read.detailError = null; read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []), - `From: ${q.sourceSession}`, `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`], + `From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`], files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] }; } } diff --git a/packages/tui/test/feed-open-human-updates.test.ts b/packages/tui/test/feed-open-human-updates.test.ts index 2da04f5ee..2f4d32620 100644 --- a/packages/tui/test/feed-open-human-updates.test.ts +++ b/packages/tui/test/feed-open-human-updates.test.ts @@ -133,3 +133,45 @@ it("leaves health episodes ordered by date when hydrating queue updates and deli expect(merged.items.filter(i => i.id.startsWith("health:")).map(i => i.id)).toEqual(["health:new-active", "health:old-cleared"]); expect(merged.items.filter(i => i.id.startsWith("queue-update:")).map(i => i.id)).toEqual(["queue-update:critical", "queue-update:urgent"]); }); + +it("labels relay bridge acceptance as accepted by the bridge rather than Delivered while preserving Slack delivery wording", () => { + const bridgeUpdates: DeliveredHumanUpdates = { + limit: 20, + truncated: false, + items: [ + { + qitemId: "bridge-fyi", + summary: "Bridge FYI ready", + body: "Bridge accepted this update.", + humanDetail: "Bridge detail", + destinationSession: "human@kernel", + sourceSession: "author@fixture", + tags: ["project:relay"], + evidenceRef: "/relay/proof.md", + deliveredAt: "2026-10-08T14:00:00Z", + deliveryReceipt: "relay-owner-notification-accepted notification_key=bridge-fyi:1 delivery_id=d-1 connector_ref=wa-main level=NOTICE kind=human-update", + deliveryOutcome: "accepted", + }, + ], + }; + const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!; + const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? ""; + expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z"); + expect(bridgeDetailLines).not.toContain("Delivered:"); + expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge"); + expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates"); + + const listState = { ...createViewState({ instanceId: "owned-fixture" }).get(), section: "needs" as const }; + const groupedText = attentionLines(listState, { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n"); + expect(groupedText).toContain("accepted by bridge"); + expect(groupedText).not.toContain("confirmed delivered updates"); + + // Slack delivered wording remains unchanged + const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!; + const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? ""; + expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z"); + expect(slackDetailLines).not.toContain("Accepted by bridge:"); + expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe( + "Latest 20 confirmed delivered updates; retained receipt window.", + ); +});