@vellumai/credential-executor 0.10.4 → 0.10.5-dev.202607022248.91fa053

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.
@@ -5,10 +5,8 @@ import {
5
5
  normalizePublicBaseUrl,
6
6
  } from "../ingress.js";
7
7
  import {
8
- buildTwilioConnectActionUrl,
9
8
  buildTwilioMediaStreamUrl,
10
9
  buildTwilioPhoneNumberWebhookUrls,
11
- buildTwilioRelayUrl,
12
10
  buildTwilioVoiceWebhookUrl,
13
11
  resolveTwilioPublicBaseUrl,
14
12
  } from "../twilio-ingress.js";
@@ -90,12 +88,6 @@ describe("Twilio ingress helpers", () => {
90
88
  expect(buildTwilioVoiceWebhookUrl("https://example.test", "call-123")).toBe(
91
89
  "https://example.test/webhooks/twilio/voice?callSessionId=call-123",
92
90
  );
93
- expect(buildTwilioConnectActionUrl("https://example.test")).toBe(
94
- "https://example.test/webhooks/twilio/connect-action",
95
- );
96
- expect(buildTwilioRelayUrl("https://example.test")).toBe(
97
- "wss://example.test/webhooks/twilio/relay",
98
- );
99
91
  expect(buildTwilioMediaStreamUrl("http://example.test")).toBe(
100
92
  "ws://example.test/webhooks/twilio/media-stream",
101
93
  );
@@ -5,8 +5,7 @@
5
5
  * 1. Does NOT import from assistant, gateway, credential-executor, or other
6
6
  * service runtime modules.
7
7
  * 2. Does NOT import from runtime shared packages that sit above it in the
8
- * dependency hierarchy (@vellumai/credential-storage, @vellumai/egress-proxy,
9
- * @vellumai/skill-host-contracts).
8
+ * dependency hierarchy (@vellumai/credential-storage, @vellumai/egress-proxy).
10
9
  * 3. Does NOT import from x-client packages (@vellumai/assistant-client,
11
10
  * @vellumai/ces-client, @vellumai/gateway-client).
12
11
  * 4. Remains a pure schema/type package — no runtime dependencies beyond zod.
@@ -73,8 +72,6 @@ const FORBIDDEN_IMPORT_PATTERNS = [
73
72
  /require\s*\(\s*["']@vellumai\/credential-storage(?:\/|["'])/,
74
73
  /from\s+["']@vellumai\/egress-proxy(?:\/|["'])/,
75
74
  /require\s*\(\s*["']@vellumai\/egress-proxy(?:\/|["'])/,
76
- /from\s+["']@vellumai\/skill-host-contracts(?:\/|["'])/,
77
- /require\s*\(\s*["']@vellumai\/skill-host-contracts(?:\/|["'])/,
78
75
 
79
76
  // x-client packages (higher layer)
80
77
  /from\s+["']@vellumai\/assistant-client(?:\/|["'])/,
@@ -143,7 +140,6 @@ describe("package boundary", () => {
143
140
  "@vellumai/assistant",
144
141
  "@vellumai/credential-storage",
145
142
  "@vellumai/egress-proxy",
146
- "@vellumai/skill-host-contracts",
147
143
  "@vellumai/assistant-client",
148
144
  "@vellumai/ces-client",
149
145
  "@vellumai/gateway-client",
@@ -2,9 +2,6 @@ import { normalizePublicBaseUrl } from "./ingress.js";
2
2
 
3
3
  export const TWILIO_VOICE_WEBHOOK_PATH = "/webhooks/twilio/voice";
4
4
  export const TWILIO_STATUS_WEBHOOK_PATH = "/webhooks/twilio/status";
5
- export const TWILIO_CONNECT_ACTION_WEBHOOK_PATH =
6
- "/webhooks/twilio/connect-action";
7
- export const TWILIO_RELAY_WEBHOOK_PATH = "/webhooks/twilio/relay";
8
5
  export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
9
6
 
10
7
  /**
@@ -13,9 +10,9 @@ export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
13
10
  * with the actual public URL (from Velay registration, config, or the
14
11
  * `X-Vellum-Ingress-URL` header) before returning TwiML to Twilio.
15
12
  *
16
- * The placeholder uses `https://` so that `buildTwilioRelayUrl` /
17
- * `buildTwilioMediaStreamUrl` can apply the standard `http→ws` scheme
18
- * conversion, producing `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
13
+ * The placeholder uses `https://` so that `buildTwilioMediaStreamUrl` can
14
+ * apply the standard `http→ws` scheme conversion, producing
15
+ * `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
19
16
  */
20
17
  export const TWILIO_PUBLIC_BASE_URL_PLACEHOLDER =
21
18
  "https://__VELLUM_PUBLIC_BASE_URL__";
@@ -58,14 +55,6 @@ export function buildTwilioStatusWebhookUrl(baseUrl: string): string {
58
55
  return `${baseUrl}${TWILIO_STATUS_WEBHOOK_PATH}`;
59
56
  }
60
57
 
61
- export function buildTwilioConnectActionUrl(baseUrl: string): string {
62
- return `${baseUrl}${TWILIO_CONNECT_ACTION_WEBHOOK_PATH}`;
63
- }
64
-
65
- export function buildTwilioRelayUrl(baseUrl: string): string {
66
- return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_RELAY_WEBHOOK_PATH}`;
67
- }
68
-
69
58
  export function buildTwilioMediaStreamUrl(baseUrl: string): string {
70
59
  return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_MEDIA_STREAM_WEBHOOK_PATH}`;
71
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.10.4",
3
+ "version": "0.10.5-dev.202607022248.91fa053",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -419,6 +419,110 @@ describe("TemporaryGrantStore", () => {
419
419
  expect(store.check("allow_once", "hash-1")).toBe(false);
420
420
  expect(store.check("allow_once", "hash-2")).toBe(false);
421
421
  });
422
+
423
+ test("remains usable immediately (default TTL is not instantaneous)", () => {
424
+ // The default `allow_once` TTL must still allow a prompt retry of the
425
+ // just-approved operation.
426
+ store.add("allow_once", "hash-default-ttl");
427
+ expect(store.check("allow_once", "hash-default-ttl")).toBe(true);
428
+ });
429
+
430
+ test("expires after its TTL even if never consumed (ATL-935)", () => {
431
+ // An unconsumed single-use approval must not live forever.
432
+ store.add("allow_once", "hash-once-expire", { durationMs: 1 });
433
+
434
+ const start = Date.now();
435
+ while (Date.now() - start < 5) {
436
+ // spin
437
+ }
438
+
439
+ expect(store.check("allow_once", "hash-once-expire")).toBe(false);
440
+ });
441
+ });
442
+
443
+ // -------------------------------------------------------------------------
444
+ // ATL-935: ephemeral approvals are bounded by TTLs, not by connection
445
+ // teardown.
446
+ //
447
+ // In managed mode the store instance — contents included — is deliberately
448
+ // shared across assistant reconnects and, in the multi-process daemon model,
449
+ // across the connections that each talk to CES, so one guardian approval can
450
+ // be used by any connection entitled to it. The security boundary is the
451
+ // per-grant TTL: an approval that is recorded but never consumed must expire
452
+ // on its own rather than survive indefinitely and be replayed by a much later
453
+ // connection. These tests encode both halves of that contract — an approval
454
+ // stays usable across a reconnect (sharing preserved) but does not outlive
455
+ // its TTL (replay window bounded).
456
+ // -------------------------------------------------------------------------
457
+ describe("ATL-935: ephemeral approvals are bounded by TTLs, not teardown", () => {
458
+ test("every grant kind is bounded by a TTL (no unbounded approvals)", () => {
459
+ // The finding was that allow_once and allow_conversation had no time
460
+ // bound at all. Every kind must now expire on its own.
461
+ store.add("allow_once", "exp-once", { durationMs: 1 });
462
+ store.add("allow_10m", "exp-10m", { durationMs: 1 });
463
+ store.add("allow_conversation", "exp-conv", {
464
+ conversationId: "c",
465
+ durationMs: 1,
466
+ });
467
+
468
+ const start = Date.now();
469
+ while (Date.now() - start < 5) {
470
+ // spin until all three TTLs lapse
471
+ }
472
+
473
+ expect(store.check("allow_once", "exp-once")).toBe(false);
474
+ expect(store.check("allow_10m", "exp-10m")).toBe(false);
475
+ expect(store.check("allow_conversation", "exp-conv", "c")).toBe(false);
476
+ });
477
+
478
+ test("allow_once that is never consumed expires instead of lingering", () => {
479
+ // The headline scenario: an approval recorded but not consumed before a
480
+ // connection drops must not be replayable by a later connection.
481
+ store.add("allow_once", "hash-stale", { durationMs: 1 });
482
+
483
+ const start = Date.now();
484
+ while (Date.now() - start < 5) {
485
+ // spin
486
+ }
487
+
488
+ expect(store.checkAny("hash-stale")).toBeUndefined();
489
+ });
490
+
491
+ test("allow_conversation is bounded by an absolute TTL backstop", () => {
492
+ store.add("allow_conversation", "hash-conv-ttl", {
493
+ conversationId: "conv-1",
494
+ durationMs: 1,
495
+ });
496
+
497
+ const start = Date.now();
498
+ while (Date.now() - start < 5) {
499
+ // spin
500
+ }
501
+
502
+ expect(store.checkAny("hash-conv-ttl", "conv-1")).toBeUndefined();
503
+ });
504
+
505
+ test("an unexpired approval is still shared across a reconnect (no teardown)", () => {
506
+ // The multi-process daemon model depends on this: the store is NOT
507
+ // cleared on disconnect, so an approval granted while one connection was
508
+ // live remains usable by a later or sibling connection within its TTL.
509
+ store.add("allow_conversation", "hash-shared", {
510
+ conversationId: "conv-1",
511
+ });
512
+
513
+ // Simulate a reconnect — no clear() happens between connections.
514
+ expect(store.checkAny("hash-shared", "conv-1")).toBe(
515
+ "allow_conversation",
516
+ );
517
+ // Still usable again (conversation grants are not consumed on use).
518
+ expect(store.checkAny("hash-shared", "conv-1")).toBe(
519
+ "allow_conversation",
520
+ );
521
+
522
+ // An allow_10m approval likewise survives a reconnect within its window.
523
+ store.add("allow_10m", "hash-shared-10m");
524
+ expect(store.checkAny("hash-shared-10m")).toBe("allow_10m");
525
+ });
422
526
  });
423
527
 
424
528
  describe("allow_10m", () => {
@@ -6,11 +6,29 @@
6
6
  * which is the desired behaviour for ephemeral approvals.
7
7
  *
8
8
  * Keying:
9
- * - `allow_once`: Keyed by proposal hash. Consumed (deleted) on first use.
9
+ * - `allow_once`: Keyed by proposal hash. Consumed (deleted) on first use, and
10
+ * bounded by a short default TTL so an approval that is never consumed cannot
11
+ * linger and be replayed long after the guardian approved the imminent
12
+ * operation (see ATL-935).
10
13
  * - `allow_10m`: Keyed by proposal hash. Checked for expiry on every read;
11
14
  * expired entries are lazily purged.
12
15
  * - `allow_conversation`: Keyed by proposal hash + conversation ID. Scoped to a
13
- * single conversation.
16
+ * single conversation and bounded by a generous absolute TTL backstop so an
17
+ * approval cannot live for the store's entire (process-long) lifetime and be
18
+ * replayed by a later connection that presents the same conversation ID
19
+ * (see ATL-935).
20
+ *
21
+ * Lifetime note: in managed mode this store instance is process-scoped and
22
+ * deliberately shared across assistant reconnects — and, in the forthcoming
23
+ * multi-process daemon model, across the multiple connections that each talk to
24
+ * CES — so a single guardian approval can be used by any connection entitled to
25
+ * it. Grant lifetime is therefore bounded by per-grant TTLs rather than by
26
+ * connection teardown: every grant kind carries an expiry, so an unconsumed
27
+ * approval expires on its own instead of surviving indefinitely. (A future
28
+ * multi-connection daemon may additionally evict on quiescence — when the count
29
+ * of live CES connections reaches zero — to scope grants to assistant presence;
30
+ * that is connection-lifecycle machinery the multi-connection work should own,
31
+ * and is intentionally not built here.)
14
32
  */
15
33
 
16
34
  // ---------------------------------------------------------------------------
@@ -28,13 +46,41 @@ export interface TemporaryGrant {
28
46
  conversationId?: string;
29
47
  /** When the grant was created (epoch ms). */
30
48
  createdAt: number;
31
- /** When the grant expires (epoch ms). Set for `allow_10m`; optionally set for `allow_once`. */
49
+ /** When the grant expires (epoch ms). Set for every grant kind: a short
50
+ * default for `allow_once`, the timed window for `allow_10m`, and a generous
51
+ * absolute backstop for `allow_conversation`. */
32
52
  expiresAt?: number;
33
53
  }
34
54
 
35
55
  /** Default TTL for timed grants (10 minutes). */
36
56
  const DEFAULT_TIMED_DURATION_MS = 10 * 60 * 1000;
37
57
 
58
+ /**
59
+ * Default TTL for single-use (`allow_once`) grants (2 minutes).
60
+ *
61
+ * `allow_once` exists to bridge the gap between a guardian approval and the
62
+ * caller immediately retrying the just-approved operation. Without a TTL, an
63
+ * approval that is never consumed (e.g. the assistant connection drops before
64
+ * the retry) would live for the store's entire lifetime and could later be
65
+ * replayed without a fresh prompt (ATL-935). A short bound keeps the grant
66
+ * usable for a prompt retry while ensuring a stale, unconsumed approval
67
+ * expires on its own.
68
+ */
69
+ const DEFAULT_ONCE_DURATION_MS = 2 * 60 * 1000;
70
+
71
+ /**
72
+ * Absolute TTL backstop for `allow_conversation` grants (12 hours).
73
+ *
74
+ * `allow_conversation` is scoped to a conversation ID and is meant to persist
75
+ * for the life of that conversation, so it is not consumed on use and has no
76
+ * short timeout. But without any bound it would live for the store's entire
77
+ * process-long lifetime and could be replayed by a later connection that
78
+ * presents the same conversation ID long after the original approval (ATL-935).
79
+ * A generous backstop keeps the grant usable across a normal working session
80
+ * while ensuring a long-stale approval eventually requires a fresh prompt.
81
+ */
82
+ const DEFAULT_CONVERSATION_DURATION_MS = 12 * 60 * 60 * 1000;
83
+
38
84
  // ---------------------------------------------------------------------------
39
85
  // Store implementation
40
86
  // ---------------------------------------------------------------------------
@@ -102,8 +148,19 @@ export class TemporaryGrantStore {
102
148
  if (kind === "allow_10m") {
103
149
  grant.expiresAt =
104
150
  Date.now() + (options?.durationMs ?? DEFAULT_TIMED_DURATION_MS);
105
- } else if (kind === "allow_once" && options?.durationMs !== undefined) {
106
- grant.expiresAt = Date.now() + options.durationMs;
151
+ } else if (kind === "allow_once") {
152
+ // `allow_once` is always bounded by a TTL — a caller-supplied duration
153
+ // when present, otherwise a short default — so an unconsumed single-use
154
+ // approval cannot be replayed indefinitely (ATL-935).
155
+ grant.expiresAt =
156
+ Date.now() + (options?.durationMs ?? DEFAULT_ONCE_DURATION_MS);
157
+ } else if (kind === "allow_conversation") {
158
+ // `allow_conversation` persists for the conversation and is not consumed
159
+ // on use, but it still carries a generous absolute TTL backstop so a
160
+ // conversation-scoped approval cannot linger for the store's entire
161
+ // process lifetime and be replayed by a later connection (ATL-935).
162
+ grant.expiresAt =
163
+ Date.now() + (options?.durationMs ?? DEFAULT_CONVERSATION_DURATION_MS);
107
164
  }
108
165
 
109
166
  this.store.set(key, grant);
@@ -115,8 +172,9 @@ export class TemporaryGrantStore {
115
172
  * - `allow_once`: Returns `true` and **consumes** the grant (deletes it).
116
173
  * - `allow_10m`: Returns `true` only if the grant has not expired.
117
174
  * Expired grants are lazily purged.
118
- * - `allow_conversation`: Returns `true` only if a grant exists for the given
119
- * proposal hash scoped to the specified conversation ID.
175
+ * - `allow_conversation`: Returns `true` only if a non-expired grant exists
176
+ * for the given proposal hash scoped to the specified conversation ID.
177
+ * Expired grants (past the absolute TTL backstop) are lazily purged.
120
178
  *
121
179
  * Returns `false` if no matching grant exists.
122
180
  */
@@ -149,7 +207,12 @@ export class TemporaryGrantStore {
149
207
  return true;
150
208
  }
151
209
 
152
- // allow_conversation — no expiry, just existence check
210
+ // allow_conversation — bounded by an absolute TTL backstop; lazily purge an
211
+ // expired grant and deny, mirroring allow_10m.
212
+ if (grant.expiresAt !== undefined && Date.now() >= grant.expiresAt) {
213
+ this.store.delete(key);
214
+ return false;
215
+ }
153
216
  return true;
154
217
  }
155
218
 
@@ -668,12 +668,23 @@ async function main(): Promise<void> {
668
668
  // `unregister` miss a tool registered in an earlier session and orphan its
669
669
  // bundle.
670
670
  //
671
- // The in-memory temporary-grant store is also process-scoped, and (unlike
672
- // before) is NOT cleared between sessions: ephemeral approvals are shared
673
- // across all of a daemon's connections for the process lifetime. Its own
674
- // semantics keep this safe `allow_once` is consumed on first use,
675
- // `allow_10m` expires by wall-clock TTL, `allow_conversation` is cleared when
676
- // its conversation ends (see the serve loop below).
671
+ // The in-memory temporary-grant store instance is also process-scoped and is
672
+ // deliberately reused contents included across reconnects. Ephemeral
673
+ // approvals (`allow_once` / `allow_10m` / `allow_conversation`) are keyed by
674
+ // proposal hash (plus a caller-supplied conversation ID), not by the
675
+ // connection that produced them, precisely so a single guardian approval can
676
+ // be shared by any connection entitled to use it. That sharing is what the
677
+ // multi-process daemon model needs: several assistant processes will each
678
+ // talk to CES, and an approval granted while one is connected must remain
679
+ // usable by the others. Grant lifetime is therefore bounded by per-grant TTLs
680
+ // (every kind now carries an expiry), not by tearing the store down on
681
+ // disconnect — so an approval that is never consumed expires on its own
682
+ // instead of surviving indefinitely and being replayed by a much later
683
+ // connection without a fresh guardian prompt (ATL-935). A future
684
+ // multi-connection daemon may additionally evict on quiescence (when the
685
+ // count of live CES connections reaches zero) to scope grants to assistant
686
+ // presence; that is connection-lifecycle machinery the multi-connection work
687
+ // should own, and is intentionally not added here.
677
688
  //
678
689
  // The mutable refs carry the handshake-provided API key and assistant ID;
679
690
  // handlers read them at call time. These don't vary across a daemon's
@@ -682,11 +693,7 @@ async function main(): Promise<void> {
682
693
  // at call time for audit attribution).
683
694
  const apiKeyRef: ApiKeyRef = { current: "" };
684
695
  const assistantIdRef: AssistantIdRef = { current: "" };
685
- const handlers = buildHandlers(
686
- apiKeyRef,
687
- assistantIdRef,
688
- secureKeyBackend,
689
- );
696
+ const handlers = buildHandlers(apiKeyRef, assistantIdRef, secureKeyBackend);
690
697
 
691
698
  // Serve loop. CES is a long-lived sidecar that must outlive any single
692
699
  // assistant session: the assistant container can crash and be restarted
@@ -782,16 +789,6 @@ async function main(): Promise<void> {
782
789
 
783
790
  rpcConnected = false;
784
791
 
785
- // Temporary grants are process-shared: they are NOT cleared when a session
786
- // ends. CES is moving to a model where the assistant daemon's multiple
787
- // processes each hold their own connection, so an ephemeral approval
788
- // (`allow_once` / `allow_10m` / `allow_conversation`) granted on one
789
- // connection must remain usable by the others rather than being scoped to a
790
- // single session. The store's own semantics keep this safe across a
791
- // reconnect: `allow_once` is consumed on first use, `allow_10m` is bounded
792
- // by its wall-clock TTL, and `allow_conversation` is cleared when its
793
- // conversation ends.
794
- //
795
792
  // A signal-driven end means the process is shutting down; exit the loop.
796
793
  // Any other end reason (the assistant disconnected, its stream closed,
797
794
  // or the transport errored) means we keep the sidecar up and await a