@vellumai/assistant 0.11.4-dev.202608201513.9b3b830 → 0.11.4-dev.202608201710.fcf0423

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -18,7 +18,7 @@ When you introduce a new env var that the assistant process needs to read at run
18
18
 
19
19
  The daemon must **never** block startup due to **subsystem** failures (DB, Qdrant, plugins, feature flags, etc.). If an individual subsystem fails, log the error and continue in degraded mode so the process remains reachable for health checks and diagnostics.
20
20
 
21
- **Exception — duplicate daemon detection:** If the daemon cannot establish **any** client-facing transport because another daemon already holds both the IPC socket and HTTP port, it must exit immediately. A daemon with no transport is unmanageable (invisible to health checks, unreachable by stop commands) yet still runs background jobs (scheduler, memory worker, background wake) against the shared database, causing duplicate side effects.
21
+ **Exception, occupied client-facing transports:** A **transport** is not a subsystem. If either client-facing transport's address is taken (the IPC socket in `ipc/assistant-server.ts`, the runtime HTTP port in `runtime/http-server.ts`), the daemon exits with an `EADDRINUSE`-coded error so `emitDaemonError` reports `PORT_IN_USE`. Half a daemon is worse than none: missing IPC makes it unmanageable while its background jobs still write the shared database, and missing HTTP makes it read healthy over IPC while the gateway proxies `/v1/*` to a foreign listener. Bind failures that are not address collisions (permission denied, fd exhaustion) stay non-fatal.
22
22
 
23
23
  ## DB migration readiness gating
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608201513.9b3b830",
3
+ "version": "0.11.4-dev.202608201710.fcf0423",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,10 +13,12 @@
13
13
  * - `revoke` with no flags (channel: undefined)
14
14
  * - `revoke --channel phone` sends channel param
15
15
  * - `create --channel fax` → invalid channel, exits with code 1, IPC not called
16
+ * - the accepted `--channel` set is the canonical CHANNEL_IDS vocabulary
16
17
  */
17
18
 
18
19
  import { beforeEach, describe, expect, mock, test } from "bun:test";
19
20
 
21
+ import { CHANNEL_IDS } from "@vellumai/service-contracts/channels";
20
22
  import { Command } from "commander";
21
23
 
22
24
  // ---------------------------------------------------------------------------
@@ -242,6 +244,35 @@ describe("channel-verification-sessions create", () => {
242
244
  expect(lastIpcCall).toBeNull();
243
245
  });
244
246
 
247
+ test("--channel discord reaches IPC (canonical vocabulary, not a local copy)", async () => {
248
+ const { exitCode } = await runCommand([
249
+ "channel-verification-sessions",
250
+ "create",
251
+ "--channel",
252
+ "discord",
253
+ ]);
254
+
255
+ expect(exitCode).toBe(0);
256
+ expect(lastIpcCall!.method).toBe("channel_verification_sessions_create");
257
+ expect(lastIpcCall!.params).toMatchObject({
258
+ body: { channel: "discord", purpose: "guardian" },
259
+ });
260
+ });
261
+
262
+ test("every canonical channel id is accepted", async () => {
263
+ for (const channel of CHANNEL_IDS) {
264
+ const { exitCode } = await runCommand([
265
+ "channel-verification-sessions",
266
+ "create",
267
+ "--channel",
268
+ channel,
269
+ ]);
270
+
271
+ expect(exitCode).toBe(0);
272
+ expect(lastIpcCall!.params).toMatchObject({ body: { channel } });
273
+ }
274
+ });
275
+
245
276
  test("IPC error results in exit code 1", async () => {
246
277
  mockIpcResult = { ok: false, error: "Could not connect" };
247
278
 
@@ -10,15 +10,16 @@ export const channelVerificationSessionsHelp: CliCommandHelp = {
10
10
  ],
11
11
  helpText: `
12
12
  Verification sessions are used to verify guardian bindings and trusted
13
- contacts across channels (telegram, phone, slack, email). Three flows exist:
13
+ contacts across channels (telegram, phone, slack, discord, email). Three
14
+ flows exist:
14
15
 
15
16
  1. Inbound challenge — the assistant generates a secret code and waits
16
17
  for the guardian to send it back on the channel. Used when the
17
18
  guardian can already message the assistant.
18
19
 
19
20
  2. Outbound verification — the assistant sends a verification code to
20
- a destination (Telegram handle, phone number, Slack user ID) and
21
- waits for confirmation. Used when bootstrapping a new channel.
21
+ a destination (Telegram handle, phone number, Slack or Discord user
22
+ ID) and waits for confirmation. Used when bootstrapping a new channel.
22
23
 
23
24
  3. Trusted contact verification — verifies a contact channel that
24
25
  already exists in the contact graph, sending a code to the channel
@@ -36,7 +37,7 @@ Examples:
36
37
  options: [
37
38
  {
38
39
  flags: "--channel <channel>",
39
- description: "Channel type (telegram, phone, slack, email)",
40
+ description: "Channel type (telegram, phone, slack, discord, email)",
40
41
  },
41
42
  {
42
43
  flags: "--destination <destination>",
@@ -72,8 +73,8 @@ Routes between three creation modes based on the provided options:
72
73
 
73
74
  2. Outbound: --channel <ch> --destination <dest>
74
75
  Sends a verification code to the given destination. Supports telegram
75
- (handle or chat ID), phone (E.164 number), slack (user ID), and email.
76
- Use --rebind to replace an existing guardian binding.
76
+ (handle or chat ID), phone (E.164 number), slack and discord (user ID),
77
+ and email. Use --rebind to replace an existing guardian binding.
77
78
 
78
79
  3. Inbound: --channel <ch> (no --destination)
79
80
  Generates a challenge secret for the guardian to send back on the
@@ -113,7 +114,7 @@ Examples:
113
114
  options: [
114
115
  {
115
116
  flags: "--channel <channel>",
116
- description: "Channel type (telegram, phone, slack, email)",
117
+ description: "Channel type (telegram, phone, slack, discord, email)",
117
118
  required: true,
118
119
  },
119
120
  {
@@ -137,7 +138,7 @@ Examples:
137
138
  options: [
138
139
  {
139
140
  flags: "--channel <channel>",
140
- description: "Channel type (telegram, phone, slack, email)",
141
+ description: "Channel type (telegram, phone, slack, discord, email)",
141
142
  required: true,
142
143
  },
143
144
  ],
@@ -1,3 +1,12 @@
1
+ // `cli/no-daemon-internals` forbids hoisting `channels/types.js`, so the CLI
2
+ // reads the vocabulary straight from the contract package that file re-exports.
3
+ // Both sides then validate `--channel` against the same set.
4
+
5
+ import {
6
+ CHANNEL_IDS,
7
+ type ChannelId,
8
+ isChannelId,
9
+ } from "@vellumai/service-contracts/channels";
1
10
  import type { Command } from "commander";
2
11
 
3
12
  import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
@@ -6,25 +15,6 @@ import { registerCommand } from "../lib/register-command.js";
6
15
  import { writeOutput } from "../output.js";
7
16
  import { channelVerificationSessionsHelp } from "./channel-verification-sessions.help.js";
8
17
 
9
- // ---------------------------------------------------------------------------
10
- // Local channel validation (replaces daemon-internal channels/types.js import)
11
- // ---------------------------------------------------------------------------
12
-
13
- const VALID_CHANNEL_IDS = [
14
- "telegram",
15
- "phone",
16
- "vellum",
17
- "whatsapp",
18
- "slack",
19
- "email",
20
- "platform",
21
- ] as const;
22
- type ChannelId = (typeof VALID_CHANNEL_IDS)[number];
23
-
24
- function isChannelId(raw: string): raw is ChannelId {
25
- return (VALID_CHANNEL_IDS as readonly string[]).includes(raw);
26
- }
27
-
28
18
  /**
29
19
  * Validate the --channel option. Returns the validated ChannelId or writes an
30
20
  * error and returns `false`. When `required` is false an absent value is fine
@@ -49,7 +39,7 @@ function validateChannelOpt(
49
39
  if (required) {
50
40
  writeOutput(cmd, {
51
41
  ok: false,
52
- error: `The "channel" option is required. Valid values: ${VALID_CHANNEL_IDS.join(", ")}`,
42
+ error: `The "channel" option is required. Valid values: ${CHANNEL_IDS.join(", ")}`,
53
43
  });
54
44
  process.exitCode = 1;
55
45
  return false;
@@ -59,7 +49,7 @@ function validateChannelOpt(
59
49
  if (!isChannelId(raw)) {
60
50
  writeOutput(cmd, {
61
51
  ok: false,
62
- error: `Invalid channel "${raw}". Valid values: ${VALID_CHANNEL_IDS.join(", ")}`,
52
+ error: `Invalid channel "${raw}". Valid values: ${CHANNEL_IDS.join(", ")}`,
63
53
  });
64
54
  process.exitCode = 1;
65
55
  return false;
@@ -198,8 +198,10 @@ export async function runDaemon(): Promise<void> {
198
198
  }
199
199
  }
200
200
 
201
- // Start the runtime HTTP server early so /healthz answers ASAP. A bind
202
- // failure is non-fatal — the daemon falls back to IPC-only operation.
201
+ // Start the runtime HTTP server early so /healthz answers ASAP. Throws on
202
+ // EADDRINUSE to abort startup: another process holds the port every HTTP
203
+ // client (and the gateway's /v1/* proxy) targets, so an IPC-only daemon
204
+ // would look healthy while all HTTP traffic 502s.
203
205
  await startRuntimeHttpServer();
204
206
 
205
207
  // Warms the configured-probe cache (credential reads only, no DB). Fired
@@ -23,6 +23,22 @@ export interface DaemonStartupError {
23
23
 
24
24
  const DAEMON_ERROR_PREFIX = "DAEMON_ERROR:";
25
25
 
26
+ /**
27
+ * Build an `EADDRINUSE`-coded error so callers (and {@link categorizeDaemonError})
28
+ * can branch on `err.code` and surface the structured "already running"
29
+ * guidance instead of a generic UNKNOWN. Used by every transport that detects
30
+ * an occupied address itself rather than receiving a kernel-coded error.
31
+ */
32
+ export function makeAddrInUseError(
33
+ message: string,
34
+ cause?: unknown,
35
+ ): NodeJS.ErrnoException {
36
+ const err = new Error(message, cause === undefined ? undefined : { cause });
37
+ const errno = err as NodeJS.ErrnoException;
38
+ errno.code = "EADDRINUSE";
39
+ return errno;
40
+ }
41
+
26
42
  /**
27
43
  * Inspect an error and return a categorized {@link DaemonStartupError}.
28
44
  */
@@ -15,6 +15,8 @@ import {
15
15
  removeIpcEndpointFile,
16
16
  } from "@vellumai/ipc-server-utils";
17
17
 
18
+ import { makeAddrInUseError } from "../daemon/startup-error.js";
19
+
18
20
  /**
19
21
  * Maximum time to wait for the probe `connect()` to settle before declaring
20
22
  * the path occupied. Without a bound, a hung process holding the socket would
@@ -25,17 +27,6 @@ import {
25
27
  */
26
28
  const PROBE_CONNECT_TIMEOUT_MS = 2000;
27
29
 
28
- /**
29
- * Build an `EADDRINUSE`-coded error so callers (and `categorizeDaemonError`)
30
- * can branch on `err.code` and surface the structured "already running"
31
- * guidance instead of a generic UNKNOWN.
32
- */
33
- function makeAddrInUseError(message: string): NodeJS.ErrnoException {
34
- const err = new Error(message) as NodeJS.ErrnoException;
35
- err.code = "EADDRINUSE";
36
- return err;
37
- }
38
-
39
30
  /**
40
31
  * Probe-connect to `socketPath`. Behavior:
41
32
  * - Path doesn't exist → return.
@@ -0,0 +1,131 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+
3
+ import { emitDaemonError } from "../../daemon/startup-error.js";
4
+ import {
5
+ startRuntimeHttpServer,
6
+ stopRuntimeHttpServer,
7
+ } from "../http-server.js";
8
+
9
+ /**
10
+ * The daemon binds two client-facing transports. An occupied runtime HTTP port
11
+ * must abort startup the same way an occupied IPC socket does, so the daemon
12
+ * can never answer IPC (reading healthy to `vellum ps` and platform status)
13
+ * while the gateway proxies /v1/* to a foreign listener.
14
+ */
15
+ describe("runtime HTTP port collision", () => {
16
+ const originalPort = process.env.RUNTIME_HTTP_PORT;
17
+
18
+ afterEach(async () => {
19
+ await stopRuntimeHttpServer();
20
+ if (originalPort === undefined) {
21
+ delete process.env.RUNTIME_HTTP_PORT;
22
+ } else {
23
+ process.env.RUNTIME_HTTP_PORT = originalPort;
24
+ }
25
+ });
26
+
27
+ test("aborts startup when a foreign process holds the runtime HTTP port", async () => {
28
+ /**
29
+ * Tests that a runtime HTTP bind collision fails daemon startup instead of
30
+ * silently degrading to IPC-only operation.
31
+ */
32
+
33
+ // GIVEN a foreign process holding a port
34
+ const foreign = Bun.serve({
35
+ port: 0,
36
+ hostname: "127.0.0.1",
37
+ fetch: () => new Response("foreign"),
38
+ });
39
+
40
+ // AND the daemon configured to serve HTTP on that same port
41
+ process.env.RUNTIME_HTTP_PORT = String(foreign.port);
42
+
43
+ try {
44
+ // WHEN the daemon starts its runtime HTTP server
45
+ const err = await startRuntimeHttpServer().then(
46
+ () => null,
47
+ (e: unknown) => e,
48
+ );
49
+
50
+ // THEN startup fails rather than continuing without HTTP
51
+ expect(err).toBeInstanceOf(Error);
52
+
53
+ // AND the failure carries the address-collision code the daemon's
54
+ // startup-error categorization branches on
55
+ expect((err as NodeJS.ErrnoException).code).toBe("EADDRINUSE");
56
+
57
+ // AND the message names the occupied port and the env var that moves it
58
+ expect((err as Error).message).toContain(String(foreign.port));
59
+ expect((err as Error).message).toContain("RUNTIME_HTTP_PORT");
60
+ } finally {
61
+ foreign.stop(true);
62
+ }
63
+ });
64
+
65
+ test("reports a runtime HTTP port collision as PORT_IN_USE on stderr", async () => {
66
+ /**
67
+ * Tests that the bind failure reaches consumers that parse the daemon's
68
+ * structured startup-error line (e.g. the macOS app) as PORT_IN_USE.
69
+ */
70
+
71
+ // GIVEN a foreign process holding the daemon's runtime HTTP port
72
+ const foreign = Bun.serve({
73
+ port: 0,
74
+ hostname: "127.0.0.1",
75
+ fetch: () => new Response("foreign"),
76
+ });
77
+ process.env.RUNTIME_HTTP_PORT = String(foreign.port);
78
+
79
+ // AND stderr captured so the structured startup-error line is observable
80
+ const written: string[] = [];
81
+ const originalWrite = process.stderr.write.bind(process.stderr);
82
+ process.stderr.write = ((chunk: string | Uint8Array) => {
83
+ written.push(chunk.toString());
84
+ return true;
85
+ }) as typeof process.stderr.write;
86
+
87
+ try {
88
+ // WHEN the daemon's startup error handler reports the bind failure
89
+ const err = await startRuntimeHttpServer().then(
90
+ () => null,
91
+ (e: unknown) => e,
92
+ );
93
+ emitDaemonError(err);
94
+
95
+ // THEN the structured line categorizes the failure as PORT_IN_USE
96
+ const line = written.find((entry) => entry.includes("DAEMON_ERROR:"));
97
+ expect(line).toBeDefined();
98
+ const structured = JSON.parse(
99
+ (line as string).replace("DAEMON_ERROR:", "").trim(),
100
+ ) as Record<string, unknown>;
101
+ expect(structured.error).toBe("PORT_IN_USE");
102
+ } finally {
103
+ process.stderr.write = originalWrite;
104
+ foreign.stop(true);
105
+ }
106
+ });
107
+
108
+ test("binds and leaves the singleton serving when the port is free", async () => {
109
+ /**
110
+ * Tests that the collision guard does not turn ordinary startup into a
111
+ * failure: a free port still yields a listening HTTP server.
112
+ */
113
+
114
+ // GIVEN a free port for the runtime HTTP server
115
+ const probe = Bun.serve({
116
+ port: 0,
117
+ hostname: "127.0.0.1",
118
+ fetch: () => new Response("probe"),
119
+ });
120
+ const freePort = probe.port;
121
+ probe.stop(true);
122
+ process.env.RUNTIME_HTTP_PORT = String(freePort);
123
+
124
+ // WHEN the daemon starts its runtime HTTP server
125
+ await startRuntimeHttpServer();
126
+
127
+ // THEN the server answers its liveness probe
128
+ const response = await fetch(`http://127.0.0.1:${freePort}/healthz`);
129
+ expect(response.status).toBe(200);
130
+ });
131
+ });
@@ -27,6 +27,7 @@ import {
27
27
  isDbMigrationGateBypassed,
28
28
  } from "../daemon/daemon-readiness.js";
29
29
  import { processMessage } from "../daemon/process-message.js";
30
+ import { makeAddrInUseError } from "../daemon/startup-error.js";
30
31
  import {
31
32
  createLiveVoiceConnection,
32
33
  type LiveVoiceConnection,
@@ -975,9 +976,14 @@ let instance: RuntimeHttpServer | null = null;
975
976
 
976
977
  /**
977
978
  * Start the runtime HTTP server singleton early in daemon startup so /healthz
978
- * answers ASAP. A bind failure (port in use, permission denied, fd exhaustion)
979
- * is non-fatal: it is logged and the daemon falls back to IPC-only operation,
980
- * leaving the singleton unset.
979
+ * answers ASAP.
980
+ *
981
+ * An occupied address (`EADDRINUSE`) aborts startup: whatever holds the port
982
+ * owns every HTTP client of this workspace, including the gateway proxy that
983
+ * fronts `/v1/*`. Continuing would leave a daemon that answers IPC (so `vellum
984
+ * ps` and platform status read healthy) while every proxied HTTP route 502s
985
+ * against a foreign listener. Any other bind failure (permission denied, fd
986
+ * exhaustion) is non-fatal, logged with the singleton left unset.
981
987
  */
982
988
  export async function startRuntimeHttpServer(): Promise<void> {
983
989
  const port = getRuntimeHttpPort();
@@ -992,11 +998,20 @@ export async function startRuntimeHttpServer(): Promise<void> {
992
998
  "Daemon startup: runtime HTTP server listening",
993
999
  );
994
1000
  } catch (err) {
1001
+ if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
1002
+ log.error(
1003
+ { err, port, hostname },
1004
+ "Runtime HTTP port already in use, aborting startup to avoid an HTTP-blind daemon",
1005
+ );
1006
+ throw makeAddrInUseError(
1007
+ `Runtime HTTP port ${hostname}:${port} is already in use by another process. Stop it, or set RUNTIME_HTTP_PORT to a free port.`,
1008
+ err,
1009
+ );
1010
+ }
995
1011
  log.warn(
996
1012
  { err, port },
997
1013
  "Failed to start runtime HTTP server, continuing without it",
998
1014
  );
999
- instance = null;
1000
1015
  }
1001
1016
  }
1002
1017
 
@@ -1007,7 +1022,7 @@ export async function startRuntimeHttpServer(): Promise<void> {
1007
1022
  * expiry, profile reaping) must still run; the retry sweep additionally skips
1008
1023
  * its cycles while readiness is unready. Never called before migrations settle,
1009
1024
  * so the sweeps can't race a schema mid-migration. No-op if the HTTP server
1010
- * failed to bind (IPC-only mode) or sweeps already started.
1025
+ * isn't running or sweeps already started.
1011
1026
  */
1012
1027
  export function startRuntimeHttpServerBackgroundSweeps(): void {
1013
1028
  instance?.startBackgroundSweeps();