@vellumai/vellum-gateway 0.11.7 → 0.11.8-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +2 -2
  2. package/ARCHITECTURE.md +3 -3
  3. package/Dockerfile +1 -0
  4. package/knip.json +2 -5
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  9. package/node_modules/@vellumai/environments/package.json +21 -0
  10. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  11. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  12. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  13. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  14. package/node_modules/@vellumai/environments/src/index.ts +22 -0
  15. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  16. package/node_modules/@vellumai/environments/src/seeds.ts +99 -0
  17. package/node_modules/@vellumai/environments/src/shell.test.ts +56 -0
  18. package/node_modules/@vellumai/environments/src/shell.ts +65 -0
  19. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  20. package/node_modules/@vellumai/environments/src/types.ts +70 -0
  21. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  24. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  25. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  26. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
  27. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
  28. package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
  29. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
  30. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
  31. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
  32. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
  33. package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
  34. package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
  35. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  36. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  37. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  38. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  39. package/openapi.json +120 -9
  40. package/package.json +3 -1
  41. package/src/__tests__/actor-token-revocation.test.ts +7 -1
  42. package/src/__tests__/contact-form-kinds.test.ts +19 -0
  43. package/src/__tests__/contact-handlers.test.ts +40 -0
  44. package/src/__tests__/contact-prompt-submit.test.ts +213 -6
  45. package/src/__tests__/contact-record-submit.test.ts +872 -0
  46. package/src/__tests__/contacts-control-plane-proxy.test.ts +47 -0
  47. package/src/__tests__/contacts-scoped-route-fallthrough.test.ts +6 -0
  48. package/src/__tests__/credential-reader.test.ts +91 -58
  49. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +39 -40
  50. package/src/__tests__/credential-watcher.test.ts +3 -69
  51. package/src/__tests__/delete-contact-if-orphaned.test.ts +231 -0
  52. package/src/__tests__/dockerfile-workspace-packages.test.ts +38 -0
  53. package/src/__tests__/guardian-binding-channel-reuse.test.ts +4 -0
  54. package/src/__tests__/guardian-form-submit.test.ts +211 -0
  55. package/src/__tests__/guardian-request-delivery-update.test.ts +100 -0
  56. package/src/__tests__/helpers/contact-route-table.ts +1 -0
  57. package/src/__tests__/inbound-contact-seed.test.ts +411 -0
  58. package/src/__tests__/invite-redemption-engine.test.ts +137 -15
  59. package/src/__tests__/ipc-socket-path.test.ts +15 -7
  60. package/src/__tests__/live-paths-guard.test.ts +108 -0
  61. package/src/__tests__/live-voice-websocket.test.ts +7 -1
  62. package/src/__tests__/pair-device-bound.test.ts +7 -1
  63. package/src/__tests__/refresh-device-binding.test.ts +7 -1
  64. package/src/__tests__/slack-normalize.test.ts +7 -7
  65. package/src/__tests__/slack-reaction-normalize.test.ts +5 -3
  66. package/src/__tests__/slack-socket-mode-group-dm.test.ts +4 -4
  67. package/src/__tests__/slack-socket-mode-outbound-root.test.ts +5 -4
  68. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +10 -8
  69. package/src/__tests__/telegram-normalize.test.ts +4 -4
  70. package/src/__tests__/upsert-verified-contact-channel.test.ts +27 -175
  71. package/src/__tests__/verification-session-consume.test.ts +4 -0
  72. package/src/approvals/guardian-request-service.ts +37 -19
  73. package/src/channels/inbound-event.ts +12 -0
  74. package/src/credential-reader.ts +58 -42
  75. package/src/credential-watcher.ts +48 -46
  76. package/src/db/__tests__/guardian-request-store.test.ts +152 -21
  77. package/src/db/connection.ts +4 -21
  78. package/src/db/contact-store.ts +270 -1
  79. package/src/db/data-migrations/index.ts +2 -0
  80. package/src/db/data-migrations/m0018-trust-rules-scope-column.ts +47 -0
  81. package/src/db/guardian-request-store.ts +136 -75
  82. package/src/db/schema.ts +12 -0
  83. package/src/db/trust-rule-store.ts +14 -8
  84. package/src/discord/forward.test.ts +132 -0
  85. package/src/discord/forward.ts +21 -10
  86. package/src/discord/gateway-socket.test.ts +164 -3
  87. package/src/discord/gateway-socket.ts +260 -1
  88. package/src/discord/intents.test.ts +14 -0
  89. package/src/discord/intents.ts +13 -1
  90. package/src/discord/message-schemas.ts +168 -16
  91. package/src/discord/normalize.test.ts +299 -2
  92. package/src/discord/normalize.ts +191 -4
  93. package/src/feature-flag-registry.json +22 -13
  94. package/src/handlers/handle-inbound.ts +34 -13
  95. package/src/http/routes/contact-prompt.ts +337 -91
  96. package/src/http/routes/contacts-control-plane-proxy.ts +403 -88
  97. package/src/http/routes/contacts-control-plane-route-table.ts +12 -1
  98. package/src/http/routes/contacts-control-plane-routes.ts +78 -7
  99. package/src/http/routes/guardian-form-submit.ts +313 -0
  100. package/src/http/routes/log-export.ts +3 -3
  101. package/src/http/routes/trust-rules-routes.ts +6 -0
  102. package/src/http/routes/trust-rules.ts +7 -1
  103. package/src/index.ts +5 -26
  104. package/src/ipc/__tests__/guardian-request-routes.test.ts +27 -30
  105. package/src/ipc/contact-handlers.ts +13 -0
  106. package/src/ipc/guardian-request-handlers.ts +27 -11
  107. package/src/paths.ts +33 -4
  108. package/src/risk/bash-risk-classifier.ts +22 -4
  109. package/src/risk/command-registry/commands/assistant.ts +11 -0
  110. package/src/risk/file-risk-classifier.ts +5 -1
  111. package/src/risk/shell-parser.ts +5 -1
  112. package/src/risk/trust-rule-cache.ts +59 -6
  113. package/src/schema.ts +29 -0
  114. package/src/slack/message-change-normalizer.ts +5 -8
  115. package/src/slack/normalize.test.ts +19 -20
  116. package/src/slack/reaction-normalizer.ts +12 -4
  117. package/src/telegram/normalize.test.ts +1 -1
  118. package/src/telegram/normalize.ts +0 -1
  119. package/src/telegram/webhook-manager.ts +3 -0
  120. package/src/verification/contact-helpers.ts +144 -190
  121. package/src/avatar-sync/avatar-channel-syncer.ts +0 -78
  122. package/src/avatar-sync/avatar-sync-watcher.ts +0 -86
  123. package/src/avatar-sync/slack-avatar-syncer.ts +0 -70
  124. package/src/avatar-sync/types.ts +0 -16
package/AGENTS.md CHANGED
@@ -217,7 +217,7 @@ Two orthogonal axes, do not conflate them:
217
217
  - **Admission** (above) — _who gets in the door_. `TRUST_CLASS_RANK` vs `ADMISSION_FLOOR`, enforced across gateway + runtime.
218
218
  - **Capabilities** — _what an actor may do once admitted_. Resolved in the runtime, never on the gateway.
219
219
 
220
- **Trust classes** (`TrustClass` in `assistant/src/runtime/actor-trust-resolver.ts`) are the _role_, ranked by `TRUST_CLASS_RANK`:
220
+ **Trust classes** (`TrustClass` in `assistant/src/runtime/trust-class.ts`, re-exported from `actor-trust-resolver.ts`) are the _role_, ranked by `TRUST_CLASS_RANK`:
221
221
 
222
222
  | Class | Rank | Meaning |
223
223
  | -------------------- | ---- | ------------------------------------------------------------------------ |
@@ -262,5 +262,5 @@ The gateway owns guardian approval requests and their delivery records: the `gua
262
262
 
263
263
  - **Decide atomicity (the core invariant).** `guardian_requests_decide` commits the status CAS (`pending → approved|denied`, first-writer-wins) and the decision's ACL outcome (`activate_member`, `seed_unverified`, `block`, `mint_outbound_session`) in ONE gateway transaction. A failed outcome rolls back the CAS — the request stays `pending` and the guardian can decide again; an `approved` request without its ACL write cannot exist, and deciding twice never applies two outcomes. There is no reopen path.
264
264
  - **Schema notes.** There is no `source_type` column — the wire DTO's `sourceType` is derived from `source_channel` (`phone` → voice, `vellum` → desktop, else channel), and the list route translates a `sourceType` filter into `source_channel` predicates. The requester-side conversation is `source_conversation_id`. Request ids are caller-supplied and load-bearing (deterministic access-request ids; `tool_approval` rows reuse the pending-interaction id as PK).
265
- - **Expiry is daemon-keyed for interaction-bound kinds.** `tool_approval`/`pending_question` requests die with the daemon's in-memory `pendingInteractions` map, so the daemon calls `guardian_requests_expire_interaction_bound` at boot; the gateway never expires them on its own restart. Persistent kinds (`access_request`, `tool_grant_request`) expire past `expires_at` via the daemon's 60s `guardian_requests_sweep_expired` sweep, which returns the expired rows for daemon-side card withdrawal and requester notices.
265
+ - **Expiry is daemon-keyed for interaction-bound kinds.** `tool_approval`/`pending_question` requests die with the daemon's in-memory `pendingInteractions` map, so the daemon calls `guardian_requests_expire_interaction_bound` at boot; the gateway never expires them on its own restart, and the boot call never touches persistent kinds. Persistent kinds (`access_request`, `tool_grant_request`) expire via the daemon's 60s sweep: `guardian_requests_list_expired_pending` returns a bounded, read-only batch of past-deadline pending rows, the daemon runs each row's card withdrawal and requester notice, and only then confirms with the per-request `guardian_requests_expire` CAS, so a lost response or crash leaves the row listed for the next round instead of stranding its side effects. The decide CAS carries an `expires_at` guard, so a decision arriving past the deadline loses the arbitration atomically.
266
266
  - **Provenance.** The tables moved here from the assistant DB: gateway data migration m0015 backfilled them, and m0016 (checkpoint-gated on m0015, with a final catch-up copy) dropped the assistant-side tables.
package/ARCHITECTURE.md CHANGED
@@ -407,7 +407,7 @@ Runtime detects needs_confirmation
407
407
 
408
408
  **Guardian-aware routing:** When a guardian binding exists for the channel, the approval flow resolves the sender's actor role (`guardian` vs `non-guardian`). Non-guardian actors have `forcePromptSideEffects` set on the session so all side-effect tools trigger approval prompts regardless of existing allow rules. Approval prompts for non-guardian actions are routed to the guardian's delivery chat (not the requester's chat), and a guardian request (kind `tool_approval`) is recorded in the gateway-owned `guardian_requests` table via `guardian_requests_create`. When the guardian approves or denies, the decision commits through `guardian_requests_decide`, is applied to the underlying run, and the requester's chat is notified of the outcome. Guardian actors follow the standard approval flow. Guardian approval follow-ups also use the conversational engine with role-specific context; `approve_always` is downgraded to `approve_once` for guardian approvals since permanent allow-rules require guardian authority. All guardian state (bindings, challenges, guardian requests) is scoped to the `(assistantId, channel)` pair -- the `assistantId` parameter flows through `handleChannelInbound`, `validateAndConsumeVerification`, `isGuardian`, and `getGuardianBinding`.
409
409
 
410
- **Proactive expiry sweep:** The daemon runs a periodic sweep every 60 seconds (`assistant/src/runtime/routes/guardian-expiry-sweep.ts`) that asks the gateway to CAS-expire pending guardian requests past their `expiresAt` (`guardian_requests_sweep_expired`), then fans out card withdrawals and requester expiry notices from the returned rows. This ensures expired approvals are closed without waiting for follow-up traffic from either party.
410
+ **Proactive expiry sweep:** The daemon runs a periodic sweep every 60 seconds (`assistant/src/runtime/routes/guardian-expiry-sweep.ts`) that reads a bounded batch of past-deadline pending requests (`guardian_requests_list_expired_pending`, read-only), fans out card withdrawals and requester expiry notices per row, and only then confirms each with the per-request `guardian_requests_expire` CAS. The status flip is the receipt that the side effects ran: a lost IPC response or a daemon crash leaves the row pending and listed for the next round. Expired approvals close without waiting for follow-up traffic from either party, and a decision arriving past the deadline loses to expiry atomically (the decide CAS guards on `expires_at`).
411
411
 
412
412
  **Gateway-origin ingress contract:** The JWT token exchanged during gateway-to-runtime authentication proves gateway origin (via the `aud=vellum-daemon` claim). No separate header is required.
413
413
 
@@ -422,7 +422,7 @@ Runtime detects needs_confirmation
422
422
  | `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
423
423
  | `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
424
424
  | `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()` — in-memory deliver-once guard for terminal reply idempotency |
425
- | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_sweep_expired`) |
425
+ | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_list_expired_pending` / `_expire`) |
426
426
  | `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()` — sends approval payload to gateway |
427
427
  | `gateway/src/telegram/send.ts` | `buildInlineKeyboard()` — renders approval actions as Telegram inline buttons |
428
428
  | `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (DM-only, drops callbacks without data) |
@@ -1013,7 +1013,7 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
1013
1013
 
1014
1014
  5. **Request-code disambiguation**: When a guardian has multiple pending requests across concurrent calls, they prefix their answer with the 6-character hex request code to indicate which question they are answering. This allows unambiguous routing even when questions arrive on the same channel in quick succession.
1015
1015
 
1016
- 6. **Expiry sweep**: The daemon's `guardian-expiry-sweep.ts` runs a periodic 60-second sweep that asks the gateway to CAS-expire pending requests past their expiry timestamp (`guardian_requests_sweep_expired`). Expiry notices are sent to all delivery destinations associated with each expired request (`guardian-action-sweep.ts`).
1016
+ 6. **Expiry sweep**: The daemon's `guardian-expiry-sweep.ts` runs a periodic 60-second sweep that lists past-deadline pending requests (`guardian_requests_list_expired_pending`), runs each row's side effects, and confirms each with the per-request `guardian_requests_expire` CAS. Expiry notices are sent to all delivery destinations associated with each expired request (`guardian-action-sweep.ts`).
1017
1017
 
1018
1018
  7. **One store, many kinds**: Voice questions share the gateway's `guardian_requests` store with every other guardian request, distinguished by `kind` — `tool_approval` and `tool_grant_request` rows gate specific tool invocations, `pending_question` rows carry free-form questions from voice calls that require human input to continue the conversation, and `access_request` rows gate unknown-sender access.
1019
1019
 
package/Dockerfile CHANGED
@@ -50,6 +50,7 @@ COPY --from=builder --chown=gateway:gateway /app /app
50
50
  # Copy source after installing dependencies for better layer caching.
51
51
  COPY --chown=gateway:gateway packages/assistant-client /app/packages/assistant-client
52
52
  COPY --chown=gateway:gateway packages/ces-client /app/packages/ces-client
53
+ COPY --chown=gateway:gateway packages/environments /app/packages/environments
53
54
  COPY --chown=gateway:gateway packages/gateway-client /app/packages/gateway-client
54
55
  COPY --chown=gateway:gateway packages/ipc-server-utils /app/packages/ipc-server-utils
55
56
  COPY --chown=gateway:gateway packages/service-contracts /app/packages/service-contracts
package/knip.json CHANGED
@@ -1,13 +1,10 @@
1
1
  {
2
- "entry": [
3
- "src/**/*.test.ts",
4
- "src/**/__tests__/**/*.ts",
5
- "src/cli/index.ts"
6
- ],
2
+ "entry": ["src/**/*.test.ts", "src/**/__tests__/**/*.ts", "src/cli/index.ts"],
7
3
  "project": ["src/**/*.ts"],
8
4
  "ignoreDependencies": [
9
5
  "@vellumai/assistant-client",
10
6
  "@vellumai/ces-client",
7
+ "@vellumai/environments",
11
8
  "@vellumai/gateway-client",
12
9
  "@vellumai/ipc-server-utils",
13
10
  "@vellumai/service-contracts",
@@ -11,6 +11,7 @@
11
11
  "./client-metadata": "./src/client-metadata.ts",
12
12
  "./credential-rpc": "./src/credential-rpc.ts",
13
13
  "./ingress": "./src/ingress.ts",
14
+ "./no-response": "./src/no-response.ts",
14
15
  "./remote-web-pairing": "./src/remote-web-pairing.ts",
15
16
  "./twilio-ingress": "./src/twilio-ingress.ts",
16
17
  "./trust-rules": "./src/trust-rules.ts",
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
66
66
  * That irregularity is why this is stated rather than derived from the key,
67
67
  * and it is stated here because this file already owns what a channel is.
68
68
  *
69
+ * Both senses can hold a user token, which is the sharpest edge. The `slack`
70
+ * integration's persisted token *is* the installer's user token, held on its
71
+ * OAuth connection. `slack_channel` holds an optional `user_token` in the
72
+ * credential store, beside its bot and app tokens. Same words, different
73
+ * homes, and only the second is a credential-store key: a pasted token is
74
+ * always the channel's, because the integration's never leaves the exchange.
75
+ *
69
76
  * Deliberately only the key. What fields each credential requires is declared
70
77
  * once already, per service, in the gateway's credential specs; restating it
71
78
  * here would be a second copy of a different fact.
@@ -27,6 +27,7 @@ export * from "./handles.js";
27
27
  export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
+ export * from "./no-response.js";
30
31
  export * from "./remote-web-pairing.js";
31
32
  export * from "./twilio-ingress.js";
32
33
  export * from "./url-normalization.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The `<no_response/>` sentinel: the cross-service convention for a turn
3
+ * that deliberately produces no user-visible reply.
4
+ *
5
+ * The model emits it, the daemon stamps and strips it, channel delivery
6
+ * suppresses it, and clients hold live-streamed prefixes of it back from
7
+ * display. One definition here keeps every consumer's parsing identical;
8
+ * a hand-rolled copy in any one of them is how the case-sensitivity of a
9
+ * regex drifts.
10
+ */
11
+
12
+ /** Matches a message whose entire content is the sentinel. */
13
+ const NO_RESPONSE_ONLY_RE = /^\s*<no_response\s*\/?>\s*$/i;
14
+
15
+ /**
16
+ * Whether `text` is nothing but the sentinel: the whole reply is a
17
+ * deliberate non-response, as opposed to real content with an inline
18
+ * sentinel mixed in.
19
+ */
20
+ export function isNoResponseOnlyText(text: string): boolean {
21
+ return NO_RESPONSE_ONLY_RE.test(text);
22
+ }
23
+
24
+ /** Matches every sentinel occurrence for stripping it out of mixed content. */
25
+ export const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/gi;
26
+
27
+ /**
28
+ * Detection variant without the `g` flag: a `g`-flagged regex is stateful
29
+ * under `.test()` (it resumes from `lastIndex`), so reusing
30
+ * {@link NO_RESPONSE_INLINE_RE} for detection would alternate between
31
+ * matches and misses across calls.
32
+ */
33
+ const NO_RESPONSE_MARKER_RE = new RegExp(NO_RESPONSE_INLINE_RE.source, "i");
34
+
35
+ /** Whether `text` contains the sentinel anywhere. */
36
+ export function containsNoResponseMarker(text: string): boolean {
37
+ return NO_RESPONSE_MARKER_RE.test(text);
38
+ }
39
+
40
+ /** Removes every sentinel occurrence, trimming the leftover whitespace. */
41
+ export function stripNoResponseMarkers(text: string): string {
42
+ return text.replace(NO_RESPONSE_INLINE_RE, "").trim();
43
+ }
44
+
45
+ const NO_RESPONSE_SENTINEL_FORMS = [
46
+ "<no_response/>",
47
+ "<no_response />",
48
+ "<no_response>",
49
+ ] as const;
50
+
51
+ /**
52
+ * Whether `text` could still grow into the sentinel, i.e. it is a leading
53
+ * substring of one of its forms. Holding on these keeps a slowly-streamed
54
+ * `<no_response/>` from surfacing as visible partial content.
55
+ */
56
+ export function isPotentialNoResponsePrefix(text: string): boolean {
57
+ const lower = text.trim().toLowerCase();
58
+ if (lower.length === 0) {
59
+ return false;
60
+ }
61
+ return NO_RESPONSE_SENTINEL_FORMS.some((sentinel) =>
62
+ sentinel.startsWith(lower),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Whether `text` carries user-visible content worth acting on now. Returns
68
+ * `false` for empty text, the standalone sentinel, and any prefix that could
69
+ * still complete into one; returns `true` once real content remains after
70
+ * stripping inline sentinels.
71
+ */
72
+ export function hasDeliverableAssistantText(text: string): boolean {
73
+ const trimmed = text.trim();
74
+ if (trimmed.length === 0) {
75
+ return false;
76
+ }
77
+ if (NO_RESPONSE_ONLY_RE.test(trimmed)) {
78
+ return false;
79
+ }
80
+ if (isPotentialNoResponsePrefix(trimmed)) {
81
+ return false;
82
+ }
83
+ return stripNoResponseMarkers(trimmed).length > 0;
84
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@vellumai/environments",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./seeds": "./src/seeds.ts",
10
+ "./shell": "./src/shell.ts",
11
+ "./test-path-guard": "./src/test-path-guard.ts"
12
+ },
13
+ "scripts": {
14
+ "typecheck": "bunx tsc --noEmit",
15
+ "test": "bun test src/"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "1.3.11",
19
+ "typescript": "5.9.3"
20
+ }
21
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pins the environment-to-cloud-hub mapping in one place. Both consumers
3
+ * (the Capacitor shell's `server.url` and the CLI remote-web edge's
4
+ * `hubUrl`) read this helper, so this matrix is the single behavioral pin
5
+ * for the served URLs.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+
10
+ import { cloudAssistantHubUrl, SEEDS } from "../index.js";
11
+
12
+ describe("cloudAssistantHubUrl", () => {
13
+ test("cloud environments resolve to their own web origin", () => {
14
+ expect(cloudAssistantHubUrl("production")).toBe(
15
+ "https://www.vellum.ai/assistant",
16
+ );
17
+ expect(cloudAssistantHubUrl("staging")).toBe(
18
+ "https://staging-assistant.vellum.ai/assistant",
19
+ );
20
+ });
21
+
22
+ test("every other environment falls back to the dev SPA", () => {
23
+ for (const name of ["dev", "test", "local", "unknown-env", undefined]) {
24
+ expect(cloudAssistantHubUrl(name)).toBe(
25
+ "https://dev-assistant.vellum.ai/assistant",
26
+ );
27
+ }
28
+ });
29
+
30
+ test("cloud hub URLs derive from the seed web URLs", () => {
31
+ for (const name of ["production", "staging"]) {
32
+ expect(cloudAssistantHubUrl(name)).toBe(
33
+ `${SEEDS[name].webUrl}/assistant`,
34
+ );
35
+ }
36
+ expect(cloudAssistantHubUrl("local")).toBe(`${SEEDS.dev.webUrl}/assistant`);
37
+ });
38
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for reading an install layout: telling a repo checkout from an
3
+ * installed package, and locating the `assistant` command that ships with an
4
+ * install.
5
+ */
6
+
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { findAssistantCommand, isRepoCheckoutPath } from "../index.js";
13
+
14
+ let root: string;
15
+
16
+ beforeEach(() => {
17
+ root = mkdtempSync(join(tmpdir(), "install-layout-"));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(root, { recursive: true, force: true });
22
+ });
23
+
24
+ /** Create `<nodeModulesDir>/.bin/assistant` and return its path. */
25
+ function seedCommand(nodeModulesDir: string): string {
26
+ const binDir = join(nodeModulesDir, ".bin");
27
+ mkdirSync(binDir, { recursive: true });
28
+ const command = join(binDir, "assistant");
29
+ writeFileSync(command, "#!/usr/bin/env bun\n");
30
+ return command;
31
+ }
32
+
33
+ describe("isRepoCheckoutPath", () => {
34
+ test("an installed package is not a checkout", () => {
35
+ expect(
36
+ isRepoCheckoutPath(
37
+ "/Users/x/Library/Application Support/@vellumai/macos/cli/latest/node_modules/@vellumai/assistant/src/index.ts",
38
+ ),
39
+ ).toBe(false);
40
+ });
41
+
42
+ test("a repo checkout is", () => {
43
+ expect(
44
+ isRepoCheckoutPath("/Users/x/dev/vellum/assistant/src/index.ts"),
45
+ ).toBe(true);
46
+ });
47
+
48
+ test("a directory merely named node_modules-ish is not enough", () => {
49
+ expect(
50
+ isRepoCheckoutPath("/Users/x/node_modules_backup/a/src/index.ts"),
51
+ ).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("findAssistantCommand", () => {
56
+ test("finds the command shipped beside an installed package", () => {
57
+ const nodeModules = join(root, "cli", "latest", "node_modules");
58
+ const command = seedCommand(nodeModules);
59
+ const path = join(nodeModules, "@vellumai", "assistant", "src", "index.ts");
60
+
61
+ expect(findAssistantCommand(path)).toBe(command);
62
+ });
63
+
64
+ test("prefers a nested install over a hoisted one", () => {
65
+ const outer = join(root, "node_modules");
66
+ const outerCommand = seedCommand(outer);
67
+ const inner = join(outer, "vellum", "node_modules");
68
+ const innerCommand = seedCommand(inner);
69
+ const path = join(inner, "@vellumai", "assistant", "src", "index.ts");
70
+
71
+ expect(findAssistantCommand(path)).toBe(innerCommand);
72
+ expect(innerCommand).not.toBe(outerCommand);
73
+ });
74
+
75
+ test("falls back to a hoisted install when the nested one has no command", () => {
76
+ const outer = join(root, "node_modules");
77
+ const outerCommand = seedCommand(outer);
78
+ const inner = join(outer, "vellum", "node_modules");
79
+ mkdirSync(inner, { recursive: true });
80
+ const path = join(inner, "@vellumai", "assistant", "src", "index.ts");
81
+
82
+ expect(findAssistantCommand(path)).toBe(outerCommand);
83
+ });
84
+
85
+ test("returns null for a repo checkout", () => {
86
+ expect(
87
+ findAssistantCommand(join(root, "assistant", "src", "index.ts")),
88
+ ).toBeNull();
89
+ });
90
+
91
+ test("returns null when the install ships no assistant command", () => {
92
+ const nodeModules = join(root, "node_modules");
93
+ mkdirSync(join(nodeModules, ".bin"), { recursive: true });
94
+ const path = join(nodeModules, "@vellumai", "assistant", "src", "index.ts");
95
+
96
+ expect(findAssistantCommand(path)).toBeNull();
97
+ });
98
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Package boundary tests for @vellumai/environments.
3
+ *
4
+ * This package is the lowest layer of the shared-packages hierarchy:
5
+ * environment types, constants, and small helpers over node builtins, with no
6
+ * runtime dependencies. It must stay a leaf so any consumer (CLI, assistant,
7
+ * local-mode host) can depend on it without dragging in a dependency tree.
8
+ *
9
+ * Enforces that the package:
10
+ * 1. Imports only node builtins and its own relative modules — no `@vellumai/*`
11
+ * packages and no third-party runtime imports.
12
+ * 2. Declares no runtime `dependencies` (devDependencies only).
13
+ * 3. Is marked `private`.
14
+ */
15
+
16
+ import { describe, expect, test } from "bun:test";
17
+ import { readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+
20
+ const PACKAGE_ROOT = resolve(import.meta.dirname, "../..");
21
+ const SRC_DIR = join(PACKAGE_ROOT, "src");
22
+
23
+ function collectSourceFiles(dir: string): string[] {
24
+ const files: string[] = [];
25
+ for (const entry of readdirSync(dir)) {
26
+ const full = join(dir, entry);
27
+ const stat = statSync(full);
28
+ if (stat.isDirectory()) {
29
+ if (entry === "node_modules" || entry === "__tests__") continue;
30
+ files.push(...collectSourceFiles(full));
31
+ } else if (
32
+ entry.endsWith(".ts") &&
33
+ !entry.endsWith(".test.ts") &&
34
+ !entry.endsWith(".d.ts")
35
+ ) {
36
+ files.push(full);
37
+ }
38
+ }
39
+ return files;
40
+ }
41
+
42
+ /**
43
+ * Matches the module specifier of any `import ... from "<spec>"` /
44
+ * `export ... from "<spec>"` / `require("<spec>")` statement.
45
+ */
46
+ const IMPORT_SPEC = /(?:from|require\s*\(\s*)["']([^"']+)["']/g;
47
+
48
+ /** A bare specifier is anything that is not a relative or node-builtin import. */
49
+ function isForbiddenSpecifier(spec: string): boolean {
50
+ if (spec.startsWith(".") || spec.startsWith("/")) return false;
51
+ if (spec.startsWith("node:")) return false;
52
+ return true;
53
+ }
54
+
55
+ describe("package boundary", () => {
56
+ const sourceFiles = collectSourceFiles(SRC_DIR);
57
+
58
+ test("has source files to validate", () => {
59
+ expect(sourceFiles.length).toBeGreaterThan(0);
60
+ });
61
+
62
+ test("imports only node builtins and relative modules", () => {
63
+ const violations: string[] = [];
64
+
65
+ for (const file of sourceFiles) {
66
+ const lines = readFileSync(file, "utf-8").split("\n");
67
+ for (let i = 0; i < lines.length; i++) {
68
+ for (const match of lines[i]!.matchAll(IMPORT_SPEC)) {
69
+ const spec = match[1]!;
70
+ if (isForbiddenSpecifier(spec)) {
71
+ const relative = file.replace(PACKAGE_ROOT + "/", "");
72
+ violations.push(`${relative}:${i + 1}: ${spec}`);
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ if (violations.length > 0) {
79
+ throw new Error(
80
+ `Found ${violations.length} forbidden import(s) in @vellumai/environments:\n` +
81
+ violations.map((v) => ` - ${v}`).join("\n") +
82
+ "\n\n@vellumai/environments is a dependency-free leaf and must\n" +
83
+ "import only node builtins and its own relative modules.",
84
+ );
85
+ }
86
+ });
87
+
88
+ test("package.json declares it as private with no runtime dependencies", () => {
89
+ const pkg = JSON.parse(
90
+ readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"),
91
+ );
92
+ expect(pkg.private).toBe(true);
93
+ expect(pkg.dependencies ?? {}).toEqual({});
94
+ });
95
+ });
@@ -0,0 +1,89 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, test } from "bun:test";
5
+
6
+ import {
7
+ assertTestPathIsEphemeral,
8
+ canonicalizePathThroughExistingParent,
9
+ isBunTestProcess,
10
+ } from "../test-path-guard.js";
11
+
12
+ const OPTIONS = {
13
+ allowEnvVar: "TEST_PATH_GUARD_ALLOW",
14
+ runHint: "Run tests from the package root.",
15
+ };
16
+
17
+ describe("isBunTestProcess", () => {
18
+ test("detects this bun test run", () => {
19
+ expect(isBunTestProcess()).toBe(true);
20
+ });
21
+ });
22
+
23
+ describe("canonicalizePathThroughExistingParent", () => {
24
+ test("resolves symlinks on the deepest existing ancestor", () => {
25
+ const root = mkdtempSync(join(tmpdir(), "test-path-guard-"));
26
+ try {
27
+ const target = join(root, "target");
28
+ mkdirSync(target);
29
+ const link = join(root, "link");
30
+ symlinkSync(target, link, "dir");
31
+ expect(
32
+ canonicalizePathThroughExistingParent(join(link, "missing", "leaf")),
33
+ ).toBe(
34
+ join(canonicalizePathThroughExistingParent(target), "missing", "leaf"),
35
+ );
36
+ } finally {
37
+ rmSync(root, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test("returns the input path when no ancestor exists", () => {
42
+ expect(canonicalizePathThroughExistingParent("/nonexistent-root-x/y")).toBe(
43
+ "/nonexistent-root-x/y",
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("assertTestPathIsEphemeral", () => {
49
+ test("allows paths under tmpdir, existing or not", () => {
50
+ expect(() =>
51
+ assertTestPathIsEphemeral(join(tmpdir(), "guard-missing", "x"), OPTIONS),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ test("refuses paths outside tmpdir, naming the escape hatch and hint", () => {
56
+ delete process.env[OPTIONS.allowEnvVar];
57
+ const dir = join(homedir(), "test-path-guard-live");
58
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
59
+ /Refusing to use[\s\S]*package root[\s\S]*TEST_PATH_GUARD_ALLOW/,
60
+ );
61
+ });
62
+
63
+ test("refuses a tmpdir symlink that resolves outside tmpdir", () => {
64
+ delete process.env[OPTIONS.allowEnvVar];
65
+ const link = join(tmpdir(), `test-path-guard-escape-${process.pid}`);
66
+ rmSync(link, { force: true });
67
+ symlinkSync(homedir(), link);
68
+ try {
69
+ expect(() => assertTestPathIsEphemeral(link, OPTIONS)).toThrow(
70
+ /Refusing to use/,
71
+ );
72
+ } finally {
73
+ rmSync(link, { force: true });
74
+ }
75
+ });
76
+
77
+ test("the allow env var bypasses the guard, per call", () => {
78
+ const dir = join(homedir(), "test-path-guard-optout");
79
+ process.env[OPTIONS.allowEnvVar] = "1";
80
+ try {
81
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).not.toThrow();
82
+ } finally {
83
+ delete process.env[OPTIONS.allowEnvVar];
84
+ }
85
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
86
+ /Refusing to use/,
87
+ );
88
+ });
89
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @vellumai/environments: the single source of truth for how a Vellum install
3
+ * is laid out and which deployment environment it belongs to.
4
+ *
5
+ * Two things live here:
6
+ *
7
+ * - The known deployment environments (names, platform/web URLs, per-service
8
+ * port blocks). The Swift client mirrors the same set in
9
+ * `VellumEnvironment.swift`.
10
+ * - Install layout: whether a runtime is a repo checkout or an installed
11
+ * package, and where the `assistant` command it ships lives.
12
+ *
13
+ * Consumed by the CLI, the assistant daemon, and the local-mode host library
14
+ * so each answer is defined exactly once on the TS side.
15
+ *
16
+ * This is the lowest layer of the shared-packages hierarchy and stays a leaf:
17
+ * types, constants, and small helpers over node builtins, with no runtime
18
+ * dependencies of its own. See `__tests__/package-boundary.test.ts`.
19
+ */
20
+ export * from "./types.js";
21
+ export * from "./seeds.js";
22
+ export * from "./install-layout.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Reading the layout a Vellum runtime is installed in.
3
+ *
4
+ * The daemon is started either from a developer's repo checkout or from an
5
+ * installed npm package (`<install>/node_modules/@vellumai/assistant/src/
6
+ * index.ts`, which is what the desktop app runs). The CLI needs the layout to
7
+ * decide whether a launch is a dev run and what to put on the daemon's PATH;
8
+ * the daemon needs it to decide what to install as the `assistant` command.
9
+ * Both ask the same two questions, so they ask them here.
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { join, sep } from "node:path";
14
+
15
+ const NODE_MODULES_SEGMENT = `${sep}node_modules${sep}`;
16
+
17
+ /**
18
+ * Whether `path` sits in a developer's repo checkout rather than inside an
19
+ * installed package.
20
+ */
21
+ export function isRepoCheckoutPath(path: string): boolean {
22
+ return !path.includes(NODE_MODULES_SEGMENT);
23
+ }
24
+
25
+ /**
26
+ * The `assistant` command shipped with the install that contains `path`, or
27
+ * null when there is none (a repo checkout, or an install whose dependency
28
+ * graph omits the package declaring the bin).
29
+ *
30
+ * Searches innermost `node_modules` outward, since a nested install shadows a
31
+ * hoisted one.
32
+ */
33
+ export function findAssistantCommand(path: string): string | null {
34
+ let index = path.lastIndexOf(NODE_MODULES_SEGMENT);
35
+
36
+ while (index !== -1) {
37
+ const nodeModulesDir = path.slice(
38
+ 0,
39
+ index + NODE_MODULES_SEGMENT.length - 1,
40
+ );
41
+ const command = join(nodeModulesDir, ".bin", "assistant");
42
+ if (existsSync(command)) {
43
+ return command;
44
+ }
45
+ index = path.lastIndexOf(NODE_MODULES_SEGMENT, index - 1);
46
+ }
47
+
48
+ return null;
49
+ }