@vellumai/vellum-gateway 0.9.0 → 0.10.0-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +78 -4
  2. package/ARCHITECTURE.md +5 -6
  3. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  4. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  5. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +97 -0
  6. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +10 -0
  7. package/node_modules/@vellumai/gateway-client/src/index.ts +32 -6
  8. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  9. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  10. package/openapi.json +16 -5
  11. package/package.json +1 -1
  12. package/src/__tests__/admission-policy-store.test.ts +146 -0
  13. package/src/__tests__/channel-admission-policy-routes.test.ts +345 -0
  14. package/src/__tests__/edge-auth.test.ts +23 -0
  15. package/src/__tests__/edge-guardian-auth.test.ts +12 -0
  16. package/src/__tests__/feature-flags-route.test.ts +58 -1
  17. package/src/__tests__/guardian-binding-channel-reuse.test.ts +8 -20
  18. package/src/__tests__/handle-inbound-admission.test.ts +254 -0
  19. package/src/__tests__/ipc-contact-routes.test.ts +2 -5
  20. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +2 -0
  21. package/src/__tests__/remote-web-ingress-denylist.test.ts +20 -1
  22. package/src/__tests__/remote-web-pairing-challenge.test.ts +163 -7
  23. package/src/__tests__/remote-web-pairing-token.test.ts +530 -0
  24. package/src/__tests__/remote-web-pairing-verification.test.ts +230 -0
  25. package/src/__tests__/route-schema-guard.test.ts +4 -0
  26. package/src/__tests__/seed-admission-policy.test.ts +67 -0
  27. package/src/__tests__/slack-display-name.test.ts +16 -2
  28. package/src/__tests__/slack-socket-mode-catchup.test.ts +1 -0
  29. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +180 -2
  30. package/src/__tests__/telegram-webhook-handler.test.ts +14 -1
  31. package/src/__tests__/upsert-verified-contact-channel.test.ts +5 -10
  32. package/src/auth/guardian-bootstrap.ts +55 -23
  33. package/src/auth/guardian-refresh.ts +78 -38
  34. package/src/credential-watcher.ts +2 -2
  35. package/src/db/admission-policy-store.ts +212 -0
  36. package/src/db/connection.ts +34 -0
  37. package/src/db/contact-store.ts +50 -59
  38. package/src/db/data-migrations/m0005-normalize-contact-channel-addresses.ts +6 -51
  39. package/src/db/schema.ts +26 -5
  40. package/src/db/seed-admission-policy.ts +62 -0
  41. package/src/feature-flag-registry.json +46 -31
  42. package/src/handlers/handle-inbound.ts +37 -1
  43. package/src/http/browser-auth-cookies.ts +77 -0
  44. package/src/http/middleware/auth.ts +3 -0
  45. package/src/http/middleware/cors.ts +1 -1
  46. package/src/http/read-limited-body.ts +50 -0
  47. package/src/http/router.ts +31 -5
  48. package/src/http/routes/channel-admission-policy.ts +239 -0
  49. package/src/http/routes/channel-verification-session-proxy.test.ts +0 -15
  50. package/src/http/routes/channel-verification-session-proxy.ts +1 -71
  51. package/src/http/routes/contact-prompt.ts +5 -3
  52. package/src/http/routes/contacts-control-plane-proxy.ts +21 -12
  53. package/src/http/routes/feature-flags.ts +16 -3
  54. package/src/http/routes/guardian-channel-create.ts +12 -9
  55. package/src/http/routes/guardian-refresh.ts +173 -0
  56. package/src/http/routes/ipc-runtime-proxy.test.ts +22 -0
  57. package/src/http/routes/ipc-runtime-proxy.ts +20 -0
  58. package/src/http/routes/remote-web-pairing-challenge.ts +100 -9
  59. package/src/http/routes/remote-web-pairing-token.ts +122 -0
  60. package/src/http/routes/remote-web-pairing-verification.ts +122 -0
  61. package/src/http/routes/twilio-voice-verify-callback.ts +4 -7
  62. package/src/http/routes/twilio-voice-webhook.test.ts +120 -0
  63. package/src/http/routes/twilio-voice-webhook.ts +15 -0
  64. package/src/index.ts +144 -42
  65. package/src/ipc/__tests__/admission-policy-handlers.test.ts +80 -0
  66. package/src/ipc/admission-policy-handlers.ts +27 -0
  67. package/src/ipc/contact-handlers.ts +4 -2
  68. package/src/ipc/risk-classification-handlers.test.ts +72 -0
  69. package/src/ipc/risk-classification-handlers.ts +28 -1
  70. package/src/remote-web/pairing-challenge-rate-limit-store.ts +34 -0
  71. package/src/remote-web/pairing-challenge-store.ts +156 -6
  72. package/src/remote-web/pairing-verification-rate-limit-store.ts +57 -0
  73. package/src/risk/admission-policy-cache.ts +99 -0
  74. package/src/risk/command-registry/commands/assistant.ts +8 -18
  75. package/src/risk/command-registry.test.ts +1 -1
  76. package/src/risk/file-risk-classifier.test.ts +381 -2
  77. package/src/risk/file-risk-classifier.ts +245 -66
  78. package/src/schema.ts +107 -236
  79. package/src/slack/channel.test.ts +25 -0
  80. package/src/slack/channel.ts +35 -0
  81. package/src/slack/normalize.ts +8 -14
  82. package/src/slack/socket-mode.ts +42 -15
  83. package/src/telegram/send.test.ts +6 -6
  84. package/src/telegram/send.ts +3 -13
  85. package/src/verification/binding-helpers.ts +10 -9
  86. package/src/verification/contact-helpers.ts +24 -52
  87. package/src/verification/outbound-voice-verification-sync.test.ts +10 -15
  88. package/src/verification/outbound-voice-verification-sync.ts +3 -4
  89. package/src/verification/text-verification.ts +30 -21
  90. package/src/whatsapp/send.ts +2 -12
  91. package/src/__tests__/privacy-config-route.test.ts +0 -891
  92. package/src/http/routes/privacy-config.ts +0 -296
package/AGENTS.md CHANGED
@@ -16,10 +16,7 @@ Why: the gateway is the single point of ingress, handling TLS termination, auth,
16
16
 
17
17
  All assistant API requests from clients, CLI, skills, and user-facing tooling **MUST** target gateway URLs. Never construct URLs using the daemon runtime port (`7821`) or `RUNTIME_HTTP_PORT` for external API consumption.
18
18
 
19
- **Exception boundary:** The gateway service itself may call the runtime internally. Tests may use direct runtime URLs for isolated unit/integration scenarios. Intentional local daemon-control paths are exempt:
20
-
21
- - `clients/shared/Network/DaemonClient.swift`
22
- - `clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift` (health probe)
19
+ **Exception boundary:** The gateway service itself may call the runtime internally. Tests may use direct runtime URLs for isolated unit/integration scenarios. Intentional local daemon-control paths (e.g. health probes) are exempt; the authoritative allowlist lives in `assistant/src/__tests__/gateway-only-guard.test.ts`.
23
20
 
24
21
  **Migration rule:** If a needed endpoint is not available at the gateway, add a gateway route/proxy first, then consume it. Do not work around a missing gateway endpoint by hitting the runtime directly.
25
22
 
@@ -67,3 +64,80 @@ Gateway inbound events use a channel-discriminated union model (`GatewayInboundE
67
64
  Trust/guardian decisions must be keyed on `actorExternalId` only — never fall back to `conversationExternalId` for actor identity.
68
65
 
69
66
  Physical DB column names (`externalUserId`, `externalChatId`) are unchanged; the rename is at the API/type layer only.
67
+
68
+ ## Channel Trust Classification & Admission Policy
69
+
70
+ The gateway owns per-channel `AdmissionPolicy` storage (`gateway/src/db/admission-policy-store.ts`, HTTP in `gateway/src/http/routes/channel-admission-policy.ts`) and attaches the floor to every forwarded inbound via `sourceMetadata.admissionPolicy`. The runtime (`assistant/src/runtime/routes/inbound-stages/admission-policy.ts`) emits `admitted: true | false` based on `TRUST_CLASS_RANK[trustClass] >= ADMISSION_FLOOR[policy]`.
71
+
72
+ A default row per enforced channel is **seeded at startup** (`seedAdmissionPolicyDefaults` in `gateway/src/db/seed-admission-policy.ts`) — `trusted_contacts` for every channel except `vellum` (`guardian_only`). Per-channel defaults live only in that seed; the store/cache fall back to `ADMISSION_POLICY_DEFAULT` (`trusted_contacts`) only if a row is somehow absent.
73
+
74
+ **5 policies, ranked floors** (seed default `trusted_contacts`):
75
+
76
+ | Policy | Floor | Notes |
77
+ |---|---|---|
78
+ | `no_one` | 5 | Hard-deny at gateway *before* forwarding (kill switch in `handle-inbound.ts`). Includes the guardian — this channel is *OFF*. |
79
+ | `guardian_only` | 4 | Seeded default for `vellum`. |
80
+ | `trusted_contacts` | 3 | Seeded default for all other channels; also the read-path safety fallback. |
81
+ | `any_contact` | 2 | May surface Slack DM / email upgrade challenge on deny. |
82
+ | `strangers` | 1 | May surface upgrade challenge. |
83
+
84
+ **Exempt channels** (no policy ever applies — gateway **AND** runtime both short-circuit):
85
+
86
+ - `platform` — internal platform control plane.
87
+ - `a2a` — assistant-to-assistant peer traffic (out of human-trust model).
88
+
89
+ `phone` is now an enforced channel (voice ingress reads the policy): it seeds the universal default `trusted_contacts` and accepts PUT like other enforced channels.
90
+
91
+ For exempt ids, `PUT /v1/assistants/:id/channel-admission-policy/:channelType` returns **403**, the GET list omits them, and the runtime short-circuits `admitted: true` in `admission-policy.ts` (defense in depth). Codex finding from #35006 review: exemption checks must live in *both* the gateway route handler AND the runtime stage — single-side enforcement creates a misuse wedge.
92
+
93
+ **Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `whatsapp`) — managed automatically, **not** user-configurable, but (unlike exempt channels) **still enforced at runtime**:
94
+
95
+ - The GET list omits them, and `PUT`/`DELETE` return **403** (`isAdmissionPolicyHiddenChannel`).
96
+ - They are **not** exempt — the runtime still evaluates rank-vs-floor, so a real inbound channel like `whatsapp` keeps its admission floor.
97
+ - Their floor is pinned to the seed default; `seedAdmissionPolicyDefaults` **overwrites** any drifted/legacy row at startup (e.g. a stale `whatsapp = no_one`), so a stranded floor can't silently block a channel the user can no longer see or reset. The guardian is always max-rank on `vellum`, so its `guardian_only` seed default never locks them out — there is **no** `no_one` picker or 422 kill-switch path for hidden channels.
98
+
99
+ Only the **assistant-scoped** routes (`/v1/assistants/:id/channel-admission-policy/...`) exist; admission policy is gateway-global so the id is matched and discarded. (The flat `/v1/channel-admission-policy/...` variants were removed with the CLI that used them.)
100
+
101
+ **Split enforcement** (locked decision):
102
+
103
+ - **Gateway kill switch** — `handle-inbound.ts` enforces the `no_one` floor before forwarding. Zero contact-table lookups, zero daemon I/O, true kill.
104
+ - **Runtime floor** — every other policy flows through the gateway unchanged; the runtime evaluates rank-vs-floor inside `admission-policy.ts`. This keeps the canonical `actor-trust-resolver.ts:280` classifier as the single source of `TrustClass` truth (no fork).
105
+ - **Gateway vs runtime reciprocity** — the gateway section in `gateway/CLAUDE.md` records *which channels the gateway enforces*; the assistant section records *how the runtime classifies*. Either side getting out of sync is a bug, not an over-defended boundary.
106
+
107
+ **Adding a new policy**: extend the `AdmissionPolicy` union in `packages/gateway-client/src/admission-policy-contract.ts`, add its floor in `ADMISSION_FLOOR`, update the openapi schema, and update `gateway/src/__tests__/channel-admission-policy-routes.test.ts` + `assistant/src/runtime/routes/inbound-stages/admission-policy.test.ts`. Do not add a 6th floor without also bumping the `TRUST_CLASS_RANK` ceiling to match.
108
+
109
+ **Adding a new exempt channel**: update `ADMISSION_POLICY_EXEMPT_CHANNELS` in `packages/gateway-client/src/admission-policy-contract.ts` AND `EXEMPT_CHANNEL_TYPES` in `gateway/src/db/admission-policy-store.ts`. The gateway route (403), GET-list omission, runtime short-circuit, and seed-skip all read from these — symmetric enforcement is required so a stray runtime call (test harness, internal IPC) can't bypass the floor.
110
+
111
+ **Hiding a channel from the UI (still enforced)**: add it to `ADMISSION_POLICY_HIDDEN_CHANNELS` in `packages/gateway-client/src/admission-policy-contract.ts` (and the web mirror `HIDDEN_CHANNELS` in `clients/web/src/lib/channel-admission-policy/types.ts`). The GET-list omission, `PUT`/`DELETE` 403, and seed re-pin all read from this set. Use this — **not** the exempt set — for a channel that must keep enforcing a floor but should not be user-configurable, so its admission check is never short-circuited.
112
+
113
+ ### Trust Classes → Capabilities (what an actor may do)
114
+
115
+ Two orthogonal axes, do not conflate them:
116
+
117
+ - **Admission** (above) — *who gets in the door*. `TRUST_CLASS_RANK` vs `ADMISSION_FLOOR`, enforced across gateway + runtime.
118
+ - **Capabilities** — *what an actor may do once admitted*. Resolved in the runtime, never on the gateway.
119
+
120
+ **Trust classes** (`TrustClass` in `assistant/src/runtime/actor-trust-resolver.ts`) are the *role*, ranked by `TRUST_CLASS_RANK`:
121
+
122
+ | Class | Rank | Meaning |
123
+ |---|---|---|
124
+ | `guardian` | 4 | Matches the active guardian binding for this (assistant, channel). |
125
+ | `trusted_contact` | 3 | Active contact channel, not the guardian. |
126
+ | `unverified_contact` | 2 | Contact channel that is `pending`/`unverified` — known but not verified. |
127
+ | `unknown` | 1 | No contact record, no identity, or blocked/revoked. Fail-closed. |
128
+
129
+ The gateway classifies the actor at ingress (keyed on `actorExternalId`) and forwards the resolved `trustClass`; it is persisted in retry payloads, the journal store, and conversation CRUD. The gateway does **not** compute capabilities.
130
+
131
+ **Capability resolution** lives in `assistant/src/runtime/capabilities.ts`. `resolveCapabilities(trustClass) → CapabilitySet` is the **single fail-closed trust boundary** for the "what may they do" axis, separating permissions from the raw class the way RBAC separates permissions from roles:
132
+
133
+ - **Total & fail-closed.** Accepts any string or `undefined`; anything not a recognized class (incl. legacy strings like `"non_guardian"`) resolves to the `unknown` set. The lookup uses an own-property check so inherited keys (`"__proto__"`, `"constructor"`, `"toString"`) also fail closed.
134
+ - **`trusted_contact` ≡ `unverified_contact`.** They share the same `CapabilitySet` object — the distinction is admission-only. Pinned by `capabilities.test.ts`.
135
+ - **`CapabilitySet` fields**: `canSelfApproveTools`, `sensitiveToolApproval` (`self | escalate-and-wait | deny`), `canManageSchedules`, `canUseVerificationControlPlane`, `canSelfAuthorizeArchiveBySender`, `canAccessMemory`, `canAccessPrivilegedDocuments`, `canRunUnsandboxedShell`, `mayBeInteractive`, `canActUnderDiskPressureCleanup`, `promptTrustGuidance` (`none | social-engineering-defense | stranger-warning`). All the booleans except `mayBeInteractive` are guardian-only; `mayBeInteractive` is also true for contacts.
136
+
137
+ **How call sites use it.** Read a named capability instead of re-deriving from the raw class — e.g. `if (!resolveCapabilities(trustClass).canAccessMemory) skip`. Context-dependent decisions **compose** a capability primitive with runtime context rather than encoding it in the table: `resolveRoutingState` uses `mayBeInteractive && guardianRouteResolvable`; `document-tool` uses `canAccessPrivilegedDocuments || executionChannel === "vellum"`; the self-approval race guard uses `!canSelfApproveTools && <pending-row state>`. The legacy `isUntrustedTrustClass` helper has been removed — use `!resolveCapabilities(x).<cap>`.
138
+
139
+ **Stateless.** Capabilities are derived on read from the already-persisted/forwarded `trustClass`; nothing capability-shaped is stored or sent on the wire.
140
+
141
+ **Adding a capability**: add the field to `CapabilitySet` + the three class records (`GUARDIAN_CAPABILITIES`, `CONTACT_CAPABILITIES`, `UNKNOWN_CAPABILITIES`) + the `MATRIX` in `capabilities.test.ts`. **Adding a trust class**: add a member to `TrustClass` (the `Record<TrustClass, …>` tables then fail to compile until every column is filled) and a matrix row.
142
+
143
+ **Intentionally NOT capability-gated** (these are identity / admission-flow decisions, not permissions, and stay raw class checks): `calls/*` guardian-identity call routing, `inbound-message-handler` heartbeat/timezone side-effects, `surface-action-routes` drift-heal re-resolution, and `channel-retry-sweep` trust-class parsing.
package/ARCHITECTURE.md CHANGED
@@ -34,9 +34,9 @@ Internet
34
34
 
35
35
  ### STT Route Proxying (Assistant-Scoped Rewrite)
36
36
 
37
- Native clients (macOS, iOS) send speech-to-text transcription requests through the gateway to the daemon's STT service. Clients POST to the assistant-scoped path `/v1/assistants/:assistantId/stt/transcribe`, which the gateway's runtime proxy rewrites to the flat daemon path `/v1/stt/transcribe`. This follows the same assistant-scoped rewrite pattern used by other client-facing endpoints (feature flags, privacy config, etc.).
37
+ Clients send speech-to-text transcription requests through the gateway to the daemon's STT service. Clients POST to the assistant-scoped path `/v1/assistants/:assistantId/stt/transcribe`, which the gateway's runtime proxy rewrites to the flat daemon path `/v1/stt/transcribe`. This follows the same assistant-scoped rewrite pattern used by other client-facing endpoints (feature flags, privacy config, etc.).
38
38
 
39
- The request carries base64-encoded WAV audio and a MIME type. The daemon resolves the configured STT provider via `resolveBatchTranscriber()` and returns the transcribed text. Clients use the response to implement a service-first strategy: the service transcription takes precedence when available, with Apple-native `SFSpeechRecognizer` as fallback when the service returns 503 (not configured) or fails.
39
+ The request carries base64-encoded WAV audio and a MIME type. The daemon resolves the configured STT provider via `resolveBatchTranscriber()` and returns the transcribed text. Clients use the response to implement a service-first strategy: the service transcription takes precedence when available, with a client-local fallback when the service returns 503 (not configured) or fails.
40
40
 
41
41
  | Client path (gateway) | Daemon path (after rewrite) | Method |
42
42
  | ----------------------------------- | --------------------------- | ------ |
@@ -48,12 +48,11 @@ The request carries base64-encoded WAV audio and a MIME type. The daemon resolve
48
48
  | ------------------------------------------------ | ------------------------------------------------------------------------- |
49
49
  | `gateway/src/http/routes/runtime-proxy.ts` | Assistant-scoped path rewriting (`/v1/assistants/:id/...` → `/v1/...`) |
50
50
  | `assistant/src/runtime/routes/stt-routes.ts` | Daemon HTTP endpoint: validates audio, resolves transcriber, returns text |
51
- | `clients/shared/Network/STTClient.swift` | Shared client: POSTs audio to the gateway, returns typed `STTResult` |
52
- | `clients/shared/Utilities/AudioWavEncoder.swift` | WAV encoding utility for PCM audio buffers |
51
+ | `clients/web/src/domains/chat/voice/stt-api.ts` | Web client: POSTs audio to the gateway, returns a typed result |
53
52
 
54
53
  ### STT Streaming WebSocket Proxy
55
54
 
56
- Native clients (macOS, iOS) open WebSocket connections through the gateway to the daemon's real-time STT streaming endpoint for conversation chat message capture. The gateway authenticates the downstream client using an edge JWT (actor principal required), then opens an upstream WebSocket connection to the daemon's `/v1/stt/stream` endpoint with a short-lived gateway service token. This keeps the daemon's WebSocket endpoint unreachable from the public internet while allowing authenticated clients to stream audio for real-time transcription.
55
+ Clients open WebSocket connections through the gateway to the daemon's real-time STT streaming endpoint for conversation chat message capture. The gateway authenticates the downstream client using an edge JWT (actor principal required), then opens an upstream WebSocket connection to the daemon's `/v1/stt/stream` endpoint with a short-lived gateway service token. This keeps the daemon's WebSocket endpoint unreachable from the public internet while allowing authenticated clients to stream audio for real-time transcription.
57
56
 
58
57
  **Config-authoritative model:** The runtime always resolves the streaming transcriber from `services.stt.provider` in the assistant config, regardless of any `provider` query parameter. The `provider` parameter is optional compatibility metadata — when supplied and it disagrees with the configured provider, the runtime logs a mismatch warning for operator visibility.
59
58
 
@@ -80,7 +79,7 @@ Native clients (macOS, iOS) open WebSocket connections through the gateway to th
80
79
  | `gateway/src/index.ts` | Route registration: wires upgrade handler to the gateway's Bun HTTP server |
81
80
  | `assistant/src/runtime/http-server.ts` | Daemon-side WebSocket upgrade at `/v1/stt/stream`, session creation and registry |
82
81
  | `assistant/src/stt/stt-stream-session.ts` | Runtime session orchestrator: drives the `StreamingTranscriber` from the WebSocket |
83
- | `clients/shared/Network/STTStreamingClient.swift` | Swift client: builds the gateway WS URL via `GatewayHTTPClient.buildWebSocketRequest` |
82
+ | `clients/web/src/domains/chat/voice/dictation-stream.ts` | Web client: opens the gateway WebSocket, parses transcript events, reports failures |
84
83
 
85
84
  ### Assistant Feature Flags API
86
85
 
@@ -74,7 +74,11 @@ function createMockTransport(): CesTransport & {
74
74
  messages: string[];
75
75
  messageHandler: ((msg: string) => void) | null;
76
76
  alive: boolean;
77
+ /** Simulate the transport dying (e.g. a spurious stdout EOF): mark it dead
78
+ * and fire the registered close handlers, as the real transports do. */
79
+ die: () => void;
77
80
  } {
81
+ const closeHandlers: Array<() => void> = [];
78
82
  const transport = {
79
83
  messages: [] as string[],
80
84
  messageHandler: null as ((msg: string) => void) | null,
@@ -88,9 +92,16 @@ function createMockTransport(): CesTransport & {
88
92
  isAlive() {
89
93
  return transport.alive;
90
94
  },
95
+ onClose(handler: () => void) {
96
+ closeHandlers.push(handler);
97
+ },
91
98
  close() {
92
99
  transport.alive = false;
93
100
  },
101
+ die() {
102
+ transport.alive = false;
103
+ for (const handler of closeHandlers) handler();
104
+ },
94
105
  };
95
106
  return transport;
96
107
  }
@@ -605,6 +616,42 @@ describe("CesRpcClient", () => {
605
616
  expect(client.isReady()).toBe(false);
606
617
  });
607
618
 
619
+ test("a pending request fails fast when the transport dies (no timeout wait)", async () => {
620
+ const transport = createMockTransport();
621
+ const client = createCesRpcClient(transport, {
622
+ handshakeTimeoutMs: 5000,
623
+ // Long timeout on purpose: the call must reject from the transport
624
+ // DEATH, not this timer. Without the fail-fast it would hang 60s.
625
+ requestTimeoutMs: 60_000,
626
+ });
627
+
628
+ // Handshake
629
+ const hPromise = client.handshake();
630
+ const hSent = JSON.parse(transport.messages[0]!);
631
+ transport.messageHandler!(
632
+ JSON.stringify({
633
+ type: "handshake_ack",
634
+ protocolVersion: CES_PROTOCOL_VERSION,
635
+ sessionId: hSent.sessionId,
636
+ accepted: true,
637
+ }),
638
+ );
639
+ await hPromise;
640
+
641
+ const callPromise = client.call("list_credentials", {});
642
+
643
+ // The transport's read side dies (e.g. a spurious stdout EOF on a CES
644
+ // bounce) while the request is in flight — the response can never arrive.
645
+ transport.die();
646
+
647
+ try {
648
+ await callPromise;
649
+ throw new Error("should have thrown");
650
+ } catch (err) {
651
+ expect(err).toBeInstanceOf(CesTransportError);
652
+ }
653
+ });
654
+
608
655
  test("subsequent handshake call returns immediately if already ready", async () => {
609
656
  const transport = createMockTransport();
610
657
  const client = createCesRpcClient(transport, { handshakeTimeoutMs: 5000 });
@@ -45,6 +45,14 @@ export interface CesTransport {
45
45
  isAlive(): boolean;
46
46
  /** Tear down the transport connection. */
47
47
  close(): void;
48
+ /**
49
+ * Register a callback that fires once when the transport dies (its read side
50
+ * ends, the process exits, or the socket closes). Lets the client fail-fast
51
+ * any in-flight requests instead of letting each wait out its timeout.
52
+ * Optional: a transport that doesn't implement it falls back to
53
+ * timeout-based failure.
54
+ */
55
+ onClose?(handler: () => void): void;
48
56
  }
49
57
 
50
58
  // ---------------------------------------------------------------------------
@@ -155,6 +163,16 @@ export function createCesRpcClient(
155
163
 
156
164
  const pending = new Map<string, PendingRequest>();
157
165
 
166
+ /** Reject and clear every in-flight request. Shared by `close()` and the
167
+ * transport-death handler. */
168
+ function rejectAllPending(error: Error): void {
169
+ for (const [id, entry] of pending) {
170
+ clearTimeout(entry.timer);
171
+ pending.delete(id);
172
+ entry.reject(error);
173
+ }
174
+ }
175
+
158
176
  // -------------------------------------------------------------------------
159
177
  // Incoming message dispatch
160
178
  // -------------------------------------------------------------------------
@@ -209,6 +227,15 @@ export function createCesRpcClient(
209
227
  }
210
228
  });
211
229
 
230
+ // Fail-fast on transport death: when the transport's read side ends (e.g. a
231
+ // spurious stdout EOF on a CES bounce) or the connection closes, reject every
232
+ // in-flight request immediately. The response can never arrive on a dead
233
+ // transport, so without this a pending call hangs until `requestTimeoutMs`
234
+ // (observed: 30s stalls on credential reads that raced a CES restart).
235
+ transport.onClose?.(() => {
236
+ rejectAllPending(new CesTransportError("CES transport closed"));
237
+ });
238
+
212
239
  // -------------------------------------------------------------------------
213
240
  // Send helpers
214
241
  // -------------------------------------------------------------------------
@@ -384,11 +411,7 @@ export function createCesRpcClient(
384
411
  },
385
412
 
386
413
  close(): void {
387
- for (const [id, entry] of pending) {
388
- clearTimeout(entry.timer);
389
- entry.reject(new CesTransportError("CES client closed"));
390
- pending.delete(id);
391
- }
414
+ rejectAllPending(new CesTransportError("CES client closed"));
392
415
  ready = false;
393
416
  transport.close();
394
417
  },
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared admission policy vocabulary used on the gateway→runtime wire.
3
+ *
4
+ * Both the gateway (channel admission policy storage + kill switch) and the
5
+ * runtime (admission-policy stage) consume these values. Keeping the type
6
+ * here avoids the runtime importing from `gateway/src` and avoids the
7
+ * vocabulary drift the plan §2.1 flags for the verification-purpose
8
+ * `trustClass` enum.
9
+ */
10
+
11
+ import { z } from "zod";
12
+
13
+ /**
14
+ * Per-channel inbound admission policy — ordered from most-restrictive
15
+ * (`no_one`, hard kill switch) to most-permissive (`strangers`, admits any
16
+ * sender). See `unverified-contact-role-plan.md` §2.3.
17
+ */
18
+ export const ADMISSION_POLICY_VALUES = [
19
+ "no_one",
20
+ "guardian_only",
21
+ "trusted_contacts",
22
+ "any_contact",
23
+ "strangers",
24
+ ] as const;
25
+
26
+ export type AdmissionPolicy = (typeof ADMISSION_POLICY_VALUES)[number];
27
+
28
+ export const AdmissionPolicySchema = z.enum(ADMISSION_POLICY_VALUES);
29
+
30
+ /**
31
+ * Read-side default applied when a channel has no row in the DB. Matches
32
+ * today's effective semantics: guardian + active contacts admitted,
33
+ * strangers denied. See plan §2.2.
34
+ */
35
+ export const ADMISSION_POLICY_DEFAULT: AdmissionPolicy = "trusted_contacts";
36
+
37
+ /**
38
+ * Minimum trust rank required for each policy. Higher rank = more trusted.
39
+ * `no_one` is 5 — above the maximum guardian rank (4) — so no class is ever
40
+ * admitted. See plan §2.4 for the rank table.
41
+ */
42
+ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
43
+ no_one: 5,
44
+ guardian_only: 4,
45
+ trusted_contacts: 3,
46
+ any_contact: 2,
47
+ strangers: 1,
48
+ };
49
+
50
+ /**
51
+ * Hard-exempt internal channels — never subject to PUT policy, omitted from
52
+ * GET list, runtime admission stage short-circuits without floor check.
53
+ *
54
+ * `platform` / `a2a` are peer/internal channels with no human-trust model.
55
+ *
56
+ * `phone` is NOT exempt — voice ingress enforces the admission floor.
57
+ *
58
+ * `vellum` / `whatsapp` are NOT exempt — their floors are still enforced at
59
+ * runtime — but they are hidden from the configurable UI; see
60
+ * {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
61
+ */
62
+ export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
63
+ "platform",
64
+ "a2a",
65
+ ]);
66
+
67
+ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
68
+ return ADMISSION_POLICY_EXEMPT_CHANNELS.has(channelType);
69
+ }
70
+
71
+ /**
72
+ * Channels omitted from the Channel Trust Floors list (GET) and rejected on
73
+ * PUT/DELETE — managed automatically at their seed default, not user
74
+ * configurable. Unlike {@link ADMISSION_POLICY_EXEMPT_CHANNELS} they are still
75
+ * enforced at runtime, so hiding a real inbound channel like `whatsapp` never
76
+ * silently disables its admission floor check. The startup seed re-pins any
77
+ * drifted row so a stale floor (e.g. a legacy `no_one`) can't strand a channel
78
+ * the user can no longer see.
79
+ *
80
+ * `vellum` is the local desktop/web client surface; the guardian is always
81
+ * max-rank there, so the seed default admits them regardless of the floor.
82
+ */
83
+ export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
84
+ "vellum",
85
+ "whatsapp",
86
+ ]);
87
+
88
+ export function isAdmissionPolicyHiddenChannel(channelType: string): boolean {
89
+ return ADMISSION_POLICY_HIDDEN_CHANNELS.has(channelType);
90
+ }
91
+
92
+ export function isAdmissionPolicy(value: unknown): value is AdmissionPolicy {
93
+ return (
94
+ typeof value === "string" &&
95
+ (ADMISSION_POLICY_VALUES as readonly string[]).includes(value)
96
+ );
97
+ }
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { z } from "zod";
14
14
 
15
+ import { AdmissionPolicySchema } from "./admission-policy-contract.js";
16
+
15
17
  // ---------------------------------------------------------------------------
16
18
  // Command intent (channel-initiated commands, e.g. Telegram /start)
17
19
  // ---------------------------------------------------------------------------
@@ -67,6 +69,14 @@ export const SourceMetadataSchema = z
67
69
  /** Slack workspace/team ID. */
68
70
  account: z.string().optional(),
69
71
 
72
+ /**
73
+ * Per-channel inbound admission policy attached by the gateway. The
74
+ * runtime admission-policy stage enforces the floor against the
75
+ * resolved trust class; when absent, the runtime falls back to
76
+ * `ADMISSION_POLICY_DEFAULT` (`trusted_contacts`).
77
+ */
78
+ admissionPolicy: AdmissionPolicySchema.optional(),
79
+
70
80
  // Email-specific fields
71
81
  /** Email subject line. */
72
82
  emailSubject: z.string().optional(),
@@ -19,20 +19,26 @@ export * from "./gateway-ipc-contracts.js";
19
19
 
20
20
  export { ipcCall, IpcCallError, PersistentIpcClient } from "./ipc-client.js";
21
21
 
22
+ // Outbound delivery contract (daemon → gateway) — Zod schemas + derived types
23
+ export {
24
+ ApprovalActionOptionSchema,
25
+ ApprovalUIMetadataSchema,
26
+ AttachmentMetadataSchema,
27
+ ChannelDeliveryResultSchema,
28
+ ChannelReplyPayloadSchema,
29
+ PermissionRequestDetailsSchema,
30
+ } from "./outbound-contract.js";
31
+
22
32
  export type {
23
33
  ApprovalActionOption,
24
34
  ApprovalUIMetadata,
25
35
  AttachmentMetadata,
26
36
  ChannelDeliveryResult,
27
37
  ChannelReplyPayload,
28
- IpcRequest,
29
- IpcResponse,
30
- Logger,
31
38
  PermissionRequestDetails,
32
- } from "./types.js";
33
-
34
- export { noopLogger } from "./types.js";
39
+ } from "./outbound-contract.js";
35
40
 
41
+ // Inbound contract (gateway → daemon) — Zod schemas + derived types
36
42
  export {
37
43
  CommandIntentSchema,
38
44
  RuntimeInboundPayloadSchema,
@@ -44,3 +50,23 @@ export type {
44
50
  RuntimeInboundPayload,
45
51
  SourceMetadata,
46
52
  } from "./inbound-contract.js";
53
+
54
+ // IPC, logger, and utility types
55
+ export type { IpcRequest, IpcResponse, Logger } from "./types.js";
56
+
57
+ export { noopLogger } from "./types.js";
58
+
59
+ // Admission policy contract (gateway → daemon) — Zod schemas + derived types + channel sets
60
+ export {
61
+ ADMISSION_FLOOR,
62
+ ADMISSION_POLICY_DEFAULT,
63
+ ADMISSION_POLICY_EXEMPT_CHANNELS,
64
+ ADMISSION_POLICY_HIDDEN_CHANNELS,
65
+ ADMISSION_POLICY_VALUES,
66
+ AdmissionPolicySchema,
67
+ isAdmissionPolicy,
68
+ isAdmissionPolicyExemptChannel,
69
+ isAdmissionPolicyHiddenChannel,
70
+ } from "./admission-policy-contract.js";
71
+
72
+ export type { AdmissionPolicy } from "./admission-policy-contract.js";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Daemon → gateway outbound delivery contract.
3
+ *
4
+ * Zod schemas defining the wire format for channel replies delivered from
5
+ * the daemon to the gateway via `POST /deliver/{channel}`. Both services
6
+ * import from here so the contract is enforced at compile time.
7
+ *
8
+ * The daemon constructs these payloads in `deliverChannelReply()` and
9
+ * `deliverApprovalPrompt()`; the gateway validates and dispatches them
10
+ * to the target channel provider.
11
+ */
12
+
13
+ import { z } from "zod";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Attachment metadata
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const AttachmentMetadataSchema = z.object({
20
+ id: z.string(),
21
+ filename: z.string(),
22
+ mimeType: z.string(),
23
+ sizeBytes: z.number(),
24
+ kind: z.string(),
25
+ data: z.string().optional(),
26
+ thumbnailData: z.string().optional(),
27
+ fileBacked: z.boolean().optional(),
28
+ });
29
+
30
+ export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Approval UI types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export const ApprovalActionOptionSchema = z.object({
37
+ id: z.string(),
38
+ label: z.string(),
39
+ });
40
+
41
+ export type ApprovalActionOption = z.infer<typeof ApprovalActionOptionSchema>;
42
+
43
+ export const PermissionRequestDetailsSchema = z.object({
44
+ toolName: z.string(),
45
+ riskLevel: z.string(),
46
+ toolInput: z.record(z.string(), z.unknown()),
47
+ requesterIdentifier: z.string().optional(),
48
+ });
49
+
50
+ export type PermissionRequestDetails = z.infer<
51
+ typeof PermissionRequestDetailsSchema
52
+ >;
53
+
54
+ export const ApprovalUIMetadataSchema = z.object({
55
+ requestId: z.string(),
56
+ actions: z.array(ApprovalActionOptionSchema),
57
+ plainTextFallback: z.string(),
58
+ permissionDetails: PermissionRequestDetailsSchema.optional(),
59
+ });
60
+
61
+ export type ApprovalUIMetadata = z.infer<typeof ApprovalUIMetadataSchema>;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Channel reply payload — the full outbound wire format
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export const ChannelReplyPayloadSchema = z.object({
68
+ chatId: z.string(),
69
+ text: z.string().optional(),
70
+ /** Pre-formatted Block Kit blocks for Slack delivery. */
71
+ blocks: z.array(z.unknown()).optional(),
72
+ assistantId: z.string().optional(),
73
+ attachments: z.array(AttachmentMetadataSchema).optional(),
74
+ approval: ApprovalUIMetadataSchema.optional(),
75
+ chatAction: z.literal("typing").optional(),
76
+ /**
77
+ * When true, deliver via `chat.postEphemeral` so only the target `user`
78
+ * sees the message.
79
+ */
80
+ ephemeral: z.boolean().optional(),
81
+ /** Slack user ID — required when `ephemeral` is true. */
82
+ user: z.string().optional(),
83
+ /** When provided, update an existing message instead of posting a new one. */
84
+ messageTs: z.string().optional(),
85
+ /** When true, auto-generate Block Kit blocks from text via textToBlocks(). */
86
+ useBlocks: z.boolean().optional(),
87
+ /** When provided, add or remove an emoji reaction on a message. */
88
+ reaction: z
89
+ .object({
90
+ action: z.enum(["add", "remove"]),
91
+ name: z.string(),
92
+ messageTs: z.string(),
93
+ })
94
+ .optional(),
95
+ /** When provided, set or clear the Slack Assistants API thread status. */
96
+ assistantThreadStatus: z
97
+ .object({
98
+ channel: z.string(),
99
+ threadTs: z.string(),
100
+ status: z.string(),
101
+ /** Serialized to Slack as `loading_messages`. */
102
+ loadingMessages: z.array(z.string()).optional(),
103
+ })
104
+ .optional(),
105
+ });
106
+
107
+ export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Channel delivery result — gateway response
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export const ChannelDeliveryResultSchema = z.object({
114
+ ok: z.boolean(),
115
+ /** The message timestamp returned by the delivery endpoint. */
116
+ ts: z.string().optional(),
117
+ });
118
+
119
+ export type ChannelDeliveryResult = z.infer<typeof ChannelDeliveryResultSchema>;