From 151a45643f9dfc67660138ff946f421a767eb206 Mon Sep 17 00:00:00 2001 From: ogarciarevett Date: Fri, 5 Jun 2026 11:24:26 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(scheduler):=20pipeline=20notify=20?= =?UTF-8?q?=E2=80=94=20ctx.notify=20for=20proactive=20channel=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline-facing seam so a running pipeline can push a notification to the user out a connected channel — the outbound complement to the inbound chatbot flow. - PipelineContext.notify(text, opts?) gated by NETWORK_FETCH; NotifyFn injected via BuildContextDeps + SchedulerOptions where `complete` is threaded (top-level + sub-agent). Core stays decoupled (channel: string, not ChannelId). - Graceful: missing channel/destination/resolver -> {delivered:false,reason}; only a capability violation throws. - Host makeNotifyFn (CLI) resolves channel + pairing-persisted owner chatId, sends through the daemon's running handler, audits each send on events (notification_sent/notification_failed; body + chat id never logged). - config.notify.defaultChannel normalization; no migration, no new capability, no new dependency. 890 tests / 0 fail (+20); 100% line+func coverage on new units; Biome clean. --- .ai/context.md | 13 +- .ai/generated/rules.mdc | 13 +- AGENTS.md | 13 +- cycle-log.md | 43 ++++ .../orchestrator-demo/handler.test.ts | 1 + packages/pipelines/router/handler.test.ts | 3 + packages/pipelines/selftest/handler.test.ts | 3 + .../pipelines/skill-train/handler.test.ts | 3 + .../vesper-cli/src/commands/daemon-run.ts | 23 +- packages/vesper-cli/src/config.test.ts | 22 ++ packages/vesper-cli/src/config.ts | 24 ++ packages/vesper-cli/src/make-notify.test.ts | 215 ++++++++++++++++++ packages/vesper-cli/src/make-notify.ts | 94 ++++++++ packages/vesper-core/src/connections/audit.ts | 2 + .../vesper-core/src/scheduler/context.test.ts | 61 +++++ packages/vesper-core/src/scheduler/context.ts | 22 ++ packages/vesper-core/src/scheduler/index.ts | 4 + .../src/scheduler/scheduler-context.test.ts | 49 +++- .../vesper-core/src/scheduler/scheduler.ts | 11 + .../src/scheduler/subagent.test.ts | 1 + .../vesper-core/src/scheduler/subagent.ts | 4 + packages/vesper-core/src/scheduler/types.ts | 49 ++++ 22 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 packages/vesper-cli/src/make-notify.test.ts create mode 100644 packages/vesper-cli/src/make-notify.ts diff --git a/.ai/context.md b/.ai/context.md index fdd5ac4..e51e0ad 100644 --- a/.ai/context.md +++ b/.ai/context.md @@ -349,8 +349,19 @@ canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)* `@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. +**Pipeline notify (`ctx.notify`) SHIPPED.** The outbound, pipeline-initiated complement to the inbound +chatbot flow (`specs/pipeline-notify.md`): a running pipeline can push a notification to the user out a +connected channel. `PipelineContext.notify(text, opts?)` is gated by `NETWORK_FETCH` and backed by a +`NotifyFn` injected through `BuildContextDeps` + `SchedulerOptions` exactly where `complete` is threaded +(top-level + sub-agent). Core stays decoupled (`channel?: string`, not the connections `ChannelId`); the +host `makeNotifyFn` (CLI) resolves the channel + the pairing-persisted owner `defaultChatId`, sends +through the daemon's already-authenticated running handler, and audits each send on the `events` table +(`notification_sent`/`notification_failed`, body/chat id never logged) — no migration, no new capability, +no new dependency. A missing channel/destination/resolver is graceful (`{delivered:false, reason}`); only +a capability violation throws. Issue-capped: the record is the spec + `cycle-log.md` + the commit (Rule 11). + **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **870 tests / 0 fail**; Biome clean; no +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **890 tests / 0 fail**; Biome clean; no provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); diff --git a/.ai/generated/rules.mdc b/.ai/generated/rules.mdc index 3e1c884..042ec48 100644 --- a/.ai/generated/rules.mdc +++ b/.ai/generated/rules.mdc @@ -357,8 +357,19 @@ canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)* `@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. +**Pipeline notify (`ctx.notify`) SHIPPED.** The outbound, pipeline-initiated complement to the inbound +chatbot flow (`specs/pipeline-notify.md`): a running pipeline can push a notification to the user out a +connected channel. `PipelineContext.notify(text, opts?)` is gated by `NETWORK_FETCH` and backed by a +`NotifyFn` injected through `BuildContextDeps` + `SchedulerOptions` exactly where `complete` is threaded +(top-level + sub-agent). Core stays decoupled (`channel?: string`, not the connections `ChannelId`); the +host `makeNotifyFn` (CLI) resolves the channel + the pairing-persisted owner `defaultChatId`, sends +through the daemon's already-authenticated running handler, and audits each send on the `events` table +(`notification_sent`/`notification_failed`, body/chat id never logged) — no migration, no new capability, +no new dependency. A missing channel/destination/resolver is graceful (`{delivered:false, reason}`); only +a capability violation throws. Issue-capped: the record is the spec + `cycle-log.md` + the commit (Rule 11). + **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **870 tests / 0 fail**; Biome clean; no +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **890 tests / 0 fail**; Biome clean; no provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); diff --git a/AGENTS.md b/AGENTS.md index 0fc6592..be8bdbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,8 +351,19 @@ canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)* `@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. +**Pipeline notify (`ctx.notify`) SHIPPED.** The outbound, pipeline-initiated complement to the inbound +chatbot flow (`specs/pipeline-notify.md`): a running pipeline can push a notification to the user out a +connected channel. `PipelineContext.notify(text, opts?)` is gated by `NETWORK_FETCH` and backed by a +`NotifyFn` injected through `BuildContextDeps` + `SchedulerOptions` exactly where `complete` is threaded +(top-level + sub-agent). Core stays decoupled (`channel?: string`, not the connections `ChannelId`); the +host `makeNotifyFn` (CLI) resolves the channel + the pairing-persisted owner `defaultChatId`, sends +through the daemon's already-authenticated running handler, and audits each send on the `events` table +(`notification_sent`/`notification_failed`, body/chat id never logged) — no migration, no new capability, +no new dependency. A missing channel/destination/resolver is graceful (`{delivered:false, reason}`); only +a capability violation throws. Issue-capped: the record is the spec + `cycle-log.md` + the commit (Rule 11). + **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **870 tests / 0 fail**; Biome clean; no +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **890 tests / 0 fail**; Biome clean; no provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); diff --git a/cycle-log.md b/cycle-log.md index 9eb0d72..d9298b3 100644 --- a/cycle-log.md +++ b/cycle-log.md @@ -969,3 +969,46 @@ Backend->Client->Review workflow; the review's 2 real HIGH gaps were then fixed (the plugin is registered only in the daemon — the UI/daemon is the source of truth; the CLI doesn't load Baileys for a list); the compiled `vesper-desktop` binary omits whatsapp-web (dynamic import not bundled) until Launch wires it; re-pairing an already-live whatsapp-web opens a second socket (rare edge). Signal (signal-cli) still open. + +## Pipeline notify (`ctx.notify` — proactive channel delivery) — SHIPPED +- The outbound, pipeline-initiated complement to the shipped inbound `ChatSink` flow. A running pipeline can now + push a notification to the user out a connected channel. `OutboundIntent.kind:"notify"` + `ChannelHandler.send` + already existed (used only by the operator `vesper connections send`); the gap was a pipeline-facing seam. + Spec: `specs/pipeline-notify.md`. Issue-capped: this entry + the commit are the record (Rule 11). Omar approved + SPEC + PLAN at the advancement gates; chose graceful-degradation + reuse-`NETWORK_FETCH` over throw + new cap. +- DESIGN (mirror `complete`, stay decoupled): `ctx.notify(text, opts?)` on `PipelineContext`, gated by + `NETWORK_FETCH` (the egress cap `send` already needs). A `NotifyFn` is injected through `BuildContextDeps` + + `SchedulerOptions` exactly where `complete` is threaded (top-level run AND the `subagent.ts` child context). + KEY DECISION: the core `NotifyIntent`/`NotifyOutcome` use `channel?: string`, NOT the connections `ChannelId` + union — so `vesper-core/scheduler` keeps ZERO dependency on the connections feature layer (the import is + cycle-safe either way; decoupling is the better architecture). The host (`makeNotifyFn`, CLI) owns channel + identity. DIVERGENCE from `complete`: a missing resolver is GRACEFUL (`{delivered:false, reason:"unavailable"}`), + never throws — a side-channel must not crash a pipeline; only a capability violation throws. +- HOST RESOLUTION (`packages/vesper-cli/src/make-notify.ts`): channel = explicit `intent.channel` (must be running) + -> `config.notify.defaultChannel` (if running) -> first running channel with a paired owner. chatId = explicit + -> `config.connections..params.defaultChatId` (the destination scan-to-connect ALREADY persists at pairing, + `pairing-coordinator.ts#persistLinked`) — so a pipeline never handles a chat id. Sends through the daemon's + ALREADY-AUTHENTICATED running handler (`registry.list().find`), never a fresh handler (that stays the operator + `sendVia` path). Audits every actual send attempt on the `events` table (`notification_sent`/`notification_failed`, + reusing `recordConnectionEvent`, which strips `text`/body) — NO migration, payload is `{channel}` only (never the + body or chat id; a test asserts neither serializes). +- DAEMON WIRING: the Scheduler is constructed BEFORE `buildChannelRegistry`, so `makeNotifyFn` late-binds the + registry through a `getRegistry: () => channelRegistry` getter read only at notify time (`channelRegistry` is a + `let` assigned right after the registry builds). Avoided reordering the whole startup; `uiStore` was moved a few + lines up so it can be the notify-audit sink passed into the constructor. +- SPEC DELTA (the one deviation): the spec's acceptance said `normalizeNotify` "SHALL surface a dropped-record + warning". The codebase has NO warnings channel in `config.ts` — `normalizePresence`/`normalizeConnection` all + SILENTLY drop malformed input. Matched that precedent (drop, never throw) rather than invent a one-off warning + path; behavior is otherwise identical (unknown/non-string `defaultChannel` dropped). Reconcile the contract + wording if a warnings channel is ever added. +- GOTCHA: adding `notify` to the `PipelineContext` interface broke 5 hand-rolled context mocks in pipeline + + subagent tests (tsc: "Property 'notify' is missing") — they had no notify stub. Fixed with a one-line + `notify: async () => ({ delivered:false })` per mock. A reminder that widening a core interface ripples into + every hand-rolled test double; a shared `fakeContext` factory would localize this (follow-up). +- Verified: 890 tests / 0 fail (+20: 5 context + 2 scheduler-context + 4 config + 9 make-notify); 100% line+func + coverage on the two new units; biome clean (exit 0); tsc adds 0 NEW errors (the 5 mock errors fixed; pre-existing + exactOptional/`as`-cast errors in unchanged code remain, CI skips tsc); NO new dependency; NO migration; NO new + capability; transport mocked end-to-end (suite sends to nothing). NOT exercised against a live channel. +- FOLLOW-UPS: rate-limiting/anti-spam on notifications (declared out-of-scope; every send is audited so abuse is + visible); rich/structured messages (plain text only in v1); a shared `fakeContext` test factory; downstream + consumers can now wire delivery (`pipeline-career.md`, `pipeline-secretary.md`) onto `ctx.notify`. diff --git a/packages/pipelines/orchestrator-demo/handler.test.ts b/packages/pipelines/orchestrator-demo/handler.test.ts index 8b14e15..41d8170 100644 --- a/packages/pipelines/orchestrator-demo/handler.test.ts +++ b/packages/pipelines/orchestrator-demo/handler.test.ts @@ -130,6 +130,7 @@ describe("orchestrator-demo pipeline", () => { readSignals: () => { throw new Error("unused"); }, + notify: async () => ({ delivered: false }), }); expect(recorded).toEqual([{ status: "ok", summary: "research complete" }]); diff --git a/packages/pipelines/router/handler.test.ts b/packages/pipelines/router/handler.test.ts index 966f368..cacdea8 100644 --- a/packages/pipelines/router/handler.test.ts +++ b/packages/pipelines/router/handler.test.ts @@ -101,6 +101,9 @@ function makeFakeContext(options: { readSignals() { throw new Error("readSignals is not supported in this fake context"); }, + async notify() { + return { delivered: false }; + }, }; return { ctx, completePrompts, spawned, recordedRuns, progress }; diff --git a/packages/pipelines/selftest/handler.test.ts b/packages/pipelines/selftest/handler.test.ts index 3752f7e..6fe8c3e 100644 --- a/packages/pipelines/selftest/handler.test.ts +++ b/packages/pipelines/selftest/handler.test.ts @@ -84,6 +84,9 @@ function makeFakeContext(options: { readSignals() { throw new Error("readSignals is not supported in this fake context"); }, + async notify() { + return { delivered: false }; + }, }; return { ctx, completeCalls, recordedRuns }; diff --git a/packages/pipelines/skill-train/handler.test.ts b/packages/pipelines/skill-train/handler.test.ts index 1a78be2..a4b6217 100644 --- a/packages/pipelines/skill-train/handler.test.ts +++ b/packages/pipelines/skill-train/handler.test.ts @@ -47,6 +47,9 @@ function makeCtx(params: Record): { readSignals() { throw new Error("readSignals is not supported in this fake context"); }, + async notify() { + return { delivered: false }; + }, }; return { ctx, recorded }; } diff --git a/packages/vesper-cli/src/commands/daemon-run.ts b/packages/vesper-cli/src/commands/daemon-run.ts index 79004b0..ce6be92 100644 --- a/packages/vesper-cli/src/commands/daemon-run.ts +++ b/packages/vesper-cli/src/commands/daemon-run.ts @@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"; import { mkdir } from "node:fs/promises"; import { ApprovalTokenStore, + type ChannelRegistry, channelStates, DEFAULT_AGENT_MATCHERS, detectAvailableCLIs, @@ -19,6 +20,7 @@ import { loadConfig, saveConfig } from "../config.ts"; import { buildChannelRegistry, makeChannelSink } from "../connections-wiring.ts"; import { removePidFile, resolveDaemonState, writePidFile } from "../daemon-lifecycle.ts"; import type { Command } from "../dispatch.ts"; +import { makeNotifyFn } from "../make-notify.ts"; import { loadOptionalChannels } from "../optional-channels.ts"; import { PairingCoordinator } from "../pairing-coordinator.ts"; import { dbPath, pidPath, runDir, socketPath, uiPort } from "../paths.ts"; @@ -62,20 +64,29 @@ export const daemonRunCommand: Command = { const installed = await detectAvailableCLIs(); const complete = makeCompleteFn(config, installed); + // The UI store also serves the router's editable template default_params (#4) + // and is the audit sink for `ctx.notify`; opened before the scheduler so the + // notify resolver can be wired into the constructor. + const uiStore = openStore(dbPath()); + + // `ctx.notify` resolver: delivers a pipeline notification out a connected + // channel. The channel registry is built further below (after the scheduler), so + // the resolver late-binds it through a getter read only at notify time. + let channelRegistry: ChannelRegistry | undefined; + const notify = makeNotifyFn({ getRegistry: () => channelRegistry, config, store: uiStore }); + // Construct the Scheduler granting only the capabilities the built-in - // pipelines actually declare (deny-by-default), with the CLI resolver, then - // register the pipelines so their handlers + tasks are available to the tick loop. + // pipelines actually declare (deny-by-default), with the CLI + notify resolvers, + // then register the pipelines so their handlers + tasks are available to the tick loop. const registry = new HandlerRegistry(); const scheduler = new Scheduler({ db, registry, grants: grantedCapabilities(), complete, + notify, redactSummaries: config.storage?.redactRunSummaries === true, }); - // The UI store is opened first so the router can read editable template - // default_params through it (#4) — an edited template then affects its runs. - const uiStore = openStore(dbPath()); registerPipelines(scheduler, registry, { getDefaultParams: (handlerId) => uiStore.getTemplate(handlerId)?.defaultParams ?? {}, }); @@ -104,6 +115,8 @@ export const daemonRunCommand: Command = { vault, store: uiStore, }); + // Late-bind the live registry into the notify resolver (declared before the scheduler). + channelRegistry = channels.registry; // Pairing (scan-to-connect): the coordinator multiplexes the daemon's single // inbound stream into active QR/link pairing sessions and persists the captured // chat id on link. Exposed to the UI's POST /api/connections/:id/pair route and diff --git a/packages/vesper-cli/src/config.test.ts b/packages/vesper-cli/src/config.test.ts index 0670e8d..4d15e48 100644 --- a/packages/vesper-cli/src/config.test.ts +++ b/packages/vesper-cli/src/config.test.ts @@ -155,6 +155,28 @@ describe("normalizeConfig — connections", () => { }); }); +describe("normalizeConfig — notify", () => { + test("keeps a defaultChannel that names a catalog channel", () => { + expect(normalizeConfig({ notify: { defaultChannel: "telegram" } }).notify).toEqual({ + defaultChannel: "telegram", + }); + }); + + test("drops a defaultChannel that is not a catalog channel", () => { + expect(normalizeConfig({ notify: { defaultChannel: "slack" } }).notify).toBeUndefined(); + }); + + test("drops a non-string defaultChannel", () => { + expect(normalizeConfig({ notify: { defaultChannel: 5 } }).notify).toBeUndefined(); + }); + + test("omits notify entirely when absent or malformed", () => { + expect(normalizeConfig({ cli: { adapters: {} } }).notify).toBeUndefined(); + expect(normalizeConfig({ notify: "nope" }).notify).toBeUndefined(); + expect(normalizeConfig({ notify: {} }).notify).toBeUndefined(); + }); +}); + describe("loadConfig / saveConfig", () => { test("missing file yields the default", async () => { expect(await loadConfig(tempConfigPath())).toEqual(DEFAULT_CONFIG); diff --git a/packages/vesper-cli/src/config.ts b/packages/vesper-cli/src/config.ts index f4cc63c..b085ca4 100644 --- a/packages/vesper-cli/src/config.ts +++ b/packages/vesper-cli/src/config.ts @@ -47,6 +47,15 @@ export interface VesperConfig { }; /** Per-channel messaging wiring (Connections). Secrets stay in the vault. */ readonly connections?: Readonly>; + /** Proactive-notification routing for `ctx.notify` (pipeline -> connected channel). */ + readonly notify?: { + /** + * Catalog channel id `ctx.notify` delivers through when a pipeline names none. + * When unset the host resolves the first enabled+running channel with a paired + * destination. An unknown id is dropped during normalization. + */ + readonly defaultChannel?: string; + }; } /** A fresh config with no default and no overrides. */ @@ -161,6 +170,19 @@ function normalizeConnections(raw: unknown): VesperConfig["connections"] | undef return Object.keys(result).length > 0 ? result : undefined; } +/** + * Coerce untrusted `notify` config. Keeps `defaultChannel` only when it names a + * known catalog channel (mirrors `normalizeConnection`'s catalog gate); an unknown + * or non-string id is dropped, never thrown. Returns undefined when nothing valid + * remains so the host falls back to first-eligible resolution. + */ +function normalizeNotify(raw: unknown): VesperConfig["notify"] | undefined { + if (!isObject(raw)) return undefined; + const defaultChannel = asString(raw.defaultChannel); + if (defaultChannel === undefined || channelById(defaultChannel) === undefined) return undefined; + return { defaultChannel }; +} + /** Coerce untrusted `ui` config; keeps only a string `theme`. */ function normalizeUi(raw: unknown): VesperConfig["ui"] | undefined { if (!isObject(raw)) return undefined; @@ -195,6 +217,8 @@ export function normalizeConfig(raw: unknown): VesperConfig { if (ui !== undefined) result = { ...result, ui }; const connections = normalizeConnections(raw.connections); if (connections !== undefined) result = { ...result, connections }; + const notify = normalizeNotify(raw.notify); + if (notify !== undefined) result = { ...result, notify }; return result; } diff --git a/packages/vesper-cli/src/make-notify.test.ts b/packages/vesper-cli/src/make-notify.test.ts new file mode 100644 index 0000000..4ca0d75 --- /dev/null +++ b/packages/vesper-cli/src/make-notify.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; +import { + type ChannelHandler, + type ChannelId, + ChannelRegistry, + type OutboundIntent, + openStore, + type Store, +} from "@vesper/core"; +import type { VesperConfig } from "./config.ts"; +import { makeNotifyFn } from "./make-notify.ts"; + +/** A fake channel handler that records outbound sends and may throw on send. */ +function fakeHandler( + id: ChannelId, + opts: { throwOnSend?: boolean } = {}, +): { handler: ChannelHandler; sends: OutboundIntent[] } { + const sends: OutboundIntent[] = []; + const handler: ChannelHandler = { + descriptor: { + id, + displayName: id, + transport: "long-poll", + allowedHosts: ["example.com"], + vaultKeys: [], + docsUrl: "https://example.com", + status: "ready", + }, + authenticate: async () => {}, + send: async (intent) => { + if (opts.throwOnSend) throw new Error("transport down"); + sends.push(intent); + }, + receive: () => ({ stop() {} }), + }; + return { handler, sends }; +} + +function registryWith(...handlers: ChannelHandler[]): ChannelRegistry { + return new ChannelRegistry(handlers); +} + +/** Minimal config with a connections block keyed by channel. */ +function config(opts: { + connections?: VesperConfig["connections"]; + notify?: VesperConfig["notify"]; +}): VesperConfig { + return { + cli: { adapters: {} }, + ...(opts.connections !== undefined ? { connections: opts.connections } : {}), + ...(opts.notify !== undefined ? { notify: opts.notify } : {}), + }; +} + +function memStore(): Store { + const store = openStore(":memory:"); + store.migrate(); + return store; +} + +describe("makeNotifyFn — resolution", () => { + test("no_channel when the registry is not yet built", async () => { + const notify = makeNotifyFn({ getRegistry: () => undefined, config: config({}) }); + expect(await notify({ text: "hi" })).toEqual({ delivered: false, reason: "no_channel" }); + }); + + test("no_channel when no channel is running", async () => { + const notify = makeNotifyFn({ getRegistry: () => registryWith(), config: config({}) }); + expect(await notify({ text: "hi" })).toEqual({ delivered: false, reason: "no_channel" }); + }); + + test("no_channel when an explicitly requested channel is not running", async () => { + const notify = makeNotifyFn({ + getRegistry: () => registryWith(fakeHandler("telegram").handler), + config: config({ + connections: { + telegram: { + enabled: true, + vaultKey: "k", + allowedHosts: [], + params: { defaultChatId: "1" }, + }, + }, + }), + }); + expect(await notify({ text: "hi", channel: "discord" })).toEqual({ + delivered: false, + reason: "no_channel", + }); + }); + + test("no_destination when the resolved channel has no paired chat id", async () => { + const notify = makeNotifyFn({ + getRegistry: () => registryWith(fakeHandler("telegram").handler), + config: config({ + connections: { telegram: { enabled: true, vaultKey: "k", allowedHosts: [] } }, + notify: { defaultChannel: "telegram" }, + }), + }); + expect(await notify({ text: "hi" })).toEqual({ + delivered: false, + channel: "telegram", + reason: "no_destination", + }); + }); + + test("prefers config.notify.defaultChannel when running", async () => { + const tg = fakeHandler("telegram"); + const dc = fakeHandler("discord"); + const notify = makeNotifyFn({ + getRegistry: () => registryWith(tg.handler, dc.handler), + config: config({ + connections: { + telegram: { + enabled: true, + vaultKey: "k", + allowedHosts: [], + params: { defaultChatId: "111" }, + }, + discord: { + enabled: true, + vaultKey: "k", + allowedHosts: [], + params: { defaultChatId: "222" }, + }, + }, + notify: { defaultChannel: "discord" }, + }), + }); + const outcome = await notify({ text: "hi" }); + expect(outcome).toEqual({ delivered: true, channel: "discord" }); + expect(dc.sends).toEqual([{ kind: "notify", chatId: "222", text: "hi" }]); + expect(tg.sends).toHaveLength(0); + }); + + test("falls back to the first running channel with a paired destination", async () => { + const tg = fakeHandler("telegram"); + const notify = makeNotifyFn({ + getRegistry: () => registryWith(tg.handler), + config: config({ + connections: { + telegram: { + enabled: true, + vaultKey: "k", + allowedHosts: [], + params: { defaultChatId: "111" }, + }, + }, + }), + }); + expect(await notify({ text: "yo" })).toEqual({ delivered: true, channel: "telegram" }); + expect(tg.sends).toEqual([{ kind: "notify", chatId: "111", text: "yo" }]); + }); + + test("an explicit chatId overrides the paired default", async () => { + const tg = fakeHandler("telegram"); + const notify = makeNotifyFn({ + getRegistry: () => registryWith(tg.handler), + config: config({ + connections: { + telegram: { + enabled: true, + vaultKey: "k", + allowedHosts: [], + params: { defaultChatId: "111" }, + }, + }, + }), + }); + await notify({ text: "yo", channel: "telegram", chatId: "999" }); + expect(tg.sends).toEqual([{ kind: "notify", chatId: "999", text: "yo" }]); + }); +}); + +describe("makeNotifyFn — delivery + audit", () => { + const conns: VesperConfig["connections"] = { + telegram: { enabled: true, vaultKey: "k", allowedHosts: [], params: { defaultChatId: "111" } }, + }; + + test("send_failed (never throws) when the handler throws, and audits the failure", async () => { + const store = memStore(); + const notify = makeNotifyFn({ + getRegistry: () => registryWith(fakeHandler("telegram", { throwOnSend: true }).handler), + config: config({ connections: conns }), + store, + }); + expect(await notify({ text: "boom" })).toEqual({ + delivered: false, + channel: "telegram", + reason: "send_failed", + }); + const events = store.listEvents({ source: "connections" }); + expect(events.map((e) => e.kind)).toEqual(["notification_failed"]); + store.close(); + }); + + test("audits notification_sent with the channel but never the body or chat id", async () => { + const store = memStore(); + const notify = makeNotifyFn({ + getRegistry: () => registryWith(fakeHandler("telegram").handler), + config: config({ connections: conns }), + store, + }); + await notify({ text: "a private message body" }); + const events = store.listEvents({ source: "connections" }); + expect(events).toHaveLength(1); + expect(events[0]?.kind).toBe("notification_sent"); + expect(events[0]?.payload).toEqual({ channel: "telegram" }); + // The body and the destination must not appear anywhere in the serialized row. + const serialized = JSON.stringify(events[0]); + expect(serialized).not.toContain("a private message body"); + expect(serialized).not.toContain("111"); + store.close(); + }); +}); diff --git a/packages/vesper-cli/src/make-notify.ts b/packages/vesper-cli/src/make-notify.ts new file mode 100644 index 0000000..13f0126 --- /dev/null +++ b/packages/vesper-cli/src/make-notify.ts @@ -0,0 +1,94 @@ +/** + * Host-side resolver for `ctx.notify` (the pipeline-facing proactive-notification + * seam). The scheduler core exposes `ctx.notify(text)` but stays decoupled from the + * connections feature layer; this factory closes the gap: it resolves WHICH channel + * and WHICH destination a notification goes to, then delivers it through the daemon's + * already-authenticated running handler — no re-auth, no second socket. + * + * Destination resolution mirrors the spec: an explicit `intent.channel` wins, then + * `config.notify.defaultChannel`, then the first running channel that has a paired + * owner (`config.connections..params.defaultChatId`, persisted at pairing). The + * owner chat id comes from that same pairing-captured `defaultChatId`, so a pipeline + * never handles a chat id. Every actual send attempt is audited on the `events` + * table (`notification_sent` / `notification_failed`); the body/chat id never land in + * the audit payload (the connections audit helper strips them). + */ + +import { + type ChannelRegistry, + type NotifyFn, + type NotifyIntent, + type NotifyOutcome, + recordConnectionEvent, + type Store, +} from "@vesper/core"; +import type { VesperConfig } from "./config.ts"; + +/** Dependencies for {@link makeNotifyFn}. */ +export interface MakeNotifyFnOpts { + /** + * Late-bound getter for the daemon's live channel registry. A getter (not the + * registry itself) because the daemon constructs the scheduler BEFORE it builds + * the registry; the getter is read at notify time, by which point it is set. + */ + readonly getRegistry: () => ChannelRegistry | undefined; + /** Non-secret wiring: `notify.defaultChannel` + per-channel `params.defaultChatId`. */ + readonly config: VesperConfig; + /** Audit sink (the daemon store). Omitted in unit tests that do not assert audit. */ + readonly store?: Store; +} + +/** + * Resolve which channel a notify delivers through. An explicit request wins (only + * if it is actually running); then a configured `defaultChannel` (if running); else + * the first running channel that has a paired owner destination. Returns undefined + * when nothing is eligible. + */ +function resolveChannel( + requested: string | undefined, + config: VesperConfig, + registry: ChannelRegistry, +): string | undefined { + const running = new Set(registry.list().map((h) => h.descriptor.id)); + if (requested !== undefined) return running.has(requested) ? requested : undefined; + const preferred = config.notify?.defaultChannel; + if (preferred !== undefined && running.has(preferred)) return preferred; + for (const handler of registry.list()) { + const id = handler.descriptor.id; + if (config.connections?.[id]?.params?.defaultChatId !== undefined) return id; + } + return undefined; +} + +/** Build the {@link NotifyFn} injected into the daemon's {@link import("@vesper/core").Scheduler}. */ +export function makeNotifyFn(opts: MakeNotifyFnOpts): NotifyFn { + return async (intent: NotifyIntent): Promise => { + const registry = opts.getRegistry(); + if (registry === undefined) return { delivered: false, reason: "no_channel" }; + + const channel = resolveChannel(intent.channel, opts.config, registry); + if (channel === undefined) return { delivered: false, reason: "no_channel" }; + + const chatId = intent.chatId ?? opts.config.connections?.[channel]?.params?.defaultChatId; + if (chatId === undefined) return { delivered: false, channel, reason: "no_destination" }; + + const handler = registry.list().find((h) => h.descriptor.id === channel); + if (handler === undefined) return { delivered: false, channel, reason: "no_channel" }; + + try { + await handler.send({ kind: "notify", chatId, text: intent.text }); + } catch { + if (opts.store !== undefined) { + recordConnectionEvent(opts.store, "notification_failed", { + channel, + reason: "send_failed", + }); + } + return { delivered: false, channel, reason: "send_failed" }; + } + if (opts.store !== undefined) { + recordConnectionEvent(opts.store, "notification_sent", { channel }); + } + return { delivered: true, channel }; + }; +} diff --git a/packages/vesper-core/src/connections/audit.ts b/packages/vesper-core/src/connections/audit.ts index 8611e86..6ea75eb 100644 --- a/packages/vesper-core/src/connections/audit.ts +++ b/packages/vesper-core/src/connections/audit.ts @@ -17,6 +17,8 @@ export type ConnectionEventKind = | "connection_pairing_started" | "connection_paired" | "connection_pairing_failed" + | "notification_sent" + | "notification_failed" | "mcp_enabled" | "mcp_disabled"; diff --git a/packages/vesper-core/src/scheduler/context.test.ts b/packages/vesper-core/src/scheduler/context.test.ts index 2a51357..77e9899 100644 --- a/packages/vesper-core/src/scheduler/context.test.ts +++ b/packages/vesper-core/src/scheduler/context.test.ts @@ -14,6 +14,8 @@ import { EventBus, RUN_EVENT } from "./events.ts"; import type { Capability, CompleteFn, + NotifyFn, + NotifyIntent, PipelineContext, ScheduledTask, SubAgentHandle, @@ -580,6 +582,65 @@ describe("buildPipelineContext.readSignals", () => { }); }); +describe("buildPipelineContext.notify", () => { + test("throws CapabilityError when NETWORK_FETCH is not declared", async () => { + const { store } = makeStore(); + const ctx = build({ + task: makeTask(["WRITE_STORAGE"]), + now: NOW, + store, + notify: async () => ({ delivered: true }), + }); + await expect(ctx.notify("hi")).rejects.toBeInstanceOf(CapabilityError); + }); + + test("does not invoke the resolver when the capability is denied", async () => { + const { store } = makeStore(); + let calls = 0; + const notify: NotifyFn = async () => { + calls++; + return { delivered: true }; + }; + const ctx = build({ task: makeTask([]), now: NOW, store, notify }); + await expect(ctx.notify("hi")).rejects.toBeInstanceOf(CapabilityError); + expect(calls).toBe(0); + }); + + test("returns unavailable (never throws) when no resolver is configured", async () => { + const { store } = makeStore(); + const ctx = build({ task: makeTask(["NETWORK_FETCH"]), now: NOW, store }); + expect(await ctx.notify("hi")).toEqual({ delivered: false, reason: "unavailable" }); + }); + + test("delegates to the resolver and returns its outcome", async () => { + const { store } = makeStore(); + let seen: NotifyIntent | undefined; + const notify: NotifyFn = async (intent) => { + seen = intent; + return { delivered: true, channel: "telegram" }; + }; + const ctx = build({ task: makeTask(["NETWORK_FETCH"]), now: NOW, store, notify }); + const outcome = await ctx.notify("done", { channel: "telegram", chatId: "42" }); + expect(seen).toEqual({ text: "done", channel: "telegram", chatId: "42" }); + expect(outcome).toEqual({ delivered: true, channel: "telegram" }); + }); + + test("omits channel/chatId from the intent when not supplied", async () => { + const { store } = makeStore(); + let seen: NotifyIntent | undefined; + const notify: NotifyFn = async (intent) => { + seen = intent; + return { delivered: false, reason: "no_channel" }; + }; + const ctx = build({ task: makeTask(["NETWORK_FETCH"]), now: NOW, store, notify }); + const outcome = await ctx.notify("ping"); + expect(seen).toEqual({ text: "ping" }); + expect(Object.hasOwn(seen ?? {}, "channel")).toBe(false); + expect(Object.hasOwn(seen ?? {}, "chatId")).toBe(false); + expect(outcome.delivered).toBe(false); + }); +}); + describe("redactSummary", () => { test("replaces content with a size-only marker", () => { expect(redactSummary("hello")).toBe("[redacted: 5 chars]"); diff --git a/packages/vesper-core/src/scheduler/context.ts b/packages/vesper-core/src/scheduler/context.ts index 951d816..6060121 100644 --- a/packages/vesper-core/src/scheduler/context.ts +++ b/packages/vesper-core/src/scheduler/context.ts @@ -11,6 +11,7 @@ import type { TaskPersistence } from "./persistence.ts"; import type { HandlerRegistry } from "./registry.ts"; import type { CompleteFn, + NotifyFn, PipelineContext, RunOptions, ScheduledTask, @@ -61,6 +62,13 @@ export interface BuildContextDeps { * {@link PipelineContext.complete} throws a clear {@link CLIError}. */ readonly complete?: CompleteFn; + /** + * Resolver that delivers a pipeline notification out a connected channel. + * Injected by the host. When absent, `ctx.notify` resolves to + * `{ delivered:false, reason:"unavailable" }` — a missing side-channel must not + * crash a pipeline. + */ + readonly notify?: NotifyFn; /** Per-run overrides (manual run): transient CLI override + params. */ readonly options?: RunOptions; /** @@ -215,6 +223,20 @@ export function buildPipelineContext(deps: BuildContextDeps): PipelineContext { { sinceMs }, ); }, + + async notify(text, opts) { + assertCapabilities(["NETWORK_FETCH"], task.required_capabilities); + // A missing resolver is graceful: a notification is a side-channel, not the + // pipeline's reason to exist (contrast `complete`, which throws when unset). + if (deps.notify === undefined) { + return { delivered: false, reason: "unavailable" }; + } + return deps.notify({ + text, + ...(opts?.channel !== undefined ? { channel: opts.channel } : {}), + ...(opts?.chatId !== undefined ? { chatId: opts.chatId } : {}), + }); + }, }; return self; diff --git a/packages/vesper-core/src/scheduler/index.ts b/packages/vesper-core/src/scheduler/index.ts index ac5128e..ceca2aa 100644 --- a/packages/vesper-core/src/scheduler/index.ts +++ b/packages/vesper-core/src/scheduler/index.ts @@ -18,6 +18,10 @@ export { remainingBudgetMs, withTimeout } from "./timeout.ts"; export type { CompleteFn, FailedTask, + NotifyFailReason, + NotifyFn, + NotifyIntent, + NotifyOutcome, PipelineContext, ProgressEvent, ProgressKind, diff --git a/packages/vesper-core/src/scheduler/scheduler-context.test.ts b/packages/vesper-core/src/scheduler/scheduler-context.test.ts index d93e0a9..357d0ee 100644 --- a/packages/vesper-core/src/scheduler/scheduler-context.test.ts +++ b/packages/vesper-core/src/scheduler/scheduler-context.test.ts @@ -7,7 +7,7 @@ import { RUN_COMPLETED } from "./events.ts"; import { TaskPersistence } from "./persistence.ts"; import { HandlerRegistry } from "./registry.ts"; import { Scheduler } from "./scheduler.ts"; -import type { CompleteFn, RunOutcome } from "./types.ts"; +import type { CompleteFn, NotifyIntent, NotifyOutcome, RunOutcome } from "./types.ts"; // --------------------------------------------------------------------------- // Helpers — an in-memory DB with migrations applied, plus a recording resolver. @@ -94,6 +94,53 @@ describe("Scheduler — pipeline runtime context", () => { expect(task?.last_error).toBeNull(); }); + test("ctx.notify reaches the injected resolver and returns its outcome", async () => { + const seen: NotifyIntent[] = []; + const notify = async (intent: NotifyIntent): Promise => { + seen.push(intent); + return { delivered: true, channel: "telegram" }; + }; + let handlerOutcome: NotifyOutcome | undefined; + registry.register("notifier", async (ctx) => { + handlerOutcome = await ctx.notify("run finished"); + ctx.recordRun({ status: "ok", summary: "notified" }); + }); + + const scheduler = new Scheduler({ db, registry, grants: CAPABILITIES, notify }); + scheduler.register({ + id: "notifier", + kind: "manual", + schedule_expr: "", + handler_id: "notifier", + required_capabilities: ["NETWORK_FETCH", "WRITE_STORAGE"], + }); + + await scheduler.run("notifier"); + + expect(seen).toEqual([{ text: "run finished" }]); + expect(handlerOutcome).toEqual({ delivered: true, channel: "telegram" }); + }); + + test("ctx.notify yields unavailable (never throws) when no resolver is injected", async () => { + let handlerOutcome: NotifyOutcome | undefined; + registry.register("notifier", async (ctx) => { + handlerOutcome = await ctx.notify("hello"); + ctx.recordRun({ status: "ok", summary: "" }); + }); + + const scheduler = new Scheduler({ db, registry, grants: CAPABILITIES }); + scheduler.register({ + id: "notifier", + kind: "manual", + schedule_expr: "", + handler_id: "notifier", + required_capabilities: ["NETWORK_FETCH", "WRITE_STORAGE"], + }); + + await scheduler.run("notifier"); + expect(handlerOutcome).toEqual({ delivered: false, reason: "unavailable" }); + }); + test("a handler that records a run then throws keeps the recorded status (no error clobber)", async () => { registry.register("recorder", async (ctx) => { ctx.recordRun({ status: "partial", summary: "committed before failure" }); diff --git a/packages/vesper-core/src/scheduler/scheduler.ts b/packages/vesper-core/src/scheduler/scheduler.ts index b6a1f4c..50fd66d 100644 --- a/packages/vesper-core/src/scheduler/scheduler.ts +++ b/packages/vesper-core/src/scheduler/scheduler.ts @@ -15,6 +15,7 @@ import { runSubAgent } from "./subagent.ts"; import { withTimeout } from "./timeout.ts"; import type { CompleteFn, + NotifyFn, PipelineContext, RegisterTaskInput, RunOptions, @@ -49,6 +50,12 @@ export interface SchedulerOptions { * a clear {@link import("../cli/errors.ts").CLIError}. */ readonly complete?: CompleteFn; + /** + * Resolver used by `ctx.notify` to deliver a proactive message out a connected + * channel. Injected by the host (CLI layer). If omitted, `ctx.notify` resolves + * to `{ delivered:false, reason:"unavailable" }` (never throws). + */ + readonly notify?: NotifyFn; /** * When true, run summaries are persisted as size-only metadata (raw CLI output * is never stored in cleartext). Host policy from `~/.vesper/config.json`. @@ -110,6 +117,7 @@ export class Scheduler { readonly #grants: readonly Capability[]; readonly #store: Store; readonly #complete: CompleteFn | undefined; + readonly #notify: NotifyFn | undefined; readonly #redactSummaries: boolean; readonly #maxFanout: number; @@ -139,6 +147,7 @@ export class Scheduler { this.#grants = options.grants ?? []; this.#store = new SqliteStore(options.db); this.#complete = options.complete; + this.#notify = options.notify; this.#redactSummaries = options.redactSummaries ?? false; this.#maxFanout = Math.max(1, options.maxFanout ?? DEFAULT_MAX_FANOUT); @@ -503,6 +512,7 @@ export class Scheduler { }, redactSummaries: this.#redactSummaries, ...(this.#complete !== undefined ? { complete: this.#complete } : {}), + ...(this.#notify !== undefined ? { notify: this.#notify } : {}), ...(options !== undefined ? { options } : {}), }); @@ -642,6 +652,7 @@ export class Scheduler { grants: this.#grants, parentTaskCapabilities: parentTask.required_capabilities, ...(this.#complete !== undefined ? { complete: this.#complete } : {}), + ...(this.#notify !== undefined ? { notify: this.#notify } : {}), redactSummaries: this.#redactSummaries, parentRemainingMs, depth: 0, diff --git a/packages/vesper-core/src/scheduler/subagent.test.ts b/packages/vesper-core/src/scheduler/subagent.test.ts index 6e04569..77d6e15 100644 --- a/packages/vesper-core/src/scheduler/subagent.test.ts +++ b/packages/vesper-core/src/scheduler/subagent.test.ts @@ -157,6 +157,7 @@ describe("ctx.spawn — sub-agent orchestration", () => { readSignals: () => { throw new Error("unused"); }, + notify: async () => ({ delivered: false }), }; expect(() => diff --git a/packages/vesper-core/src/scheduler/subagent.ts b/packages/vesper-core/src/scheduler/subagent.ts index 097176d..6895cff 100644 --- a/packages/vesper-core/src/scheduler/subagent.ts +++ b/packages/vesper-core/src/scheduler/subagent.ts @@ -27,6 +27,7 @@ import type { HandlerRegistry } from "./registry.ts"; import { remainingBudgetMs, withTimeout } from "./timeout.ts"; import type { CompleteFn, + NotifyFn, PipelineContext, RunOutcome, ScheduledTask, @@ -47,6 +48,7 @@ export interface RunSubAgentArgs { /** Parent task's grant — descriptor caps must be a subset of this. */ readonly parentTaskCapabilities: readonly Capability[]; readonly complete?: CompleteFn; + readonly notify?: NotifyFn; readonly redactSummaries: boolean; /** Time the parent still has before ITS cap fires; null = unbounded. */ readonly parentRemainingMs: number | null; @@ -72,6 +74,7 @@ export function runSubAgent(args: RunSubAgentArgs): SubAgentHandle { grants, parentTaskCapabilities, complete, + notify, redactSummaries, parentRemainingMs, depth, @@ -155,6 +158,7 @@ export function runSubAgent(args: RunSubAgentArgs): SubAgentHandle { parentTaskCapabilities: descriptorCaps, maxFanout, ...(complete !== undefined ? { complete } : {}), + ...(notify !== undefined ? { notify } : {}), // Thread the descriptor's params through to the child's `ctx.params`, so a // parent can parameterize each sub-agent it fans out. ...(descriptor.params !== undefined ? { options: { params: descriptor.params } } : {}), diff --git a/packages/vesper-core/src/scheduler/types.ts b/packages/vesper-core/src/scheduler/types.ts index 828c5c9..d8721e2 100644 --- a/packages/vesper-core/src/scheduler/types.ts +++ b/packages/vesper-core/src/scheduler/types.ts @@ -75,6 +75,41 @@ export type CompleteFn = ( opts?: { readonly cli?: string }, ) => Promise; +/** + * A proactive notification a pipeline asks the host to deliver out a connected + * messaging channel (the outbound complement to the inbound chatbot flow). + * `channel`/`chatId` are host concerns: when omitted the host resolves the + * configured default channel and the paired owner destination. `channel` is a + * plain string here so `vesper-core/scheduler` stays decoupled from the + * connections feature layer — the host validates it against the channel catalog. + */ +export interface NotifyIntent { + readonly text: string; + readonly channel?: string; + readonly chatId?: string; +} + +/** Why a {@link NotifyOutcome} did not deliver (`delivered === false`). */ +export type NotifyFailReason = "unavailable" | "no_channel" | "no_destination" | "send_failed"; + +/** The result of a {@link PipelineContext.notify} call. */ +export interface NotifyOutcome { + readonly delivered: boolean; + /** The channel id the host resolved/used, when one was chosen. */ + readonly channel?: string; + /** Set only when `delivered` is false. */ + readonly reason?: NotifyFailReason; +} + +/** + * Resolver that delivers a pipeline notification through a connected channel. + * Injected into the {@link import("./scheduler.ts").Scheduler} by the host (CLI + * layer) so `vesper-core` never imports channel/registry/config code. It returns + * an outcome and NEVER throws for a missing channel/destination — a side-channel + * must not crash a pipeline (contrast {@link CompleteFn}, which is load-bearing). + */ +export type NotifyFn = (intent: NotifyIntent) => Promise; + /** Overrides for a single manual run (transient — not stored on the task). */ export interface RunOptions { /** Per-run CLI override (highest priority during adapter resolution). */ @@ -152,6 +187,7 @@ export interface ProgressEvent { * - `emitProgress` (`WRITE_STORAGE`) — persists a live-trace step and publishes it * - `spawn` (`SPAWN_SUBAGENT`) — runs a registered handler as an in-process child * - `readSignals` (`READ_STORAGE`) — returns a frozen runtime-health snapshot + * - `notify` (`NETWORK_FETCH`) — delivers a proactive message out a connected channel */ export interface PipelineContext { readonly task: ScheduledTask; @@ -188,6 +224,19 @@ export interface PipelineContext { * live store — a handler cannot read past its window or write through it. */ readSignals(opts?: { readonly windowMs?: number }): EvolveSignals; + /** + * Deliver a proactive notification to the user through a connected messaging + * channel. Requires the task to declare `NETWORK_FETCH` (the egress capability + * `ChannelHandler.send` already requires). The channel and destination are + * resolved by the host — `opts.channel`/`opts.chatId` override, otherwise the + * configured default channel and the paired owner are used. A missing channel, + * destination, or host resolver yields `{ delivered:false, reason }`; only a + * capability violation throws. + */ + notify( + text: string, + opts?: { readonly channel?: string; readonly chatId?: string }, + ): Promise; } /** From ee02ce547bf4bacf64fbfa7ab02c2c767774af45 Mon Sep 17 00:00:00 2001 From: ogarciarevett Date: Fri, 5 Jun 2026 12:17:55 +0200 Subject: [PATCH 2/2] feat(connections): Signal channel via signal-cli (device-link pairing + send-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the channel set with Signal, the last deferred channel. signal-cli is an external binary (no SDK), so Signal is a core handler on the existing ProcessRunner seam — zero new dependencies. - SignalHandler (ChannelHandler + Pairable): send via per-call `signal-cli send` (NETWORK_FETCH asserted; subprocess egress, allowlist N/A for local-cli), no-op receive (send-only v1), self-driving device-link QR pairing. - SignalCli seam: probe/send over the batch ProcessRunner; link over a streaming Bun.spawn (URI prints before exit). Parsing + line-merge pure + unit-tested. - Pairing persists the linked number to the vault + emits it as chatId, so a paired Signal is a ctx.notify target. Reuses the whatsapp-web self-driving coordinator branch unchanged. - Plugin + catalog ready; ConnectionErrorReason gains not_installed. - No new dependency, no migration, no Capability-union change. 916 tests / 0 fail (+26); signal.ts 100% / signal-cli.ts 86% coverage; Biome clean. --- .ai/context.md | 7 +- .ai/generated/rules.mdc | 7 +- AGENTS.md | 7 +- cycle-log.md | 41 ++++ .../src/commands/connections.test.ts | 10 +- .../src/connections/catalog.test.ts | 4 +- .../vesper-core/src/connections/catalog.ts | 11 +- .../vesper-core/src/connections/errors.ts | 1 + packages/vesper-core/src/connections/index.ts | 9 + .../src/connections/pairing.test.ts | 10 +- .../src/connections/plugins.test.ts | 16 +- .../vesper-core/src/connections/plugins.ts | 15 +- .../src/connections/signal-cli.test.ts | 179 +++++++++++++++ .../vesper-core/src/connections/signal-cli.ts | 209 ++++++++++++++++++ .../src/connections/signal.test.ts | 206 +++++++++++++++++ .../vesper-core/src/connections/signal.ts | 145 ++++++++++++ .../vesper-core/src/connections/state.test.ts | 16 +- 17 files changed, 866 insertions(+), 27 deletions(-) create mode 100644 packages/vesper-core/src/connections/signal-cli.test.ts create mode 100644 packages/vesper-core/src/connections/signal-cli.ts create mode 100644 packages/vesper-core/src/connections/signal.test.ts create mode 100644 packages/vesper-core/src/connections/signal.ts diff --git a/.ai/context.md b/.ai/context.md index e51e0ad..9647774 100644 --- a/.ai/context.md +++ b/.ai/context.md @@ -348,6 +348,11 @@ invite QR + `pair `. A daemon `PairingCoordinator` multiplexes the single canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)** lands as the opt-in `@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. +**Signal (`specs/signal-channel.md`)** completes the channel set — a send-only v1 CORE handler over the +external `signal-cli` binary (no SDK, no new dependency; the `ProcessRunner` seam, like the LLM CLIs), +with self-driving device-link QR pairing (`signal-cli link`) that reuses the whatsapp-web coordinator +branch. Egress is a subprocess (allowlist N/A; `send` asserts `NETWORK_FETCH`); signal-cli owns the +session keys, the vault holds only the account number. Paired Signal is a `ctx.notify` target (Note to Self). **Pipeline notify (`ctx.notify`) SHIPPED.** The outbound, pipeline-initiated complement to the inbound chatbot flow (`specs/pipeline-notify.md`): a running pipeline can push a notification to the user out a @@ -361,7 +366,7 @@ no new dependency. A missing channel/destination/resolver is graceful (`{deliver a capability violation throws. Issue-capped: the record is the spec + `cycle-log.md` + the commit (Rule 11). **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **890 tests / 0 fail**; Biome clean; no +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **916 tests / 0 fail**; Biome clean; no provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); diff --git a/.ai/generated/rules.mdc b/.ai/generated/rules.mdc index 042ec48..f3f1728 100644 --- a/.ai/generated/rules.mdc +++ b/.ai/generated/rules.mdc @@ -356,6 +356,11 @@ invite QR + `pair `. A daemon `PairingCoordinator` multiplexes the single canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)** lands as the opt-in `@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. +**Signal (`specs/signal-channel.md`)** completes the channel set — a send-only v1 CORE handler over the +external `signal-cli` binary (no SDK, no new dependency; the `ProcessRunner` seam, like the LLM CLIs), +with self-driving device-link QR pairing (`signal-cli link`) that reuses the whatsapp-web coordinator +branch. Egress is a subprocess (allowlist N/A; `send` asserts `NETWORK_FETCH`); signal-cli owns the +session keys, the vault holds only the account number. Paired Signal is a `ctx.notify` target (Note to Self). **Pipeline notify (`ctx.notify`) SHIPPED.** The outbound, pipeline-initiated complement to the inbound chatbot flow (`specs/pipeline-notify.md`): a running pipeline can push a notification to the user out a @@ -369,7 +374,7 @@ no new dependency. A missing channel/destination/resolver is graceful (`{deliver a capability violation throws. Issue-capped: the record is the spec + `cycle-log.md` + the commit (Rule 11). **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **890 tests / 0 fail**; Biome clean; no +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **916 tests / 0 fail**; Biome clean; no provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); diff --git a/AGENTS.md b/AGENTS.md index be8bdbb..8dd3059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -350,6 +350,11 @@ invite QR + `pair `. A daemon `PairingCoordinator` multiplexes the single canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)** lands as the opt-in `@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. +**Signal (`specs/signal-channel.md`)** completes the channel set — a send-only v1 CORE handler over the +external `signal-cli` binary (no SDK, no new dependency; the `ProcessRunner` seam, like the LLM CLIs), +with self-driving device-link QR pairing (`signal-cli link`) that reuses the whatsapp-web coordinator +branch. Egress is a subprocess (allowlist N/A; `send` asserts `NETWORK_FETCH`); signal-cli owns the +session keys, the vault holds only the account number. Paired Signal is a `ctx.notify` target (Note to Self). **Pipeline notify (`ctx.notify`) SHIPPED.** The outbound, pipeline-initiated complement to the inbound chatbot flow (`specs/pipeline-notify.md`): a running pipeline can push a notification to the user out a @@ -363,7 +368,7 @@ no new dependency. A missing channel/destination/resolver is graceful (`{deliver a capability violation throws. Issue-capped: the record is the spec + `cycle-log.md` + the commit (Rule 11). **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **890 tests / 0 fail**; Biome clean; no +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **916 tests / 0 fail**; Biome clean; no provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); diff --git a/cycle-log.md b/cycle-log.md index d9298b3..d1dd4fb 100644 --- a/cycle-log.md +++ b/cycle-log.md @@ -1012,3 +1012,44 @@ Backend->Client->Review workflow; the review's 2 real HIGH gaps were then fixed - FOLLOW-UPS: rate-limiting/anti-spam on notifications (declared out-of-scope; every send is audited so abuse is visible); rich/structured messages (plain text only in v1); a shared `fakeContext` test factory; downstream consumers can now wire delivery (`pipeline-career.md`, `pipeline-secretary.md`) onto `ctx.notify`. + +## Signal channel via signal-cli (device-link pairing + send-only v1) — SHIPPED +- The last DEFERRED connections channel. Spec: `specs/signal-channel.md`. Issue-capped: this entry + the commit + are the record (Rule 11). Omar approved SPEC + PLAN at the gates; chose send-only+pairing / per-call spawn / + vault account — the smallest correct increment, and (with the just-shipped `ctx.notify`) Signal is immediately + a notification target (a pipeline result -> the user's Signal "Note to Self"). +- ARCHITECTURE (the key call): signal-cli is an EXTERNAL BINARY (no hosted API, no npm SDK) — reached via the + existing `ProcessRunner` seam exactly as the LLM CLI adapters shell out to `claude`/`codex`. So Signal is a + CORE handler (`connections/signal.ts`), NOT an opt-in package (contrast whatsapp-web/Baileys, which bundled a + library). ZERO new npm dependency; the lockfile is unchanged. Egress is a subprocess, not HTTP — so + `allowlistedFetch`/the host-allowlist is N/A for the `local-cli` transport; `send` asserts `NETWORK_FETCH` + directly against the handler grant. No migration, no Capability-union change. +- THE SEAM (`connections/signal-cli.ts`): a small injected `SignalCli` — `probe`/`send` ride the BATCH + `ProcessRunner` (`signal-cli --output=json listAccounts` to verify linked; `-a -o json send -m + `), and `link` rides a STREAMING `Bun.spawn` seam because `signal-cli link` prints the + `sgnl://linkdevice?...` URI WHILE it blocks awaiting a scan (the batch runner only returns at exit). The + fiddly streaming/merge glue (read+merge stdout+stderr into lines) is isolated in the default impl; the pure, + testable surface (`parseSignalLinkLine`, `linkEventsFromLines`, `streamLines`, `mergeStreamLines`) is unit- + tested with constructed ReadableStreams + a fake runner, so the suite spawns nothing. +- PAIRING is self-driving QR device-linking (`pairingNeedsInbound:false`) — slots into the EXISTING + whatsapp-web coordinator branch with NO `PairingCoordinator` change. `startPairing` streams the URI as a + `PairingPrompt{kind:"code"}`, and on "Associated with: " persists the account to the vault + (`signal_account`) and emits `linked{chatId:}` (which the coordinator records as `params.defaultChatId` + -> the Note-to-Self notify destination). signal-cli owns the real session keys in its own encrypted data dir; + Vesper's vault holds ONLY the account number (documented deviation from "all creds in the vault"). +- REVIEW caught a real bug: in `startPairing` the `linked` flag was set BEFORE `await vault.set(...)`, so a + vault-write failure would end the stream with NO terminal update (the catch's `if (!linked)` skipped). Fixed by + persisting FIRST, then flipping `linked`; added a test (vault.set throws -> `error`, not a silent end). +- SPEC DELTA: extended `ConnectionErrorReason` with `"not_installed"` (the spec referenced it but the union + lacked it) so a missing signal-cli surfaces an honest "brew install signal-cli" reason. No exhaustive switch on + the reason existed, so the variant is additive. Stale plugin/catalog doc-comments ("Signal is a catalog entry + with no plugin yet") were corrected; the `channelStates`/CLI tests that used `signal` as the "no handler" + example now use `whatsapp-web` (the only catalog id with no BUILT-IN plugin — it registers at runtime). +- Verified: 916 tests / 0 fail (+26); coverage signal.ts 100%, signal-cli.ts 86% (uncovered = the `Bun.spawn` + glue, like `runProcess` itself); biome clean; tsc adds 0 new errors; NO new npm dependency; NO migration. NOT + exercised against a live signal-cli (none in CI) — the exact probe subcommand + the "Associated with" line + format are signal-cli-version-dependent and the main unverified risk (the seam is mocked end to end). +- FOLLOW-UPS: inbound receive -> chatbot (needs the long-lived `signal-cli daemon --http` JSON-RPC transport — + the documented evolution; egress would then ride `allowlistedFetch` to 127.0.0.1); group messaging / + attachments; verifying the probe + link line formats against a real signal-cli build. With Signal shipped, the + connections channel set (Telegram, Discord, WhatsApp Cloud, WhatsApp-Web, Signal) is complete. diff --git a/packages/vesper-cli/src/commands/connections.test.ts b/packages/vesper-cli/src/commands/connections.test.ts index 5585ed2..c85eed8 100644 --- a/packages/vesper-cli/src/commands/connections.test.ts +++ b/packages/vesper-cli/src/commands/connections.test.ts @@ -126,8 +126,14 @@ describe("vesper connections — actions", () => { const states = await connectionStates(deps); const tg = states.find((s) => s.id === "telegram"); expect(tg).toMatchObject({ available: true, configured: true, enabled: true }); - // signal ships no handler yet -> not available. - expect(states.find((s) => s.id === "signal")?.available).toBe(false); + // signal now ships a handler -> available (though unconfigured here). + expect(states.find((s) => s.id === "signal")).toMatchObject({ + available: true, + configured: false, + enabled: false, + }); + // whatsapp-web has no built-in handler (runtime-registered by the daemon) -> not available. + expect(states.find((s) => s.id === "whatsapp-web")?.available).toBe(false); }); test("testChannel builds the handler and authenticates it", async () => { diff --git a/packages/vesper-core/src/connections/catalog.test.ts b/packages/vesper-core/src/connections/catalog.test.ts index 7bfb594..82679d8 100644 --- a/packages/vesper-core/src/connections/catalog.test.ts +++ b/packages/vesper-core/src/connections/catalog.test.ts @@ -21,11 +21,11 @@ describe("CHANNEL_CATALOG", () => { } }); - test("telegram + discord + whatsapp are ready; signal is deferred", () => { + test("telegram + discord + whatsapp + signal are ready", () => { expect(channelById("telegram")?.status).toBe("ready"); expect(channelById("discord")?.status).toBe("ready"); expect(channelById("whatsapp")?.status).toBe("ready"); - expect(channelById("signal")?.status).toBe("deferred"); + expect(channelById("signal")?.status).toBe("ready"); }); test("telegram declares api.telegram.org and the bot-token vault key", () => { diff --git a/packages/vesper-core/src/connections/catalog.ts b/packages/vesper-core/src/connections/catalog.ts index 7696b33..5616fb7 100644 --- a/packages/vesper-core/src/connections/catalog.ts +++ b/packages/vesper-core/src/connections/catalog.ts @@ -3,8 +3,8 @@ * MCP servers. User input selects an id (a KEY); it never supplies an arbitrary * host or server URL (same trust posture as the agent install catalog). * - * Channels: telegram + discord are `ready` (a handler exists / shares the - * contract); whatsapp + signal are `deferred` (catalog entry + tutorial only). + * Channels: telegram, discord, whatsapp, and signal are `ready` (each ships a + * handler); whatsapp-web is registered at runtime by its opt-in package. */ import type { ChannelDescriptor, ChannelId } from "./types.ts"; @@ -58,13 +58,12 @@ export const CHANNEL_CATALOG: readonly ChannelDescriptor[] = [ id: "signal", displayName: "Signal", transport: "local-cli", - // Signal runs through a local signal-cli process, not a hosted host. The - // localhost REST bridge is the only egress; it is still declared so the - // allowlist seam holds (a deferred handler starts nothing in v1). + // Send-only v1 via the local signal-cli binary (no HTTP egress for this transport; + // 127.0.0.1 is declared so the allowlist seam holds if a future daemon-HTTP path lands). allowedHosts: ["127.0.0.1"], vaultKeys: ["signal_account"], docsUrl: "https://github.com/AsamK/signal-cli", - status: "deferred", + status: "ready", }, ] as const; diff --git a/packages/vesper-core/src/connections/errors.ts b/packages/vesper-core/src/connections/errors.ts index 3372438..3757cb0 100644 --- a/packages/vesper-core/src/connections/errors.ts +++ b/packages/vesper-core/src/connections/errors.ts @@ -2,6 +2,7 @@ export type ConnectionErrorReason = | "host_not_allowed" | "not_authenticated" + | "not_installed" | "send_failed" | "receive_failed" | "unknown_channel" diff --git a/packages/vesper-core/src/connections/index.ts b/packages/vesper-core/src/connections/index.ts index adad63e..a323fc1 100644 --- a/packages/vesper-core/src/connections/index.ts +++ b/packages/vesper-core/src/connections/index.ts @@ -37,6 +37,15 @@ export { unregisterChannelPlugin, } from "./plugins.ts"; export { ChannelRegistry } from "./registry.ts"; +export { SignalHandler, type SignalHandlerOptions } from "./signal.ts"; +export { + type LinkProcess, + type LinkSpawner, + makeSignalCli, + type SignalCli, + type SignalLinkEvent, + type SignalLinkSession, +} from "./signal-cli.ts"; export { type ChannelState, type ChannelWiring, diff --git a/packages/vesper-core/src/connections/pairing.test.ts b/packages/vesper-core/src/connections/pairing.test.ts index d76e649..dc2b6e8 100644 --- a/packages/vesper-core/src/connections/pairing.test.ts +++ b/packages/vesper-core/src/connections/pairing.test.ts @@ -41,13 +41,19 @@ test("PAIRING_TTL_MS is a positive duration", () => { }); describe("channelStates pairable flag", () => { - test("telegram + discord are pairable; cloud whatsapp + signal are not", () => { + test("telegram + discord + signal are pairable; cloud whatsapp is not", () => { const states = channelStates({}); const byId = (id: string) => states.find((s) => s.id === id); expect(byId("telegram")?.pairable).toBe(true); expect(byId("discord")?.pairable).toBe(true); expect(byId("whatsapp")?.pairable).toBe(false); - expect(byId("signal")?.pairable).toBe(false); + expect(byId("signal")?.pairable).toBe(true); + }); + + test("signal ships a handler, so it reports available (send-only v1)", () => { + const signal = channelStates({}).find((s) => s.id === "signal"); + expect(signal?.available).toBe(true); + expect(signal?.pairable).toBe(true); }); }); diff --git a/packages/vesper-core/src/connections/plugins.test.ts b/packages/vesper-core/src/connections/plugins.test.ts index a61e70c..053b15c 100644 --- a/packages/vesper-core/src/connections/plugins.test.ts +++ b/packages/vesper-core/src/connections/plugins.test.ts @@ -19,8 +19,20 @@ describe("channel plugins", () => { expect(channelPluginById("whatsapp")).toBeDefined(); }); - test("a channel with no shipped handler has no plugin (availability gate)", () => { - expect(channelPluginById("signal")).toBeUndefined(); + test("signal ships a self-driving pairable plugin (local signal-cli)", () => { + const plugin = channelPluginById("signal"); + expect(plugin).toBeDefined(); + expect(plugin?.pairable).toBe(true); + expect(plugin?.pairingNeedsInbound).toBe(false); + const handler = plugin?.build({ + granted: CHANNEL_GRANTS, + vaultKey: "signal_account", + allowedHosts: ["127.0.0.1"], + }); + expect(handler?.descriptor.id).toBe("signal"); + }); + + test("an unknown channel id has no plugin (availability gate)", () => { expect(channelPluginById("not-a-channel")).toBeUndefined(); }); diff --git a/packages/vesper-core/src/connections/plugins.ts b/packages/vesper-core/src/connections/plugins.ts index 1a1ab74..1877b5b 100644 --- a/packages/vesper-core/src/connections/plugins.ts +++ b/packages/vesper-core/src/connections/plugins.ts @@ -5,13 +5,14 @@ * {@link ChannelPlugin} entry to {@link CHANNEL_PLUGINS}, (3) flip its catalog * status to "ready". The daemon, CLI, and UI all iterate this registry to learn * which channels are *available* (a handler ships), so none of them change when a - * channel is added — channels are a plugin. Telegram is the only handler shipped - * today; Discord/WhatsApp/Signal are catalog entries with no plugin yet. + * channel is added — channels are a plugin. Telegram, Discord, WhatsApp (Cloud API), + * and Signal (signal-cli) ship built-in plugins; WhatsApp-Web registers at runtime. */ import type { Capability } from "../capabilities/index.ts"; import { DiscordHandler } from "./discord.ts"; import type { FetchFn } from "./fetch.ts"; +import { SignalHandler } from "./signal.ts"; import { TelegramHandler } from "./telegram.ts"; import type { ChannelHandler, ChannelId } from "./types.ts"; import { WhatsAppHandler } from "./whatsapp.ts"; @@ -48,7 +49,7 @@ export interface ChannelPlugin { build(opts: ChannelBuildOptions): ChannelHandler; } -/** Built-in channel plugins. Telegram (long-poll) and Discord (Gateway) ship today. */ +/** Built-in channel plugins: Telegram, Discord, WhatsApp (Cloud API), Signal (signal-cli). */ export const CHANNEL_PLUGINS: readonly ChannelPlugin[] = [ { id: "telegram", @@ -85,6 +86,14 @@ export const CHANNEL_PLUGINS: readonly ChannelPlugin[] = [ ...(opts.fetchFn !== undefined ? { fetchFn: opts.fetchFn } : {}), }), }, + { + // Signal runs through the local signal-cli binary (no HTTP, no SDK). Self-driving + // device-link pairing, so the coordinator skips the inbound precondition. + id: "signal", + pairable: true, + pairingNeedsInbound: false, + build: (opts) => new SignalHandler({ granted: opts.granted, vaultKey: opts.vaultKey }), + }, ]; /** diff --git a/packages/vesper-core/src/connections/signal-cli.test.ts b/packages/vesper-core/src/connections/signal-cli.test.ts new file mode 100644 index 0000000..ef9e592 --- /dev/null +++ b/packages/vesper-core/src/connections/signal-cli.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { CommandNotFoundError, type ProcessRunner, type RunResult } from "../process/run.ts"; +import { ConnectionError } from "./errors.ts"; +import { + type LinkProcess, + linkEventsFromLines, + makeSignalCli, + mergeStreamLines, + parseSignalLinkLine, + type SignalLinkEvent, + streamLines, +} from "./signal-cli.ts"; + +function result(over: Partial = {}): RunResult { + return { stdout: "", stderr: "", exitCode: 0, durationMs: 1, ...over }; +} + +/** A recording fake ProcessRunner. */ +function fakeRunner(out: RunResult | (() => never)): { + run: ProcessRunner; + calls: { command: string; args: readonly string[] }[]; +} { + const calls: { command: string; args: readonly string[] }[] = []; + const run: ProcessRunner = async (command, args) => { + calls.push({ command, args }); + if (typeof out === "function") return out(); + return out; + }; + return { run, calls }; +} + +function streamFromChunks(chunks: readonly string[]): ReadableStream { + const enc = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(enc.encode(chunk)); + controller.close(); + }, + }); +} + +async function collect(it: AsyncIterable): Promise { + const out: T[] = []; + for await (const v of it) out.push(v); + return out; +} + +describe("parseSignalLinkLine", () => { + test("recognizes the sgnl device-link URI", () => { + expect(parseSignalLinkLine("sgnl://linkdevice?uuid=abc&pub_key=def")).toEqual({ + kind: "uri", + uri: "sgnl://linkdevice?uuid=abc&pub_key=def", + }); + }); + + test("recognizes the legacy tsdevice URI", () => { + expect(parseSignalLinkLine("tsdevice:/?uuid=x&pub_key=y")?.kind).toBe("uri"); + }); + + test("recognizes the association line and captures the account", () => { + expect(parseSignalLinkLine("Associated with: +15551234567")).toEqual({ + kind: "linked", + account: "+15551234567", + }); + }); + + test("ignores noise and blank lines", () => { + expect(parseSignalLinkLine("")).toBeUndefined(); + expect(parseSignalLinkLine("Scan this QR code with your phone")).toBeUndefined(); + }); +}); + +describe("linkEventsFromLines", () => { + test("maps a line stream to events, dropping noise", async () => { + async function* lines(): AsyncGenerator { + yield "starting link"; + yield "sgnl://linkdevice?uuid=a&pub_key=b"; + yield "waiting..."; + yield "Associated with: +15550001111"; + } + const events = await collect(linkEventsFromLines(lines())); + expect(events).toEqual([ + { kind: "uri", uri: "sgnl://linkdevice?uuid=a&pub_key=b" }, + { kind: "linked", account: "+15550001111" }, + ]); + }); +}); + +describe("streamLines", () => { + test("splits across chunk boundaries and flushes a trailing line", async () => { + const stream = streamFromChunks(["one\ntw", "o\nthree"]); + expect(await collect(streamLines(stream))).toEqual(["one", "two", "three"]); + }); +}); + +describe("mergeStreamLines", () => { + test("yields every line from all sources", async () => { + const a = streamFromChunks(["a1\na2\n"]); + const b = streamFromChunks(["b1\n"]); + const merged = await collect(mergeStreamLines([a, b])); + expect(merged.sort()).toEqual(["a1", "a2", "b1"]); + }); +}); + +describe("makeSignalCli.probe", () => { + test("succeeds when listAccounts contains the account", async () => { + const { run, calls } = fakeRunner(result({ stdout: '[{"number":"+15551234567"}]' })); + await makeSignalCli({ run }).probe("+15551234567"); + expect(calls[0]).toEqual({ command: "signal-cli", args: ["--output=json", "listAccounts"] }); + }); + + test("throws not_authenticated when the account is absent", async () => { + const { run } = fakeRunner(result({ stdout: "[]" })); + await expect(makeSignalCli({ run }).probe("+15551234567")).rejects.toMatchObject({ + reason: "not_authenticated", + }); + }); + + test("throws not_installed when the binary is missing", async () => { + const { run } = fakeRunner(() => { + throw new CommandNotFoundError("signal-cli"); + }); + const error = await makeSignalCli({ run }) + .probe("+1") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe("not_installed"); + }); +}); + +describe("makeSignalCli.send", () => { + test("invokes signal-cli send with body+recipient as discrete argv", async () => { + const { run, calls } = fakeRunner(result()); + await makeSignalCli({ run }).send("+1555", "+1999", "hello there"); + expect(calls[0]?.args).toEqual([ + "-a", + "+1555", + "-o", + "json", + "send", + "-m", + "hello there", + "+1999", + ]); + }); + + test("throws send_failed on a nonzero exit", async () => { + const { run } = fakeRunner(result({ exitCode: 1, stderr: "boom" })); + await expect(makeSignalCli({ run }).send("+1", "+2", "x")).rejects.toMatchObject({ + reason: "send_failed", + }); + }); +}); + +describe("makeSignalCli.link", () => { + test("streams parsed events and stop() kills the child", async () => { + let killed = false; + const spawnLink = (name: string): LinkProcess => { + expect(name).toBe("Vesper"); + return { + async *lines() { + yield "sgnl://linkdevice?uuid=a&pub_key=b"; + yield "Associated with: +15557654321"; + }, + kill() { + killed = true; + }, + }; + }; + const session = makeSignalCli({ spawnLink }).link("Vesper"); + const events: SignalLinkEvent[] = await collect(session.events()); + expect(events).toEqual([ + { kind: "uri", uri: "sgnl://linkdevice?uuid=a&pub_key=b" }, + { kind: "linked", account: "+15557654321" }, + ]); + session.stop(); + expect(killed).toBe(true); + }); +}); diff --git a/packages/vesper-core/src/connections/signal-cli.ts b/packages/vesper-core/src/connections/signal-cli.ts new file mode 100644 index 0000000..eb13749 --- /dev/null +++ b/packages/vesper-core/src/connections/signal-cli.ts @@ -0,0 +1,209 @@ +/** + * The `signal-cli` process seam — Vesper's only coupling to the external Signal + * client. Signal has no hosted API and no npm SDK; it is reached through the local + * `signal-cli` binary the user installs, exactly as the LLM CLI adapters shell out + * to `claude`/`codex`. So this stays a thin, INJECTED wrapper over the process + * layer: `probe`/`send` ride the batch {@link ProcessRunner} seam, and `link` + * (which must stream a device-link URI WHILE the process blocks awaiting a scan) + * rides a small {@link LinkSpawner} seam. Both default to `Bun.spawn`; the unit + * suite injects fakes, so no real `signal-cli` ever runs. + */ + +import { CommandNotFoundError, type ProcessRunner, runProcess } from "../process/run.ts"; +import { ConnectionError } from "./errors.ts"; + +/** The binary name; on PATH after `brew install signal-cli` (or a distro package). */ +const SIGNAL_CLI = "signal-cli"; + +/** An event parsed from `signal-cli link`'s streamed output. */ +export type SignalLinkEvent = + | { readonly kind: "uri"; readonly uri: string } + | { readonly kind: "linked"; readonly account: string }; + +/** A running `signal-cli link` process, surfaced as merged output lines + a kill. */ +export interface LinkProcess { + /** Lines from the child's stdout+stderr, merged, as they arrive. */ + lines(): AsyncIterable; + /** Kill the child (idempotent). */ + kill(): void; +} + +/** Spawns `signal-cli link -n `; injected so tests stream canned lines. */ +export type LinkSpawner = (name: string) => LinkProcess; + +/** A running device-link attempt: events until linked/end, plus a stop handle. */ +export interface SignalLinkSession { + events(): AsyncIterable; + stop(): void; +} + +/** The capabilities the Signal handler needs from `signal-cli`. */ +export interface SignalCli { + /** Verify signal-cli is installed AND `account` is linked. Throws otherwise. */ + probe(account: string): Promise; + /** Send a 1:1 text message to `recipient` from `account`. */ + send(account: string, recipient: string, text: string): Promise; + /** Begin device-link pairing; the session streams the URI then the linked account. */ + link(name: string): SignalLinkSession; +} + +/** + * Classify one line of `signal-cli link` output. The link command prints the + * device-link URI (`sgnl://linkdevice?...`, or the legacy `tsdevice:/?...`) and, + * on success, `Associated with: `. Everything else is noise (undefined). + */ +export function parseSignalLinkLine(line: string): SignalLinkEvent | undefined { + const trimmed = line.trim(); + if (trimmed.length === 0) return undefined; + const uri = trimmed.match(/(?:sgnl:\/\/linkdevice|tsdevice:\/?)\S+/)?.[0]; + if (uri !== undefined) return { kind: "uri", uri }; + const associated = trimmed.match(/associated with:\s*(.+)/i)?.[1]?.trim(); + if (associated !== undefined && associated.length > 0) { + return { kind: "linked", account: associated }; + } + return undefined; +} + +/** Map a stream of output lines to {@link SignalLinkEvent}s (pure; the testable core). */ +export async function* linkEventsFromLines( + lines: AsyncIterable, +): AsyncGenerator { + for await (const line of lines) { + const event = parseSignalLinkLine(line); + if (event !== undefined) yield event; + } +} + +/** Yield newline-delimited lines from a byte stream as they arrive (trailing line flushed). */ +export async function* streamLines(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let nl = buffer.indexOf("\n"); + while (nl >= 0) { + yield buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + nl = buffer.indexOf("\n"); + } + } + if (buffer.length > 0) yield buffer; + } finally { + reader.releaseLock(); + } +} + +/** + * Merge several byte streams into one line stream, yielding each line as soon as + * it arrives on any source. signal-cli prints the URI on stdout and may report the + * association on stderr, so both are read concurrently. + */ +export async function* mergeStreamLines( + streams: readonly ReadableStream[], +): AsyncGenerator { + const queue: string[] = []; + let active = streams.length; + let wake: (() => void) | null = null; + const signal = (): void => { + wake?.(); + wake = null; + }; + for (const stream of streams) { + void (async () => { + for await (const line of streamLines(stream)) { + queue.push(line); + signal(); + } + })() + .catch(() => {}) + .finally(() => { + active -= 1; + signal(); + }); + } + while (active > 0 || queue.length > 0) { + if (queue.length === 0) { + await new Promise((resolve) => { + wake = resolve; + }); + continue; + } + const line = queue.shift(); + if (line !== undefined) yield line; + } +} + +/** Default {@link LinkSpawner}: a streaming `Bun.spawn` of `signal-cli link`. */ +const defaultLinkSpawner: LinkSpawner = (name) => { + const proc = Bun.spawn([SIGNAL_CLI, "link", "-n", name], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const stdout = proc.stdout as ReadableStream; + const stderr = proc.stderr as ReadableStream; + return { + lines: () => mergeStreamLines([stdout, stderr]), + kill: () => { + try { + proc.kill(); + } catch { + // kill is best-effort and idempotent; a dead child must not throw. + } + }, + }; +}; + +/** Construct the default {@link SignalCli}, with the process seams injectable for tests. */ +export function makeSignalCli( + deps: { readonly run?: ProcessRunner; readonly spawnLink?: LinkSpawner } = {}, +): SignalCli { + const run = deps.run ?? runProcess; + const spawnLink = deps.spawnLink ?? defaultLinkSpawner; + + /** Run a signal-cli subcommand, mapping a missing binary to `not_installed`. */ + const exec = async (args: readonly string[]): Promise<{ stdout: string; exitCode: number }> => { + try { + const { stdout, exitCode } = await run(SIGNAL_CLI, args); + return { stdout, exitCode }; + } catch (cause) { + if (cause instanceof CommandNotFoundError) { + throw new ConnectionError( + "not_installed", + "signal-cli is not installed — `brew install signal-cli` then pair again", + { cause }, + ); + } + throw cause; + } + }; + + return { + async probe(account) { + const { stdout, exitCode } = await exec(["--output=json", "listAccounts"]); + if (exitCode !== 0 || !stdout.includes(account)) { + throw new ConnectionError( + "not_authenticated", + `signal-cli has no linked account ${account} — pair it first`, + ); + } + }, + async send(account, recipient, text) { + const { exitCode } = await exec(["-a", account, "-o", "json", "send", "-m", text, recipient]); + if (exitCode !== 0) { + throw new ConnectionError("send_failed", `signal-cli send exited ${exitCode}`); + } + }, + link(name) { + const proc = spawnLink(name); + return { + events: () => linkEventsFromLines(proc.lines()), + stop: () => proc.kill(), + }; + }, + }; +} diff --git a/packages/vesper-core/src/connections/signal.test.ts b/packages/vesper-core/src/connections/signal.test.ts new file mode 100644 index 0000000..37f6be4 --- /dev/null +++ b/packages/vesper-core/src/connections/signal.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { CapabilityError } from "../capabilities/errors.ts"; +import type { Vault } from "../vault/types.ts"; +import { ConnectionError } from "./errors.ts"; +import { SignalHandler } from "./signal.ts"; +import type { SignalCli, SignalLinkEvent, SignalLinkSession } from "./signal-cli.ts"; +import type { PairingDeps, PairingUpdate } from "./types.ts"; + +const GRANTS = ["NETWORK_FETCH", "READ_VAULT"] as const; + +interface SendCall { + account: string; + recipient: string; + text: string; +} + +function fakeCli(opts: { + probe?: () => Promise; + linkEvents?: readonly SignalLinkEvent[]; + linkThrows?: boolean; +}): { cli: SignalCli; sends: SendCall[]; linkKilled: () => boolean } { + const sends: SendCall[] = []; + let linkKilled = false; + const cli: SignalCli = { + probe: opts.probe ?? (async () => {}), + send: async (account, recipient, text) => { + sends.push({ account, recipient, text }); + }, + link: (): SignalLinkSession => ({ + async *events() { + if (opts.linkThrows) throw new Error("link blew up"); + for (const event of opts.linkEvents ?? []) yield event; + }, + stop() { + linkKilled = true; + }, + }), + }; + return { cli, sends, linkKilled: () => linkKilled }; +} + +/** A fake vault recording sets; get returns the seeded value (or empty). */ +function fakeVault(seed: Record = {}): { + vault: Vault; + store: Record; +} { + const store: Record = { ...seed }; + const vault: Vault = { + get: async (key) => store[key] ?? "", + set: async (key, value) => { + store[key] = value; + }, + delete: async (key) => { + delete store[key]; + }, + list: async () => Object.keys(store), + }; + return { vault, store }; +} + +async function collect(it: AsyncIterable): Promise { + const out: T[] = []; + for await (const v of it) out.push(v); + return out; +} + +describe("SignalHandler.authenticate", () => { + test("loads the account from the vault and probes signal-cli", async () => { + let probed = ""; + const { cli, sends } = fakeCli({ + probe: async () => { + probed = "called"; + }, + }); + const { vault } = fakeVault({ signal_account: "+15551234567" }); + const handler = new SignalHandler({ granted: GRANTS, cli }); + await handler.authenticate(vault); + expect(probed).toBe("called"); + // Once authenticated, send routes to the loaded account. + await handler.send({ kind: "notify", chatId: "+15559998888", text: "hi" }); + expect(sends[0]).toEqual({ + account: "+15551234567", + recipient: "+15559998888", + text: "hi", + }); + }); + + test("throws not_authenticated when the vault has no account", async () => { + const { cli } = fakeCli({}); + const { vault } = fakeVault({}); + const handler = new SignalHandler({ granted: GRANTS, cli }); + await expect(handler.authenticate(vault)).rejects.toMatchObject({ + reason: "not_authenticated", + }); + }); + + test("propagates a not_installed probe failure", async () => { + const { cli } = fakeCli({ + probe: async () => { + throw new ConnectionError("not_installed", "no signal-cli"); + }, + }); + const { vault } = fakeVault({ signal_account: "+1" }); + const handler = new SignalHandler({ granted: GRANTS, cli }); + await expect(handler.authenticate(vault)).rejects.toMatchObject({ reason: "not_installed" }); + }); +}); + +describe("SignalHandler.send", () => { + test("throws CapabilityError when NETWORK_FETCH is not granted", async () => { + const { cli } = fakeCli({}); + const handler = new SignalHandler({ granted: ["READ_VAULT"], cli }); + await expect(handler.send({ kind: "notify", chatId: "+1", text: "x" })).rejects.toBeInstanceOf( + CapabilityError, + ); + }); + + test("throws not_authenticated before authenticate has run", async () => { + const { cli } = fakeCli({}); + const handler = new SignalHandler({ granted: GRANTS, cli }); + await expect(handler.send({ kind: "notify", chatId: "+1", text: "x" })).rejects.toMatchObject({ + reason: "not_authenticated", + }); + }); +}); + +describe("SignalHandler.receive", () => { + test("is a no-op Stoppable (send-only v1)", () => { + const { cli } = fakeCli({}); + const handler = new SignalHandler({ granted: GRANTS, cli }); + const stop = handler.receive(async () => {}); + expect(() => stop.stop()).not.toThrow(); + }); +}); + +describe("SignalHandler.startPairing", () => { + function deps(vault: Vault): PairingDeps { + return { vault }; + } + + test("streams the URI as a QR prompt, persists the account, and links", async () => { + const { cli } = fakeCli({ + linkEvents: [ + { kind: "uri", uri: "sgnl://linkdevice?uuid=a&pub_key=b" }, + { kind: "linked", account: "+15557654321" }, + ], + }); + const { vault, store } = fakeVault(); + const handler = new SignalHandler({ granted: GRANTS, cli }); + const updates: PairingUpdate[] = await collect(handler.startPairing(deps(vault)).updates()); + + expect(updates[0]).toMatchObject({ + status: "awaiting", + prompt: { kind: "code", data: "sgnl://linkdevice?uuid=a&pub_key=b" }, + }); + expect(updates[1]).toEqual({ + status: "linked", + chatId: "+15557654321", + label: "Signal", + }); + // The linked account number is persisted to the vault for later authenticate. + expect(store.signal_account).toBe("+15557654321"); + }); + + test("emits link_incomplete when the stream ends without an association", async () => { + const { cli } = fakeCli({ linkEvents: [{ kind: "uri", uri: "sgnl://linkdevice?x=1" }] }); + const { vault } = fakeVault(); + const handler = new SignalHandler({ granted: GRANTS, cli }); + const updates = await collect(handler.startPairing(deps(vault)).updates()); + expect(updates.at(-1)).toEqual({ status: "error", reason: "link_incomplete" }); + }); + + test("surfaces an error when the link process fails", async () => { + const { cli } = fakeCli({ linkThrows: true }); + const { vault } = fakeVault(); + const handler = new SignalHandler({ granted: GRANTS, cli }); + const updates = await collect(handler.startPairing(deps(vault)).updates()); + expect(updates).toEqual([{ status: "error", reason: "link blew up" }]); + }); + + test("surfaces an error (not a silent end) when persisting the account fails", async () => { + const { cli } = fakeCli({ linkEvents: [{ kind: "linked", account: "+15551234567" }] }); + const vault: Vault = { + get: async () => "", + set: async () => { + throw new Error("keychain locked"); + }, + delete: async () => {}, + list: async () => [], + }; + const handler = new SignalHandler({ granted: GRANTS, cli }); + const updates = await collect(handler.startPairing(deps(vault)).updates()); + expect(updates).toEqual([{ status: "error", reason: "keychain locked" }]); + }); + + test("stop() kills the underlying link session", async () => { + const { cli, linkKilled } = fakeCli({ linkEvents: [] }); + const { vault } = fakeVault(); + const handler = new SignalHandler({ granted: GRANTS, cli }); + const session = handler.startPairing(deps(vault)); + session.stop(); + expect(linkKilled()).toBe(true); + // Idempotent. + expect(() => session.stop()).not.toThrow(); + }); +}); diff --git a/packages/vesper-core/src/connections/signal.ts b/packages/vesper-core/src/connections/signal.ts new file mode 100644 index 0000000..8e761df --- /dev/null +++ b/packages/vesper-core/src/connections/signal.ts @@ -0,0 +1,145 @@ +/** + * Signal channel handler — send-only v1 over the local `signal-cli` binary, with + * self-driving device-link QR pairing. Modeled on the WhatsApp send-only handler + * (a no-op `receive`) plus the whatsapp-web self-driving `Pairable`. + * + * Signal has no hosted API and no npm SDK; egress is a `signal-cli` subprocess (the + * {@link SignalCli} seam), not an HTTP fetch — so the host-allowlist (`allowlistedFetch`) + * does not apply to this `local-cli` transport, and `send` asserts `NETWORK_FETCH` + * directly. Signal's session keys live in signal-cli's own encrypted data dir; Vesper's + * vault holds only the linked account NUMBER (`signal_account`), persisted at pairing. + */ + +import { assertCapabilities } from "../capabilities/assert.ts"; +import type { Capability } from "../capabilities/index.ts"; +import { channelById } from "./catalog.ts"; +import { ConnectionError } from "./errors.ts"; +import { PAIRING_TTL_MS } from "./pairing.ts"; +import { makeSignalCli, type SignalCli } from "./signal-cli.ts"; +import type { + ChannelDescriptor, + ChannelHandler, + ChatSink, + OutboundIntent, + Pairable, + PairingDeps, + PairingSession, + PairingUpdate, + Stoppable, +} from "./types.ts"; + +const SIGNAL_DESCRIPTOR = channelById("signal") as ChannelDescriptor; + +/** Construction options for {@link SignalHandler}. */ +export interface SignalHandlerOptions { + readonly granted: readonly Capability[]; + /** Vault KEY the linked account number is stored under (default `signal_account`). */ + readonly vaultKey?: string; + /** The signal-cli process seam; defaults to the real binary. Injected in tests. */ + readonly cli?: SignalCli; + /** Device name shown in the phone's Linked Devices list. */ + readonly deviceName?: string; +} + +export class SignalHandler implements ChannelHandler, Pairable { + readonly descriptor: ChannelDescriptor = SIGNAL_DESCRIPTOR; + readonly #granted: readonly Capability[]; + readonly #vaultKey: string; + readonly #cli: SignalCli; + readonly #deviceName: string; + #account: string | null = null; + + constructor(options: SignalHandlerOptions) { + this.#granted = options.granted; + this.#vaultKey = options.vaultKey ?? "signal_account"; + this.#cli = options.cli ?? makeSignalCli(); + this.#deviceName = options.deviceName ?? "Vesper"; + } + + /** Load the linked account number from the vault, then verify signal-cli + linking. */ + async authenticate(vault: { get(key: string): Promise }): Promise { + const account = (await vault.get(this.#vaultKey)).trim(); + if (account.length === 0) { + throw new ConnectionError("not_authenticated", "signal account is empty — pair Signal first"); + } + await this.#cli.probe(account); + this.#account = account; + } + + /** Send a 1:1 text. `intent.chatId` is the recipient number (the own number = Note to Self). */ + async send(intent: OutboundIntent): Promise { + assertCapabilities(["NETWORK_FETCH"], this.#granted); + if (this.#account === null) { + throw new ConnectionError("not_authenticated", "signal handler is not authenticated"); + } + await this.#cli.send(this.#account, intent.chatId, intent.text); + } + + /** Inbound is not built in v1 (send-only, like WhatsApp). No-op {@link Stoppable}. */ + receive(_sink: ChatSink): Stoppable { + return { stop() {} }; + } + + /** + * Self-driving device-link pairing: spawn `signal-cli link`, stream the URI as a + * QR prompt, persist the associated account number to the vault, and emit `linked` + * carrying it (so the coordinator records it as the owner destination). The + * coordinator dispatches this channel with `pairingNeedsInbound: false`, so it + * skips the authenticate precondition and the transient inbound receiver. + */ + startPairing(deps: PairingDeps): PairingSession { + const session = this.#cli.link(this.#deviceName); + const vault = deps.vault; + const vaultKey = this.#vaultKey; + const label = this.descriptor.displayName; + let stopped = false; + + async function* updates(): AsyncGenerator { + let linked = false; + try { + for await (const event of session.events()) { + if (stopped) return; + if (event.kind === "uri") { + yield { + status: "awaiting", + prompt: { + kind: "code", + data: event.uri, + humanHint: + "Open Signal > Settings > Linked Devices > Link New Device, then scan this code.", + expiresAt: Date.now() + PAIRING_TTL_MS, + }, + }; + continue; + } + // event.kind === "linked": persist the account number FIRST, then mark + // linked + signal success. Order matters: if the vault write throws, + // `linked` stays false so the catch below surfaces the error (rather than + // ending the stream with no terminal update). + await vault.set(vaultKey, event.account); + linked = true; + yield { status: "linked", chatId: event.account, label }; + return; + } + // The stream ended without an association. + yield stopped ? { status: "expired" } : { status: "error", reason: "link_incomplete" }; + } catch (error) { + if (!linked) { + yield { + status: "error", + reason: error instanceof Error ? error.message : "link_failed", + }; + } + } + } + + return { + updates, + stop: () => { + if (stopped) return; + stopped = true; + session.stop(); + }, + }; + } +} diff --git a/packages/vesper-core/src/connections/state.test.ts b/packages/vesper-core/src/connections/state.test.ts index 7a72bd5..65e9747 100644 --- a/packages/vesper-core/src/connections/state.test.ts +++ b/packages/vesper-core/src/connections/state.test.ts @@ -23,14 +23,16 @@ describe("channelStates", () => { }); }); - test("a channel with no shipped handler is never available or running", () => { - const states = channelStates({ runningIds: ["signal"] }); - const signal = byId(states, "signal"); - expect(signal.available).toBe(false); + test("a channel with no built-in handler is never available or running", () => { + // whatsapp-web ships no BUILT-IN plugin (the opt-in package registers it at + // runtime in the daemon); in core it is unavailable. + const states = channelStates({ runningIds: ["whatsapp-web"] }); + const wweb = byId(states, "whatsapp-web"); + expect(wweb.available).toBe(false); // Even if runningIds claims it, an unavailable channel must never read as running. - expect(signal.running).toBe(false); - expect(signal.configured).toBe(false); - expect(signal.enabled).toBe(false); + expect(wweb.running).toBe(false); + expect(wweb.configured).toBe(false); + expect(wweb.enabled).toBe(false); }); test("configured falls back to the descriptor vault key when wiring omits it", () => {