@vellumai/assistant 0.11.2-dev.202608051637.aae860e → 0.11.2-dev.202608051734.355dff7
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/openapi.yaml +37 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-event-hub.test.ts +48 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +99 -0
- package/src/__tests__/conversation-queue.test.ts +56 -0
- package/src/__tests__/events-dev-bypass-actor.test.ts +183 -1
- package/src/__tests__/helpers/hub-clients.ts +42 -0
- package/src/__tests__/host-bash-routes.test.ts +67 -0
- package/src/__tests__/http-user-message-parity.test.ts +44 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/contacts/guardian-delivery-reader.ts +2 -2
- package/src/daemon/conversation-messaging.ts +37 -0
- package/src/daemon/conversation-process.ts +6 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +169 -0
- package/src/notifications/assistant-reply-producer.ts +35 -4
- package/src/persistence/conversation-crud.ts +14 -0
- package/src/persistence/conversation-types.ts +29 -1
- package/src/runtime/__tests__/desktop-presence.test.ts +162 -0
- package/src/runtime/assistant-event-hub.ts +87 -12
- package/src/runtime/auth/same-actor.ts +18 -1
- package/src/runtime/desktop-presence.ts +69 -0
- package/src/runtime/local-actor-identity.ts +11 -9
- package/src/runtime/routes/__tests__/client-routes.test.ts +186 -14
- package/src/runtime/routes/client-routes.ts +114 -2
- package/src/runtime/routes/conversation-routes.ts +5 -0
- package/src/runtime/routes/events-routes.ts +28 -1
- package/src/runtime/routes/host-app-control-routes.ts +2 -0
- package/src/runtime/routes/host-bash-routes.ts +2 -0
- package/src/runtime/routes/host-browser-routes.ts +2 -0
- package/src/runtime/routes/host-cu-routes.ts +2 -0
- package/src/runtime/routes/host-file-routes.ts +2 -0
- package/src/runtime/routes/host-transfer-routes.ts +4 -0
- package/src/runtime/routes/ui-snapshot-routes.ts +1 -0
package/openapi.yaml
CHANGED
|
@@ -4615,6 +4615,43 @@ paths:
|
|
|
4615
4615
|
required:
|
|
4616
4616
|
- disconnected
|
|
4617
4617
|
additionalProperties: false
|
|
4618
|
+
/v1/clients/presence:
|
|
4619
|
+
post:
|
|
4620
|
+
operationId: clients_presence_post
|
|
4621
|
+
summary: Report desktop presence
|
|
4622
|
+
description: Record the desktop presence state reported by the client identified by the X-Vellum-Client-Id header.
|
|
4623
|
+
tags:
|
|
4624
|
+
- clients
|
|
4625
|
+
requestBody:
|
|
4626
|
+
required: true
|
|
4627
|
+
content:
|
|
4628
|
+
application/json:
|
|
4629
|
+
schema:
|
|
4630
|
+
type: object
|
|
4631
|
+
properties:
|
|
4632
|
+
state:
|
|
4633
|
+
type: string
|
|
4634
|
+
enum:
|
|
4635
|
+
- active
|
|
4636
|
+
- idle
|
|
4637
|
+
- away
|
|
4638
|
+
description: Reported desktop presence state.
|
|
4639
|
+
required:
|
|
4640
|
+
- state
|
|
4641
|
+
responses:
|
|
4642
|
+
"200":
|
|
4643
|
+
description: Successful response
|
|
4644
|
+
content:
|
|
4645
|
+
application/json:
|
|
4646
|
+
schema:
|
|
4647
|
+
type: object
|
|
4648
|
+
properties:
|
|
4649
|
+
recorded:
|
|
4650
|
+
type: boolean
|
|
4651
|
+
description: Whether a connected client matched the reporting clientId.
|
|
4652
|
+
required:
|
|
4653
|
+
- recorded
|
|
4654
|
+
additionalProperties: false
|
|
4618
4655
|
/v1/config:
|
|
4619
4656
|
get:
|
|
4620
4657
|
operationId: config_get
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getReplayWindow,
|
|
12
12
|
} from "../runtime/assistant-stream-state.js";
|
|
13
13
|
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
14
|
+
import { registerHubClient } from "./helpers/hub-clients.js";
|
|
14
15
|
|
|
15
16
|
function makeEvent(
|
|
16
17
|
overrides: Partial<AssistantEventEnvelope> = {},
|
|
@@ -435,6 +436,53 @@ describe("AssistantEventHub — actorPrincipalId on ClientEntry", () => {
|
|
|
435
436
|
});
|
|
436
437
|
});
|
|
437
438
|
|
|
439
|
+
// ── ClientEntry desktop presence ─────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
describe("AssistantEventHub client presence", () => {
|
|
442
|
+
test("records presence on a matching client", () => {
|
|
443
|
+
const hub = new AssistantEventHub();
|
|
444
|
+
registerHubClient({ hub, clientId: "mac-1" });
|
|
445
|
+
|
|
446
|
+
expect(hub.setClientPresence("mac-1", "active")).toBe(true);
|
|
447
|
+
|
|
448
|
+
const presence = hub.getClientById("mac-1")!.presence!;
|
|
449
|
+
expect(presence.state).toBe("active");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("reportedAt advances on every report", async () => {
|
|
453
|
+
const hub = new AssistantEventHub();
|
|
454
|
+
registerHubClient({ hub, clientId: "mac-1" });
|
|
455
|
+
|
|
456
|
+
hub.setClientPresence("mac-1", "idle");
|
|
457
|
+
const first = hub.getClientById("mac-1")!.presence!;
|
|
458
|
+
|
|
459
|
+
await Bun.sleep(5);
|
|
460
|
+
hub.setClientPresence("mac-1", "idle");
|
|
461
|
+
const second = hub.getClientById("mac-1")!.presence!;
|
|
462
|
+
|
|
463
|
+
expect(second.reportedAt.getTime()).toBeGreaterThan(
|
|
464
|
+
first.reportedAt.getTime(),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("returns false for an unknown clientId", () => {
|
|
469
|
+
const hub = new AssistantEventHub();
|
|
470
|
+
registerHubClient({ hub, clientId: "mac-1" });
|
|
471
|
+
|
|
472
|
+
expect(hub.setClientPresence("does-not-exist", "active")).toBe(false);
|
|
473
|
+
expect(hub.getClientById("mac-1")?.presence).toBeUndefined();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("a disposed client no longer accepts presence", () => {
|
|
477
|
+
const hub = new AssistantEventHub();
|
|
478
|
+
registerHubClient({ hub, clientId: "mac-1" });
|
|
479
|
+
|
|
480
|
+
expect(hub.disposeClient("mac-1")).toBe(1);
|
|
481
|
+
expect(hub.setClientPresence("mac-1", "active")).toBe(false);
|
|
482
|
+
expect(hub.getClientById("mac-1")).toBeUndefined();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
438
486
|
// ── capabilityForMessageType — host-prefix routing ───────────────────────────
|
|
439
487
|
|
|
440
488
|
describe("capabilityForMessageType — host-prefix routing", () => {
|
|
@@ -65,6 +65,7 @@ import type {
|
|
|
65
65
|
import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
|
|
66
66
|
import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
|
|
67
67
|
import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
|
|
68
|
+
import { isMacOriginatedUserMessage } from "../persistence/conversation-types.js";
|
|
68
69
|
|
|
69
70
|
function createWebTurnContext(
|
|
70
71
|
clientOs: string | undefined,
|
|
@@ -173,3 +174,101 @@ describe("client OS surface metadata persistence", () => {
|
|
|
173
174
|
});
|
|
174
175
|
});
|
|
175
176
|
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `Conversation.clientOs` is a live field that only a transport-carrying
|
|
180
|
+
* message refreshes, so a transport-less turn stamps whatever an earlier send
|
|
181
|
+
* left there. `clientOsFromRequest` separates the two, and
|
|
182
|
+
* {@link isMacOriginatedUserMessage} (the row-origin read behind the
|
|
183
|
+
* `chat.assistant_reply` presence gate) requires it before letting an attended
|
|
184
|
+
* Mac suppress a push.
|
|
185
|
+
*/
|
|
186
|
+
describe("client OS per-row evidence marker", () => {
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
addMessageCalls.length = 0;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("marks an OS this row's own transport reported", async () => {
|
|
192
|
+
const ctx = createWebTurnContext("macos");
|
|
193
|
+
await persistQueuedMessageBody(ctx, {
|
|
194
|
+
content: "hello",
|
|
195
|
+
requestId: "req-transport-os",
|
|
196
|
+
requestClientOs: "macos",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
200
|
+
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
201
|
+
expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("marks an OS this row's own request headers reported", async () => {
|
|
205
|
+
const ctx = createWebTurnContext(undefined);
|
|
206
|
+
await persistQueuedMessageBody(ctx, {
|
|
207
|
+
content: "hello",
|
|
208
|
+
requestId: "req-header-os",
|
|
209
|
+
metadata: { client: { os: "macos", interface_version: "0.8.2" } },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
213
|
+
expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// The bug shape: a button tapped on the phone reaches a conversation whose
|
|
217
|
+
// last desktop send left `clientOs: "macos"` behind. The row still carries
|
|
218
|
+
// the inherited OS for telemetry, but nothing says this turn came from the
|
|
219
|
+
// Mac, so the reply push must survive.
|
|
220
|
+
test("leaves an inherited OS unmarked and not Mac-originated", async () => {
|
|
221
|
+
const ctx = createWebTurnContext("macos");
|
|
222
|
+
await persistQueuedMessageBody(ctx, {
|
|
223
|
+
content: "[User action on card surface: submit]",
|
|
224
|
+
requestId: "req-inherited-os",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
228
|
+
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
229
|
+
expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// The marker vouches for the `client.os` that actually landed, not for
|
|
233
|
+
// whatever the row reported: a reported OS the stamp disagrees with is no
|
|
234
|
+
// evidence at all.
|
|
235
|
+
test("leaves the marker off when the reported OS is not the stamped one", async () => {
|
|
236
|
+
const ctx = createWebTurnContext("macos");
|
|
237
|
+
await persistQueuedMessageBody(ctx, {
|
|
238
|
+
content: "hello",
|
|
239
|
+
requestId: "req-mismatched-os",
|
|
240
|
+
requestClientOs: "ios",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
244
|
+
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
245
|
+
expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// The marker is derived from what this persist can see, so a bag value
|
|
249
|
+
// riding in from a caller must not survive the metadata spread and assert an
|
|
250
|
+
// origin the row never reported.
|
|
251
|
+
test("drops a caller-supplied marker from the metadata bag", async () => {
|
|
252
|
+
const ctx = createWebTurnContext("macos");
|
|
253
|
+
await persistQueuedMessageBody(ctx, {
|
|
254
|
+
content: "[User action on card surface: submit]",
|
|
255
|
+
requestId: "req-spoofed-marker",
|
|
256
|
+
metadata: { clientOsFromRequest: true },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
260
|
+
expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("leaves the marker off for a reported OS outside the vocabulary", async () => {
|
|
264
|
+
const ctx = createWebTurnContext(undefined);
|
|
265
|
+
await persistQueuedMessageBody(ctx, {
|
|
266
|
+
content: "hello",
|
|
267
|
+
requestId: "req-unknown-os",
|
|
268
|
+
requestClientOs: "windows",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(lastUserMetadata().client).toBeUndefined();
|
|
272
|
+
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -676,6 +676,62 @@ describe("Conversation message queue", () => {
|
|
|
676
676
|
expect(pendingRuns.length).toBe(3);
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
// `Conversation.clientOs` is a live field that only a transport-carrying
|
|
680
|
+
// message refreshes, so a transport-less drain (a surface action, a signal)
|
|
681
|
+
// persists the OS of an earlier send. Both rows keep that OS for telemetry;
|
|
682
|
+
// only the row that reported it claims the surface, which is what stops an
|
|
683
|
+
// attended Mac from suppressing the push for a button tapped on the phone.
|
|
684
|
+
test("[experimental] only a queued send that reported its own OS claims that surface", async () => {
|
|
685
|
+
capturedAddMessages.length = 0;
|
|
686
|
+
const conversation = makeConversation();
|
|
687
|
+
await conversation.loadFromDb();
|
|
688
|
+
|
|
689
|
+
const p1 = conversation.processMessage({
|
|
690
|
+
content: "msg-1",
|
|
691
|
+
attachments: [],
|
|
692
|
+
onEvent: () => {},
|
|
693
|
+
requestId: "req-1",
|
|
694
|
+
});
|
|
695
|
+
await waitForPendingRun(1);
|
|
696
|
+
|
|
697
|
+
conversation.enqueueMessage({
|
|
698
|
+
content: "from-the-mac",
|
|
699
|
+
onEvent: () => {},
|
|
700
|
+
requestId: "req-mac",
|
|
701
|
+
transport: { channelId: "vellum", interfaceId: "web", clientOs: "macos" },
|
|
702
|
+
});
|
|
703
|
+
// A surface action: no transport, so the drain leaves the conversation's
|
|
704
|
+
// `clientOs` on the macOS value the send above applied.
|
|
705
|
+
conversation.enqueueMessage({
|
|
706
|
+
content: "[User action on card surface: submit]",
|
|
707
|
+
onEvent: () => {},
|
|
708
|
+
requestId: "req-surface-action",
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
await resolveRun(0);
|
|
712
|
+
await p1;
|
|
713
|
+
await waitForPendingRun(2);
|
|
714
|
+
await resolveRun(1);
|
|
715
|
+
await waitForPendingRun(3);
|
|
716
|
+
|
|
717
|
+
const macRow = capturedAddMessages.find(
|
|
718
|
+
(m) => m.role === "user" && m.content.includes("from-the-mac"),
|
|
719
|
+
);
|
|
720
|
+
expect(macRow).toBeDefined();
|
|
721
|
+
expect(macRow!.metadata?.client).toEqual({ os: "macos" });
|
|
722
|
+
expect(macRow!.metadata?.clientOsFromRequest).toBe(true);
|
|
723
|
+
|
|
724
|
+
const surfaceActionRow = capturedAddMessages.find(
|
|
725
|
+
(m) => m.role === "user" && m.content.includes("User action on card"),
|
|
726
|
+
);
|
|
727
|
+
expect(surfaceActionRow).toBeDefined();
|
|
728
|
+
expect(surfaceActionRow!.metadata?.client).toEqual({ os: "macos" });
|
|
729
|
+
expect(surfaceActionRow!.metadata?.clientOsFromRequest).toBeUndefined();
|
|
730
|
+
|
|
731
|
+
await resolveRun(2);
|
|
732
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
733
|
+
});
|
|
734
|
+
|
|
679
735
|
test("[experimental] queued passthrough siblings viewing different apps do NOT batch", async () => {
|
|
680
736
|
// A batched turn applies only the head's `visibleAppId`, which drives the
|
|
681
737
|
// `visible_app:` context line. Coalescing messages sent while different
|
|
@@ -16,13 +16,17 @@
|
|
|
16
16
|
* principalId at registration time so both sides agree. This keeps targeted
|
|
17
17
|
* host proxy execution working on Docker / platform-managed deployments.
|
|
18
18
|
*/
|
|
19
|
-
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
19
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
20
20
|
|
|
21
21
|
// ── Module mocks (must be set up before importing the route) ──────────────
|
|
22
22
|
|
|
23
23
|
let fakeHttpAuthDisabled = false;
|
|
24
24
|
let fakeGuardianPrincipalId: string | undefined = undefined;
|
|
25
25
|
|
|
26
|
+
// Manually-resolvable async resolutions, in call order, so tests control
|
|
27
|
+
// exactly when the SSE self-heal lookup completes.
|
|
28
|
+
const pendingAsyncResolutions: Array<(value: string | undefined) => void> = [];
|
|
29
|
+
|
|
26
30
|
mock.module("../config/env.js", () => ({
|
|
27
31
|
isHttpAuthDisabled: () => fakeHttpAuthDisabled,
|
|
28
32
|
hasUngatedHttpAuthDisabled: () => false,
|
|
@@ -38,8 +42,21 @@ mock.module("../runtime/local-actor-identity.js", () => ({
|
|
|
38
42
|
}
|
|
39
43
|
return fakeGuardianPrincipalId;
|
|
40
44
|
},
|
|
45
|
+
resolveActorPrincipalIdForLocalGuardian: (rawHeader: string | undefined) => {
|
|
46
|
+
if (rawHeader !== "dev-bypass" || !fakeHttpAuthDisabled) {
|
|
47
|
+
return Promise.resolve(rawHeader);
|
|
48
|
+
}
|
|
49
|
+
return new Promise<string | undefined>((resolve) => {
|
|
50
|
+
pendingAsyncResolutions.push(resolve);
|
|
51
|
+
});
|
|
52
|
+
},
|
|
41
53
|
}));
|
|
42
54
|
|
|
55
|
+
/** Let the fire-and-forget heal promise chain settle. */
|
|
56
|
+
async function flushMicrotasks(): Promise<void> {
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
// ── Real imports (after mocks) ────────────────────────────────────────────
|
|
44
61
|
|
|
45
62
|
import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
@@ -50,6 +67,10 @@ afterAll(() => {
|
|
|
50
67
|
});
|
|
51
68
|
|
|
52
69
|
describe("events SSE registration — dev-bypass actor translation", () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
pendingAsyncResolutions.length = 0;
|
|
72
|
+
});
|
|
73
|
+
|
|
53
74
|
test("translates 'dev-bypass' to the real guardian principalId when auth is disabled", () => {
|
|
54
75
|
fakeHttpAuthDisabled = true;
|
|
55
76
|
fakeGuardianPrincipalId = "guardian-real-id";
|
|
@@ -104,6 +125,167 @@ describe("events SSE registration — dev-bypass actor translation", () => {
|
|
|
104
125
|
ac.abort();
|
|
105
126
|
});
|
|
106
127
|
|
|
128
|
+
test("self-heals a cold-cache registration once the async guardian lookup resolves", async () => {
|
|
129
|
+
fakeHttpAuthDisabled = true;
|
|
130
|
+
fakeGuardianPrincipalId = undefined; // cold cache: sync resolution misses
|
|
131
|
+
|
|
132
|
+
const ac = new AbortController();
|
|
133
|
+
const hub = new AssistantEventHub();
|
|
134
|
+
|
|
135
|
+
handleSubscribeAssistantEvents(
|
|
136
|
+
{
|
|
137
|
+
headers: {
|
|
138
|
+
"x-vellum-client-id": "heal-client-001",
|
|
139
|
+
"x-vellum-interface-id": "chrome-extension",
|
|
140
|
+
"x-vellum-actor-principal-id": "dev-bypass",
|
|
141
|
+
},
|
|
142
|
+
abortSignal: ac.signal,
|
|
143
|
+
},
|
|
144
|
+
{ hub },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Registered without a principal, heal lookup in flight.
|
|
148
|
+
expect(hub.getActorPrincipalIdForClient("heal-client-001")).toBeUndefined();
|
|
149
|
+
expect(pendingAsyncResolutions).toHaveLength(1);
|
|
150
|
+
|
|
151
|
+
pendingAsyncResolutions.shift()!("guardian-real-id");
|
|
152
|
+
await flushMicrotasks();
|
|
153
|
+
|
|
154
|
+
// Hub record patched: a host-proxy request registered now snapshots the
|
|
155
|
+
// real principal, so the result-route same-actor check passes.
|
|
156
|
+
expect(hub.getActorPrincipalIdForClient("heal-client-001")).toBe(
|
|
157
|
+
"guardian-real-id",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
ac.abort();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("does not start the heal lookup when the sync resolution already succeeded", () => {
|
|
164
|
+
fakeHttpAuthDisabled = true;
|
|
165
|
+
fakeGuardianPrincipalId = "guardian-real-id"; // warm cache
|
|
166
|
+
|
|
167
|
+
const ac = new AbortController();
|
|
168
|
+
const hub = new AssistantEventHub();
|
|
169
|
+
|
|
170
|
+
handleSubscribeAssistantEvents(
|
|
171
|
+
{
|
|
172
|
+
headers: {
|
|
173
|
+
"x-vellum-client-id": "warm-client-001",
|
|
174
|
+
"x-vellum-interface-id": "macos",
|
|
175
|
+
"x-vellum-actor-principal-id": "dev-bypass",
|
|
176
|
+
},
|
|
177
|
+
abortSignal: ac.signal,
|
|
178
|
+
},
|
|
179
|
+
{ hub },
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(pendingAsyncResolutions).toHaveLength(0);
|
|
183
|
+
|
|
184
|
+
ac.abort();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("does not start the heal lookup for connections without a dev-bypass principal", () => {
|
|
188
|
+
fakeHttpAuthDisabled = true;
|
|
189
|
+
fakeGuardianPrincipalId = undefined;
|
|
190
|
+
|
|
191
|
+
const ac = new AbortController();
|
|
192
|
+
const hub = new AssistantEventHub();
|
|
193
|
+
|
|
194
|
+
// Legacy connection with no actor-principal header at all: it registers
|
|
195
|
+
// without a principal and must stay that way (no dev-bypass translation).
|
|
196
|
+
handleSubscribeAssistantEvents(
|
|
197
|
+
{
|
|
198
|
+
headers: {
|
|
199
|
+
"x-vellum-client-id": "legacy-client-001",
|
|
200
|
+
"x-vellum-interface-id": "macos",
|
|
201
|
+
},
|
|
202
|
+
abortSignal: ac.signal,
|
|
203
|
+
},
|
|
204
|
+
{ hub },
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(pendingAsyncResolutions).toHaveLength(0);
|
|
208
|
+
expect(
|
|
209
|
+
hub.getActorPrincipalIdForClient("legacy-client-001"),
|
|
210
|
+
).toBeUndefined();
|
|
211
|
+
|
|
212
|
+
ac.abort();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a stale heal cannot patch the subscription that replaced it (reconnect race)", async () => {
|
|
216
|
+
fakeHttpAuthDisabled = true;
|
|
217
|
+
fakeGuardianPrincipalId = undefined; // cold cache for the first connect
|
|
218
|
+
|
|
219
|
+
const ac1 = new AbortController();
|
|
220
|
+
const hub = new AssistantEventHub();
|
|
221
|
+
|
|
222
|
+
handleSubscribeAssistantEvents(
|
|
223
|
+
{
|
|
224
|
+
headers: {
|
|
225
|
+
"x-vellum-client-id": "race-client-001",
|
|
226
|
+
"x-vellum-interface-id": "macos",
|
|
227
|
+
"x-vellum-actor-principal-id": "dev-bypass",
|
|
228
|
+
},
|
|
229
|
+
abortSignal: ac1.signal,
|
|
230
|
+
},
|
|
231
|
+
{ hub },
|
|
232
|
+
);
|
|
233
|
+
expect(pendingAsyncResolutions).toHaveLength(1);
|
|
234
|
+
const staleHeal = pendingAsyncResolutions.shift()!;
|
|
235
|
+
|
|
236
|
+
// Client reconnects before the first heal resolves; cache is warm now.
|
|
237
|
+
fakeGuardianPrincipalId = "guardian-current";
|
|
238
|
+
const ac2 = new AbortController();
|
|
239
|
+
handleSubscribeAssistantEvents(
|
|
240
|
+
{
|
|
241
|
+
headers: {
|
|
242
|
+
"x-vellum-client-id": "race-client-001",
|
|
243
|
+
"x-vellum-interface-id": "macos",
|
|
244
|
+
"x-vellum-actor-principal-id": "dev-bypass",
|
|
245
|
+
},
|
|
246
|
+
abortSignal: ac2.signal,
|
|
247
|
+
},
|
|
248
|
+
{ hub },
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// The stale heal resolves late with a different value; it is keyed to
|
|
252
|
+
// the disposed connection, so the live record must be untouched.
|
|
253
|
+
staleHeal("guardian-stale");
|
|
254
|
+
await flushMicrotasks();
|
|
255
|
+
|
|
256
|
+
expect(hub.getActorPrincipalIdForClient("race-client-001")).toBe(
|
|
257
|
+
"guardian-current",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
ac1.abort();
|
|
261
|
+
ac2.abort();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("fillClientActorPrincipalId never overwrites a present principal", () => {
|
|
265
|
+
const hub = new AssistantEventHub();
|
|
266
|
+
const sub = hub.subscribe({
|
|
267
|
+
type: "client",
|
|
268
|
+
clientId: "direct-client-001",
|
|
269
|
+
interfaceId: "macos",
|
|
270
|
+
capabilities: [],
|
|
271
|
+
actorPrincipalId: "existing-principal",
|
|
272
|
+
callback: () => {},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
hub.fillClientActorPrincipalId(sub.connectionId, "other-principal");
|
|
276
|
+
expect(hub.getActorPrincipalIdForClient("direct-client-001")).toBe(
|
|
277
|
+
"existing-principal",
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Unknown connection id is a no-op.
|
|
281
|
+
hub.fillClientActorPrincipalId("conn-does-not-exist", "other-principal");
|
|
282
|
+
expect(hub.getActorPrincipalIdForClient("direct-client-001")).toBe(
|
|
283
|
+
"existing-principal",
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
sub.dispose();
|
|
287
|
+
});
|
|
288
|
+
|
|
107
289
|
test("does NOT translate when auth is enabled (production mode)", () => {
|
|
108
290
|
// Defense in depth: make sure we never silently rewrite a real
|
|
109
291
|
// principalId that legitimately happens to be the literal "dev-bypass"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register and tear down client subscribers on an assistant event hub.
|
|
3
|
+
*
|
|
4
|
+
* The caller supplies the hub, so this helper holds no runtime import into
|
|
5
|
+
* `src/` and stays safe for the test preload's import graph.
|
|
6
|
+
*/
|
|
7
|
+
import type { HostProxyCapability, InterfaceId } from "../../channels/types.js";
|
|
8
|
+
import type {
|
|
9
|
+
AssistantEventHub,
|
|
10
|
+
DesktopPresenceState,
|
|
11
|
+
} from "../../runtime/assistant-event-hub.js";
|
|
12
|
+
|
|
13
|
+
export interface RegisterHubClientArgs {
|
|
14
|
+
hub: AssistantEventHub;
|
|
15
|
+
clientId: string;
|
|
16
|
+
interfaceId?: InterfaceId;
|
|
17
|
+
capabilities?: HostProxyCapability[];
|
|
18
|
+
actorPrincipalId?: string;
|
|
19
|
+
/** Reported immediately after subscribing, when supplied. */
|
|
20
|
+
presence?: DesktopPresenceState;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerHubClient(args: RegisterHubClientArgs): void {
|
|
24
|
+
const { hub } = args;
|
|
25
|
+
hub.subscribe({
|
|
26
|
+
type: "client",
|
|
27
|
+
clientId: args.clientId,
|
|
28
|
+
interfaceId: args.interfaceId ?? "macos",
|
|
29
|
+
capabilities: args.capabilities ?? [],
|
|
30
|
+
actorPrincipalId: args.actorPrincipalId,
|
|
31
|
+
callback: () => {},
|
|
32
|
+
});
|
|
33
|
+
if (args.presence) {
|
|
34
|
+
hub.setClientPresence(args.clientId, args.presence);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearHubClients(hub: AssistantEventHub): void {
|
|
39
|
+
for (const client of hub.listClients()) {
|
|
40
|
+
hub.disposeClient(client.clientId);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -312,6 +312,73 @@ describe("handleHostBashResult", () => {
|
|
|
312
312
|
});
|
|
313
313
|
});
|
|
314
314
|
|
|
315
|
+
// ── Fill-if-missing: persisted target healed after registration ────
|
|
316
|
+
//
|
|
317
|
+
// A request registered while the target's SSE record had no principal
|
|
318
|
+
// persists `targetActorPrincipalId: undefined`. Once the SSE self-heal
|
|
319
|
+
// fills the hub record, the result route re-reads the live record,
|
|
320
|
+
// but ONLY when the persisted value is missing.
|
|
321
|
+
|
|
322
|
+
describe("targeted request - missing persisted target principal fills from the live hub record", () => {
|
|
323
|
+
test("accepts a matching submitter when the hub record was healed after registration", async () => {
|
|
324
|
+
const requestId = "req-healed-target";
|
|
325
|
+
// Registered pre-heal: hub had no principal, so nothing was persisted.
|
|
326
|
+
registerPending(requestId, { targetClientId: "client-abc" });
|
|
327
|
+
// SSE self-heal fills the hub record afterwards.
|
|
328
|
+
clientActorPrincipals.set("client-abc", "principal-shared");
|
|
329
|
+
|
|
330
|
+
const result = await handleHostBashResult({
|
|
331
|
+
body: bashBody(requestId),
|
|
332
|
+
headers: {
|
|
333
|
+
"x-vellum-client-id": "client-abc",
|
|
334
|
+
"x-vellum-actor-principal-id": "principal-shared",
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(result).toEqual({ accepted: true });
|
|
339
|
+
expect(resolvedIds).toContain(requestId);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("still throws ForbiddenError (403) for a submitter with a different principal than the healed target", () => {
|
|
343
|
+
const requestId = "req-healed-target-mismatch";
|
|
344
|
+
registerPending(requestId, { targetClientId: "client-abc" });
|
|
345
|
+
clientActorPrincipals.set("client-abc", "principal-victim");
|
|
346
|
+
|
|
347
|
+
expect(() =>
|
|
348
|
+
handleHostBashResult({
|
|
349
|
+
body: bashBody(requestId),
|
|
350
|
+
headers: {
|
|
351
|
+
"x-vellum-client-id": "client-abc",
|
|
352
|
+
"x-vellum-actor-principal-id": "principal-attacker",
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
).toThrow(ForbiddenError);
|
|
356
|
+
expect(pendingStore.has(requestId)).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("a present persisted principal always wins over the live hub record", () => {
|
|
360
|
+
// The fill is missing-only: a persisted value captured at registration
|
|
361
|
+
// is never overridden, so a submitter matching only the (different)
|
|
362
|
+
// live record is still rejected.
|
|
363
|
+
const requestId = "req-persisted-wins";
|
|
364
|
+
registerPending(requestId, {
|
|
365
|
+
targetClientId: "client-abc",
|
|
366
|
+
targetActorPrincipalId: "principal-registration",
|
|
367
|
+
});
|
|
368
|
+
clientActorPrincipals.set("client-abc", "principal-live");
|
|
369
|
+
|
|
370
|
+
expect(() =>
|
|
371
|
+
handleHostBashResult({
|
|
372
|
+
body: bashBody(requestId),
|
|
373
|
+
headers: {
|
|
374
|
+
"x-vellum-client-id": "client-abc",
|
|
375
|
+
"x-vellum-actor-principal-id": "principal-live",
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
).toThrow(ForbiddenError);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
315
382
|
// ── Untargeted-request behavior (regression for new check) ─────────
|
|
316
383
|
|
|
317
384
|
describe("untargeted request — actor principal check is skipped", () => {
|
|
@@ -650,6 +650,50 @@ describe("HTTP POST /v1/messages client metadata headers", () => {
|
|
|
650
650
|
];
|
|
651
651
|
expect(persistOptions.metadata).toBeUndefined();
|
|
652
652
|
});
|
|
653
|
+
|
|
654
|
+
// Persistence merges the row's `client.os` from two sources and only one of
|
|
655
|
+
// them is this row's own: the request. Threading the body's `clientOs` is
|
|
656
|
+
// what lets it tell a reported OS apart from the one a transport-less turn
|
|
657
|
+
// inherits off the live conversation.
|
|
658
|
+
test("threads the body clientOs as this row's own OS evidence", async () => {
|
|
659
|
+
const persistUserMessage = mock(
|
|
660
|
+
async (_options: { requestClientOs?: string }) => ({
|
|
661
|
+
id: "persisted-msg-id",
|
|
662
|
+
deduplicated: false,
|
|
663
|
+
}),
|
|
664
|
+
);
|
|
665
|
+
const runAgentLoop = mock(async () => undefined);
|
|
666
|
+
const conversation = makeConversation({ persistUserMessage, runAgentLoop });
|
|
667
|
+
|
|
668
|
+
const res = await sendMessage("hello", conversation, {
|
|
669
|
+
clientOs: "macos",
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(res.status).toBe(202);
|
|
673
|
+
const [persistOptions] = persistUserMessage.mock.calls[0] as unknown as [
|
|
674
|
+
{ requestClientOs?: string },
|
|
675
|
+
];
|
|
676
|
+
expect(persistOptions.requestClientOs).toBe("macos");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("omits the OS evidence when the body reports no clientOs", async () => {
|
|
680
|
+
const persistUserMessage = mock(
|
|
681
|
+
async (_options: { requestClientOs?: string }) => ({
|
|
682
|
+
id: "persisted-msg-id",
|
|
683
|
+
deduplicated: false,
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
const runAgentLoop = mock(async () => undefined);
|
|
687
|
+
const conversation = makeConversation({ persistUserMessage, runAgentLoop });
|
|
688
|
+
|
|
689
|
+
const res = await sendMessage("hello", conversation);
|
|
690
|
+
|
|
691
|
+
expect(res.status).toBe(202);
|
|
692
|
+
const [persistOptions] = persistUserMessage.mock.calls[0] as unknown as [
|
|
693
|
+
{ requestClientOs?: string },
|
|
694
|
+
];
|
|
695
|
+
expect(persistOptions.requestClientOs).toBeUndefined();
|
|
696
|
+
});
|
|
653
697
|
});
|
|
654
698
|
|
|
655
699
|
// ============================================================================
|