@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.
- package/AGENTS.md +78 -4
- package/ARCHITECTURE.md +5 -6
- package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
- package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +97 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +32 -6
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
- package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
- package/openapi.json +16 -5
- package/package.json +1 -1
- package/src/__tests__/admission-policy-store.test.ts +146 -0
- package/src/__tests__/channel-admission-policy-routes.test.ts +345 -0
- package/src/__tests__/edge-auth.test.ts +23 -0
- package/src/__tests__/edge-guardian-auth.test.ts +12 -0
- package/src/__tests__/feature-flags-route.test.ts +58 -1
- package/src/__tests__/guardian-binding-channel-reuse.test.ts +8 -20
- package/src/__tests__/handle-inbound-admission.test.ts +254 -0
- package/src/__tests__/ipc-contact-routes.test.ts +2 -5
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +2 -0
- package/src/__tests__/remote-web-ingress-denylist.test.ts +20 -1
- package/src/__tests__/remote-web-pairing-challenge.test.ts +163 -7
- package/src/__tests__/remote-web-pairing-token.test.ts +530 -0
- package/src/__tests__/remote-web-pairing-verification.test.ts +230 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/seed-admission-policy.test.ts +67 -0
- package/src/__tests__/slack-display-name.test.ts +16 -2
- package/src/__tests__/slack-socket-mode-catchup.test.ts +1 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +180 -2
- package/src/__tests__/telegram-webhook-handler.test.ts +14 -1
- package/src/__tests__/upsert-verified-contact-channel.test.ts +5 -10
- package/src/auth/guardian-bootstrap.ts +55 -23
- package/src/auth/guardian-refresh.ts +78 -38
- package/src/credential-watcher.ts +2 -2
- package/src/db/admission-policy-store.ts +212 -0
- package/src/db/connection.ts +34 -0
- package/src/db/contact-store.ts +50 -59
- package/src/db/data-migrations/m0005-normalize-contact-channel-addresses.ts +6 -51
- package/src/db/schema.ts +26 -5
- package/src/db/seed-admission-policy.ts +62 -0
- package/src/feature-flag-registry.json +46 -31
- package/src/handlers/handle-inbound.ts +37 -1
- package/src/http/browser-auth-cookies.ts +77 -0
- package/src/http/middleware/auth.ts +3 -0
- package/src/http/middleware/cors.ts +1 -1
- package/src/http/read-limited-body.ts +50 -0
- package/src/http/router.ts +31 -5
- package/src/http/routes/channel-admission-policy.ts +239 -0
- package/src/http/routes/channel-verification-session-proxy.test.ts +0 -15
- package/src/http/routes/channel-verification-session-proxy.ts +1 -71
- package/src/http/routes/contact-prompt.ts +5 -3
- package/src/http/routes/contacts-control-plane-proxy.ts +21 -12
- package/src/http/routes/feature-flags.ts +16 -3
- package/src/http/routes/guardian-channel-create.ts +12 -9
- package/src/http/routes/guardian-refresh.ts +173 -0
- package/src/http/routes/ipc-runtime-proxy.test.ts +22 -0
- package/src/http/routes/ipc-runtime-proxy.ts +20 -0
- package/src/http/routes/remote-web-pairing-challenge.ts +100 -9
- package/src/http/routes/remote-web-pairing-token.ts +122 -0
- package/src/http/routes/remote-web-pairing-verification.ts +122 -0
- package/src/http/routes/twilio-voice-verify-callback.ts +4 -7
- package/src/http/routes/twilio-voice-webhook.test.ts +120 -0
- package/src/http/routes/twilio-voice-webhook.ts +15 -0
- package/src/index.ts +144 -42
- package/src/ipc/__tests__/admission-policy-handlers.test.ts +80 -0
- package/src/ipc/admission-policy-handlers.ts +27 -0
- package/src/ipc/contact-handlers.ts +4 -2
- package/src/ipc/risk-classification-handlers.test.ts +72 -0
- package/src/ipc/risk-classification-handlers.ts +28 -1
- package/src/remote-web/pairing-challenge-rate-limit-store.ts +34 -0
- package/src/remote-web/pairing-challenge-store.ts +156 -6
- package/src/remote-web/pairing-verification-rate-limit-store.ts +57 -0
- package/src/risk/admission-policy-cache.ts +99 -0
- package/src/risk/command-registry/commands/assistant.ts +8 -18
- package/src/risk/command-registry.test.ts +1 -1
- package/src/risk/file-risk-classifier.test.ts +381 -2
- package/src/risk/file-risk-classifier.ts +245 -66
- package/src/schema.ts +107 -236
- package/src/slack/channel.test.ts +25 -0
- package/src/slack/channel.ts +35 -0
- package/src/slack/normalize.ts +8 -14
- package/src/slack/socket-mode.ts +42 -15
- package/src/telegram/send.test.ts +6 -6
- package/src/telegram/send.ts +3 -13
- package/src/verification/binding-helpers.ts +10 -9
- package/src/verification/contact-helpers.ts +24 -52
- package/src/verification/outbound-voice-verification-sync.test.ts +10 -15
- package/src/verification/outbound-voice-verification-sync.ts +3 -4
- package/src/verification/text-verification.ts +30 -21
- package/src/whatsapp/send.ts +2 -12
- package/src/__tests__/privacy-config-route.test.ts +0 -891
- 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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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 "./
|
|
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>;
|