@vellumai/assistant 0.11.3-staging.1 → 0.11.3-staging.2
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/package.json +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +123 -0
- package/src/__tests__/conversation-queue.test.ts +168 -0
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +64 -1
- package/src/__tests__/oauth-provider-seed-logos.test.ts +65 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +40 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +68 -1
- package/src/approvals/guardian-request-resolvers.ts +6 -3
- package/src/config/schemas/timeouts.ts +1 -1
- package/src/daemon/conversation-agent-loop.ts +22 -3
- package/src/daemon/conversation-messaging.ts +21 -1
- package/src/daemon/conversation-process.ts +55 -5
- package/src/daemon/conversation-queue-manager.ts +10 -0
- package/src/daemon/conversation.ts +6 -0
- package/src/daemon/trust-context-types.ts +39 -0
- package/src/oauth/AGENTS.md +4 -2
- package/src/oauth/seed-providers.ts +6 -3
- package/src/runtime/routes/conversation-routes.ts +10 -0
- package/src/subagent/manager.ts +9 -2
- package/src/tools/execution-timeout.ts +9 -2
- package/src/tools/tool-approval-handler.ts +45 -4
package/package.json
CHANGED
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
MessageQueue,
|
|
87
87
|
type QueuedMessage,
|
|
88
88
|
} from "../daemon/conversation-queue-manager.js";
|
|
89
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
89
90
|
|
|
90
91
|
// ---------------------------------------------------------------------------
|
|
91
92
|
// Fake context — captures preactivation calls, satisfies the bare minimum
|
|
@@ -172,6 +173,7 @@ function makeQueuedMessage(opts: {
|
|
|
172
173
|
content?: string;
|
|
173
174
|
turnInterfaceContext?: TurnInterfaceContext;
|
|
174
175
|
sourceActorPrincipalId?: string;
|
|
176
|
+
trustContext?: TrustContext;
|
|
175
177
|
}): QueuedMessage {
|
|
176
178
|
return {
|
|
177
179
|
content: opts.content ?? "follow up",
|
|
@@ -185,6 +187,7 @@ function makeQueuedMessage(opts: {
|
|
|
185
187
|
authContext: opts.sourceActorPrincipalId
|
|
186
188
|
? ({ actorPrincipalId: opts.sourceActorPrincipalId } as never)
|
|
187
189
|
: undefined,
|
|
190
|
+
trustContext: opts.trustContext,
|
|
188
191
|
};
|
|
189
192
|
}
|
|
190
193
|
|
|
@@ -371,6 +374,126 @@ describe("drainQueue preactivation re-add for host-proxy interfaces", () => {
|
|
|
371
374
|
expect(ctx.currentTurnSourceActorPrincipalId).toBe("trusted-contact-user");
|
|
372
375
|
});
|
|
373
376
|
|
|
377
|
+
test("drainSingleMessage runs the turn under the queued sender's trust, not the live slot", async () => {
|
|
378
|
+
// The conversation-level slot holds whichever actor sent most recently.
|
|
379
|
+
// A message that waited while someone else sent must still run as its own
|
|
380
|
+
// sender: trust decides `trustClass`, `executionChannel`, and
|
|
381
|
+
// `requesterExternalUserId`, so reading the slot hands the whole
|
|
382
|
+
// tool-approval path the wrong identity in both directions -- a contact
|
|
383
|
+
// inheriting guardian self-approval, or a guardian's own call escalating
|
|
384
|
+
// back to her as a contact's grant request.
|
|
385
|
+
const contactTrust: TrustContext = {
|
|
386
|
+
trustClass: "trusted_contact",
|
|
387
|
+
sourceChannel: "slack",
|
|
388
|
+
requesterExternalUserId: "U-contact",
|
|
389
|
+
};
|
|
390
|
+
const queue = new MessageQueue();
|
|
391
|
+
queue.push(
|
|
392
|
+
makeQueuedMessage({
|
|
393
|
+
requestId: "req-contact",
|
|
394
|
+
trustContext: contactTrust,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
const ctx = makeFakeContext({ queue });
|
|
398
|
+
// Someone else sent after this message was queued, moving the slot.
|
|
399
|
+
ctx.trustContext = {
|
|
400
|
+
trustClass: "guardian",
|
|
401
|
+
sourceChannel: "vellum",
|
|
402
|
+
requesterExternalUserId: "guardian-principal",
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
await drainQueue(ctx);
|
|
406
|
+
|
|
407
|
+
expect(ctx.currentTurnTrustContext?.trustClass).toBe("trusted_contact");
|
|
408
|
+
expect(ctx.currentTurnTrustContext?.requesterExternalUserId).toBe(
|
|
409
|
+
"U-contact",
|
|
410
|
+
);
|
|
411
|
+
expect(ctx.currentTurnTrustContext?.sourceChannel).toBe("slack");
|
|
412
|
+
// The slot itself is left alone; only the turn's view is corrected.
|
|
413
|
+
expect(ctx.trustContext?.trustClass).toBe("guardian");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("buildPassthroughBatch refuses to coalesce two channel senders", async () => {
|
|
417
|
+
// Channel senders carry no principal, so the `sourceActorPrincipalId`
|
|
418
|
+
// boundary sees `undefined === undefined` and would batch two different
|
|
419
|
+
// Slack contacts into one turn running under the head's trust. The batch
|
|
420
|
+
// must split on the sender's trust identity instead.
|
|
421
|
+
const ifCtx: TurnInterfaceContext = {
|
|
422
|
+
userMessageInterface: "web",
|
|
423
|
+
assistantMessageInterface: "web",
|
|
424
|
+
};
|
|
425
|
+
const queue = new MessageQueue();
|
|
426
|
+
queue.push(
|
|
427
|
+
makeQueuedMessage({
|
|
428
|
+
requestId: "req-contact-a",
|
|
429
|
+
content: "from A",
|
|
430
|
+
turnInterfaceContext: ifCtx,
|
|
431
|
+
trustContext: {
|
|
432
|
+
trustClass: "trusted_contact",
|
|
433
|
+
sourceChannel: "slack",
|
|
434
|
+
requesterExternalUserId: "U-alex",
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
queue.push(
|
|
439
|
+
makeQueuedMessage({
|
|
440
|
+
requestId: "req-contact-b",
|
|
441
|
+
content: "from B",
|
|
442
|
+
turnInterfaceContext: ifCtx,
|
|
443
|
+
trustContext: {
|
|
444
|
+
trustClass: "trusted_contact",
|
|
445
|
+
sourceChannel: "slack",
|
|
446
|
+
requesterExternalUserId: "U-blake",
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
const ctx = makeFakeContext({ queue, turnInterfaceContext: ifCtx });
|
|
451
|
+
|
|
452
|
+
await drainQueue(ctx);
|
|
453
|
+
|
|
454
|
+
// B stays queued: it gets its own turn under its own trust.
|
|
455
|
+
expect(queue.length).toBe(1);
|
|
456
|
+
expect(queue.peek(0)?.requestId).toBe("req-contact-b");
|
|
457
|
+
expect(ctx.currentTurnTrustContext?.requesterExternalUserId).toBe("U-alex");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("buildPassthroughBatch still coalesces two messages from the same sender", async () => {
|
|
461
|
+
// Sensitivity check on the split above: identical trust must still batch,
|
|
462
|
+
// otherwise the boundary would be splitting on something incidental and
|
|
463
|
+
// the test above would pass for the wrong reason.
|
|
464
|
+
const ifCtx: TurnInterfaceContext = {
|
|
465
|
+
userMessageInterface: "web",
|
|
466
|
+
assistantMessageInterface: "web",
|
|
467
|
+
};
|
|
468
|
+
const sameTrust: TrustContext = {
|
|
469
|
+
trustClass: "trusted_contact",
|
|
470
|
+
sourceChannel: "slack",
|
|
471
|
+
requesterExternalUserId: "U-alex",
|
|
472
|
+
};
|
|
473
|
+
const queue = new MessageQueue();
|
|
474
|
+
queue.push(
|
|
475
|
+
makeQueuedMessage({
|
|
476
|
+
requestId: "req-a1",
|
|
477
|
+
content: "first",
|
|
478
|
+
turnInterfaceContext: ifCtx,
|
|
479
|
+
trustContext: { ...sameTrust },
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
queue.push(
|
|
483
|
+
makeQueuedMessage({
|
|
484
|
+
requestId: "req-a2",
|
|
485
|
+
content: "second",
|
|
486
|
+
turnInterfaceContext: ifCtx,
|
|
487
|
+
trustContext: { ...sameTrust },
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
const ctx = makeFakeContext({ queue, turnInterfaceContext: ifCtx });
|
|
491
|
+
|
|
492
|
+
await drainQueue(ctx);
|
|
493
|
+
|
|
494
|
+
expect(queue.length).toBe(0);
|
|
495
|
+
});
|
|
496
|
+
|
|
374
497
|
test("drainSingleMessage does NOT re-add 'app-control' for web-sourced message when no capable client is connected", async () => {
|
|
375
498
|
// mockCapabilityClients remains [] (reset by afterEach from prior test)
|
|
376
499
|
const queue = new MessageQueue();
|
|
@@ -556,6 +556,174 @@ describe("Conversation message queue", () => {
|
|
|
556
556
|
await new Promise((r) => setTimeout(r, 10));
|
|
557
557
|
});
|
|
558
558
|
|
|
559
|
+
test("enqueueMessage captures the sender's trust, immune to a later slot change", async () => {
|
|
560
|
+
// Trust must ride with the queued message. The conversation-level slot is
|
|
561
|
+
// rewritten by whoever sends next, so a message that reads it at drain time
|
|
562
|
+
// would run as the wrong actor. Capturing at enqueue is what makes the
|
|
563
|
+
// drain's identity independent of who sent afterwards.
|
|
564
|
+
const conversation = makeConversation();
|
|
565
|
+
await conversation.loadFromDb();
|
|
566
|
+
|
|
567
|
+
conversation.setTrustContext({
|
|
568
|
+
trustClass: "trusted_contact",
|
|
569
|
+
sourceChannel: "slack",
|
|
570
|
+
requesterExternalUserId: "U-contact",
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const p1 = conversation.processMessage({
|
|
574
|
+
content: "msg-1",
|
|
575
|
+
attachments: [],
|
|
576
|
+
onEvent: () => {},
|
|
577
|
+
requestId: "req-1",
|
|
578
|
+
});
|
|
579
|
+
await waitForPendingRun(1);
|
|
580
|
+
|
|
581
|
+
conversation.enqueueMessage({ content: "msg-2", requestId: "req-2" });
|
|
582
|
+
|
|
583
|
+
// A different actor sends while the message waits, moving the slot.
|
|
584
|
+
conversation.setTrustContext({
|
|
585
|
+
trustClass: "guardian",
|
|
586
|
+
sourceChannel: "vellum",
|
|
587
|
+
requesterExternalUserId: "guardian-principal",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const queued = conversation.queue.peek(0);
|
|
591
|
+
expect(queued?.requestId).toBe("req-2");
|
|
592
|
+
expect(queued?.trustContext?.trustClass).toBe("trusted_contact");
|
|
593
|
+
expect(queued?.trustContext?.requesterExternalUserId).toBe("U-contact");
|
|
594
|
+
|
|
595
|
+
await resolveRun(0);
|
|
596
|
+
await p1;
|
|
597
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("a drained turn keeps its sender's trust once the agent loop is running", async () => {
|
|
601
|
+
// Exercises the real drain -> runAgentLoop ordering against a live
|
|
602
|
+
// Conversation. The loop re-initializes the per-turn trust snapshot on
|
|
603
|
+
// entry, so a drain that only stamped the field before calling it would
|
|
604
|
+
// have that stamp silently undone and the turn would execute as whoever
|
|
605
|
+
// sent most recently. Asserting inside the run is the point: checking
|
|
606
|
+
// before the loop starts passes either way.
|
|
607
|
+
const conversation = makeConversation();
|
|
608
|
+
await conversation.loadFromDb();
|
|
609
|
+
|
|
610
|
+
conversation.setTrustContext({
|
|
611
|
+
trustClass: "trusted_contact",
|
|
612
|
+
sourceChannel: "slack",
|
|
613
|
+
requesterExternalUserId: "U-contact",
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const p1 = conversation.processMessage({
|
|
617
|
+
content: "msg-1",
|
|
618
|
+
attachments: [],
|
|
619
|
+
onEvent: () => {},
|
|
620
|
+
requestId: "req-1",
|
|
621
|
+
});
|
|
622
|
+
await waitForPendingRun(1);
|
|
623
|
+
|
|
624
|
+
conversation.enqueueMessage({ content: "msg-2", requestId: "req-2" });
|
|
625
|
+
|
|
626
|
+
// The guardian sends while the contact's message waits, moving the slot.
|
|
627
|
+
conversation.setTrustContext({
|
|
628
|
+
trustClass: "guardian",
|
|
629
|
+
sourceChannel: "vellum",
|
|
630
|
+
requesterExternalUserId: "guardian-principal",
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// Finish the first turn so the queue drains into a second run.
|
|
634
|
+
await resolveRun(0);
|
|
635
|
+
await p1;
|
|
636
|
+
await waitForPendingRun(2);
|
|
637
|
+
|
|
638
|
+
// Read while run 2 is in flight: this is what tool setup and the
|
|
639
|
+
// guardian-request producers resolve against.
|
|
640
|
+
expect(conversation.currentTurnTrustContext?.trustClass).toBe(
|
|
641
|
+
"trusted_contact",
|
|
642
|
+
);
|
|
643
|
+
expect(conversation.currentTurnTrustContext?.requesterExternalUserId).toBe(
|
|
644
|
+
"U-contact",
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
await resolveRun(1);
|
|
648
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("a processMessage turn keeps its turn-start trust when the slot moves before the loop opens", async () => {
|
|
652
|
+
// processMessage is the third turn entry point, and the one a web turn
|
|
653
|
+
// takes on an idle conversation (the route only enqueues while
|
|
654
|
+
// isProcessing). It captures trust at turn start, then awaits several
|
|
655
|
+
// times before the agent loop opens. The conversation slot is writable
|
|
656
|
+
// throughout that window by paths that do not own this turn: live-voice
|
|
657
|
+
// hydration stamp-and-restore, pointer elevation, the voice bridge.
|
|
658
|
+
//
|
|
659
|
+
// Driving a real Conversation with only AgentLoop.run mocked is what makes
|
|
660
|
+
// this observable. A double that stubs runAgentLoop itself would skip the
|
|
661
|
+
// re-read entirely and pass either way.
|
|
662
|
+
const conversation = makeConversation();
|
|
663
|
+
await conversation.loadFromDb();
|
|
664
|
+
|
|
665
|
+
conversation.setTrustContext({
|
|
666
|
+
trustClass: "trusted_contact",
|
|
667
|
+
sourceChannel: "slack",
|
|
668
|
+
requesterExternalUserId: "U-contact",
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Land another actor's slot write inside the window, after the turn-start
|
|
672
|
+
// capture and before the loop opens. persistUserMessage is awaited there.
|
|
673
|
+
const originalPersist = conversation.persistUserMessage.bind(conversation);
|
|
674
|
+
let slotMoved = false;
|
|
675
|
+
(
|
|
676
|
+
conversation as unknown as {
|
|
677
|
+
persistUserMessage: typeof conversation.persistUserMessage;
|
|
678
|
+
}
|
|
679
|
+
).persistUserMessage = async (opts) => {
|
|
680
|
+
const result = await originalPersist(opts);
|
|
681
|
+
conversation.setTrustContext({
|
|
682
|
+
trustClass: "guardian",
|
|
683
|
+
sourceChannel: "vellum",
|
|
684
|
+
requesterExternalUserId: "guardian-principal",
|
|
685
|
+
});
|
|
686
|
+
// Also move the per-turn field, which is writable out-of-band: a wake
|
|
687
|
+
// that settles inside this window restores its prior value there
|
|
688
|
+
// (agent-wake stamps at :1470 and restores in a `finally` at :1554).
|
|
689
|
+
// Covers both writers, so a fix that reads either one back at the agent
|
|
690
|
+
// loop call still fails this test.
|
|
691
|
+
conversation.currentTurnTrustContext = {
|
|
692
|
+
trustClass: "guardian",
|
|
693
|
+
sourceChannel: "vellum",
|
|
694
|
+
requesterExternalUserId: "guardian-principal",
|
|
695
|
+
};
|
|
696
|
+
slotMoved = true;
|
|
697
|
+
return result;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const p1 = conversation.processMessage({
|
|
701
|
+
content: "msg-1",
|
|
702
|
+
attachments: [],
|
|
703
|
+
onEvent: () => {},
|
|
704
|
+
requestId: "req-1",
|
|
705
|
+
});
|
|
706
|
+
await waitForPendingRun(1);
|
|
707
|
+
|
|
708
|
+
// Guard the test itself: if the injection stopped running, the assertions
|
|
709
|
+
// below would pass for the wrong reason.
|
|
710
|
+
expect(slotMoved).toBe(true);
|
|
711
|
+
expect(conversation.trustContext?.trustClass).toBe("guardian");
|
|
712
|
+
|
|
713
|
+
// Read mid-run: this is what tool setup and the guardian-request producers
|
|
714
|
+
// resolve against while the turn executes.
|
|
715
|
+
expect(conversation.currentTurnTrustContext?.trustClass).toBe(
|
|
716
|
+
"trusted_contact",
|
|
717
|
+
);
|
|
718
|
+
expect(conversation.currentTurnTrustContext?.requesterExternalUserId).toBe(
|
|
719
|
+
"U-contact",
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
await resolveRun(0);
|
|
723
|
+
await p1;
|
|
724
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
725
|
+
});
|
|
726
|
+
|
|
559
727
|
test("[experimental] queued passthrough siblings drain as a single batched run", async () => {
|
|
560
728
|
const conversation = makeConversation();
|
|
561
729
|
await conversation.loadFromDb();
|
|
@@ -164,7 +164,11 @@ function makeConversationWithPendingConfirmation(
|
|
|
164
164
|
promoteToHead: (requestId: string) => ({ requestId }),
|
|
165
165
|
},
|
|
166
166
|
pendingSteerRepair: false,
|
|
167
|
-
|
|
167
|
+
// Stores rather than discarding, so a test can observe which trust the
|
|
168
|
+
// route resolved and move the slot afterwards.
|
|
169
|
+
setTrustContext(this: { trustContext: unknown }, ctx: unknown) {
|
|
170
|
+
this.trustContext = ctx;
|
|
171
|
+
},
|
|
168
172
|
updateClient: () => {},
|
|
169
173
|
emitConfirmationStateChanged: () => {},
|
|
170
174
|
emitActivityState: () => {},
|
|
@@ -354,3 +358,62 @@ describe("hidden sends to an idle conversation with a pending confirmation", ()
|
|
|
354
358
|
expect(spies.agentLoopOptions()?.isHiddenPrompt).toBeUndefined();
|
|
355
359
|
});
|
|
356
360
|
});
|
|
361
|
+
|
|
362
|
+
describe("POST /messages turn trust", () => {
|
|
363
|
+
test("the idle send path runs its turn under the trust resolved for the request", async () => {
|
|
364
|
+
// The idle web path does not go through `processMessage`; it persists and
|
|
365
|
+
// calls `runAgentLoop` directly. Without the captured trust travelling
|
|
366
|
+
// with that call, the loop re-reads the conversation slot when it opens,
|
|
367
|
+
// and the slot is writable in between by paths that do not own this turn
|
|
368
|
+
// (channel ingress for another actor, live-voice hydration, pointer
|
|
369
|
+
// elevation, the voice bridge).
|
|
370
|
+
//
|
|
371
|
+
// The defect here is at the call site, not inside the loop, so observing
|
|
372
|
+
// the options the route passes is sufficient. The re-read *inside* the
|
|
373
|
+
// loop is covered separately against a real Conversation, since a stub
|
|
374
|
+
// like this one cannot see it.
|
|
375
|
+
const spies = makeConversationWithPendingConfirmation(false);
|
|
376
|
+
setConversation(CONV_ID, spies.conversation);
|
|
377
|
+
|
|
378
|
+
const conversation = spies.conversation as unknown as {
|
|
379
|
+
trustContext: unknown;
|
|
380
|
+
persistUserMessage: () => Promise<{ id: string; deduplicated: boolean }>;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Another actor writes the slot inside the window, after the route
|
|
384
|
+
// resolves this request's trust and before the loop call.
|
|
385
|
+
let slotMoved = false;
|
|
386
|
+
conversation.persistUserMessage = async () => {
|
|
387
|
+
conversation.trustContext = {
|
|
388
|
+
trustClass: "trusted_contact",
|
|
389
|
+
sourceChannel: "slack",
|
|
390
|
+
requesterExternalUserId: "U-other-actor",
|
|
391
|
+
};
|
|
392
|
+
slotMoved = true;
|
|
393
|
+
return { id: "persisted-user-id", deduplicated: false };
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const res = await callHandler(
|
|
397
|
+
(args) => handleSendMessage(args, makeDeps(spies.conversation)),
|
|
398
|
+
makeRequest({ content: "run a command" }),
|
|
399
|
+
undefined,
|
|
400
|
+
202,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(res.status).toBe(202);
|
|
404
|
+
// Guard the test itself: if the injection stopped running the assertion
|
|
405
|
+
// below would pass for the wrong reason.
|
|
406
|
+
expect(slotMoved).toBe(true);
|
|
407
|
+
expect(
|
|
408
|
+
(conversation.trustContext as { trustClass?: string } | undefined)
|
|
409
|
+
?.trustClass,
|
|
410
|
+
).toBe("trusted_contact");
|
|
411
|
+
|
|
412
|
+
// The turn carries the trust this request resolved, not the moved slot.
|
|
413
|
+
const turnTrust = spies.agentLoopOptions()?.turnTrustContext as
|
|
414
|
+
| { trustClass?: string }
|
|
415
|
+
| undefined;
|
|
416
|
+
expect(turnTrust).toBeDefined();
|
|
417
|
+
expect(turnTrust?.trustClass).toBe("guardian");
|
|
418
|
+
});
|
|
419
|
+
});
|
|
@@ -1,7 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
1
3
|
import { describe, expect, test } from "bun:test";
|
|
2
4
|
|
|
3
5
|
import { PROVIDER_SEED_DATA } from "../oauth/seed-providers.js";
|
|
4
6
|
|
|
7
|
+
const REPO_ROOT = dirname(dirname(dirname(import.meta.dir)));
|
|
8
|
+
const WEB_PUBLIC_DIR = join(REPO_ROOT, "clients/web/public");
|
|
9
|
+
const INTEGRATION_ICON_SOURCE = join(
|
|
10
|
+
REPO_ROOT,
|
|
11
|
+
"clients/web/src/components/integrations/integration-icon.tsx",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Provider keys the web client draws without a `BUNDLED_LOGO_URLS` entry.
|
|
16
|
+
* Google's mark is multi-colour, so it ships as the `GoogleLogo` component
|
|
17
|
+
* and is returned before the bundled-asset lookup runs.
|
|
18
|
+
*/
|
|
19
|
+
const COMPONENT_RENDERED_PROVIDERS = new Set(["google"]);
|
|
20
|
+
|
|
21
|
+
/** `publicAsset("/images/...")` paths from `BUNDLED_LOGO_URLS`, by key. */
|
|
22
|
+
function readBundledLogoMap(): Map<string, string> {
|
|
23
|
+
const source = readFileSync(INTEGRATION_ICON_SOURCE, "utf8");
|
|
24
|
+
const block = source.match(
|
|
25
|
+
/const BUNDLED_LOGO_URLS: Record<string, string> = \{([\s\S]*?)\n\};/,
|
|
26
|
+
);
|
|
27
|
+
if (!block) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Could not find BUNDLED_LOGO_URLS in ${INTEGRATION_ICON_SOURCE}. If it ` +
|
|
30
|
+
`was renamed or restructured, update this test rather than deleting it.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const entries = new Map<string, string>();
|
|
34
|
+
for (const [, key, path] of block[1]!.matchAll(
|
|
35
|
+
/(\w+):\s*publicAsset\("([^"]+)"\)/g,
|
|
36
|
+
)) {
|
|
37
|
+
entries.set(key!, path!);
|
|
38
|
+
}
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
|
|
5
42
|
/**
|
|
6
43
|
* Allowed CDN prefixes for the ``logoUrl`` field on ``PROVIDER_SEED_DATA``
|
|
7
44
|
* (``assistant/src/oauth/seed-providers.ts``):
|
|
@@ -41,3 +78,31 @@ describe("PROVIDER_SEED_DATA logo URLs", () => {
|
|
|
41
78
|
expect(invalid).toEqual([]);
|
|
42
79
|
});
|
|
43
80
|
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `logoUrl` above is a fallback for providers registered at runtime. Every
|
|
84
|
+
* provider we seed ourselves must draw from an asset we ship, because an icon
|
|
85
|
+
* CDN can drop a brand at any time and the client then degrades to an initials
|
|
86
|
+
* avatar with nothing in CI to notice: the prefix check above passes just as
|
|
87
|
+
* happily for a URL that 404s. Simple Icons hosts no Microsoft mark and no
|
|
88
|
+
* Slack mark, so the seeded URLs for those brands do not resolve there.
|
|
89
|
+
*/
|
|
90
|
+
describe("PROVIDER_SEED_DATA bundled logo coverage", () => {
|
|
91
|
+
test("every seeded provider renders from a bundled asset", () => {
|
|
92
|
+
const bundled = readBundledLogoMap();
|
|
93
|
+
const uncovered = Object.keys(PROVIDER_SEED_DATA).filter(
|
|
94
|
+
(provider) =>
|
|
95
|
+
!bundled.has(provider) && !COMPONENT_RENDERED_PROVIDERS.has(provider),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(uncovered).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("every bundled logo path resolves to a file on disk", () => {
|
|
102
|
+
const missingFiles = [...readBundledLogoMap()]
|
|
103
|
+
.filter(([, path]) => !existsSync(join(WEB_PUBLIC_DIR, path)))
|
|
104
|
+
.map(([provider, path]) => ({ provider, path }));
|
|
105
|
+
|
|
106
|
+
expect(missingFiles).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -101,17 +101,20 @@ import {
|
|
|
101
101
|
getRegisteredKinds,
|
|
102
102
|
getResolver,
|
|
103
103
|
} from "../approvals/guardian-request-resolvers.js";
|
|
104
|
+
import { getConfig } from "../config/loader.js";
|
|
104
105
|
import { getDb } from "../persistence/db-connection.js";
|
|
105
106
|
import { getSqlite } from "../persistence/db-connection.js";
|
|
106
107
|
import { initializeDb } from "../persistence/db-init.js";
|
|
107
108
|
import { scopedApprovalGrants } from "../persistence/schema/index.js";
|
|
108
109
|
import {
|
|
110
|
+
TC_GRANT_WAIT_MAX_MS,
|
|
109
111
|
ToolApprovalHandler,
|
|
110
112
|
waitForInlineGrant,
|
|
111
113
|
} from "../tools/tool-approval-handler.js";
|
|
112
114
|
import type { ToolContext } from "../tools/types.js";
|
|
115
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
113
116
|
|
|
114
|
-
/** Short wait config for tests
|
|
117
|
+
/** Short wait config for tests: keeps escalation cases off the real budget. */
|
|
115
118
|
const TEST_INLINE_WAIT_CONFIG = { maxWaitMs: 100, intervalMs: 20 };
|
|
116
119
|
|
|
117
120
|
await initializeDb();
|
|
@@ -490,6 +493,42 @@ describe("inline wait-and-resume", () => {
|
|
|
490
493
|
expect(elapsed).toBeLessThan(500);
|
|
491
494
|
});
|
|
492
495
|
|
|
496
|
+
test("waitForInlineGrant spends timeouts.permissionTimeoutSec when no explicit budget is given", async () => {
|
|
497
|
+
// The guardian's escalation window must track the configured approval
|
|
498
|
+
// budget rather than a compiled-in constant, so an operator raising
|
|
499
|
+
// permissionTimeoutSec actually widens the window a guardian has to
|
|
500
|
+
// answer in. Omitting maxWaitMs is what production does.
|
|
501
|
+
const priorTimeouts = getConfig().timeouts;
|
|
502
|
+
setConfig("timeouts", {
|
|
503
|
+
...priorTimeouts,
|
|
504
|
+
permissionTimeoutSec: 0.15,
|
|
505
|
+
});
|
|
506
|
+
try {
|
|
507
|
+
const req = seedGrantRequest("sha256:configbudget");
|
|
508
|
+
|
|
509
|
+
const start = Date.now();
|
|
510
|
+
const result = await waitForInlineGrant(
|
|
511
|
+
req.id,
|
|
512
|
+
{
|
|
513
|
+
toolName: "bash",
|
|
514
|
+
inputDigest: "sha256:configbudget",
|
|
515
|
+
consumingRequestId: "consume-config-budget",
|
|
516
|
+
},
|
|
517
|
+
{ intervalMs: 20 },
|
|
518
|
+
);
|
|
519
|
+
const elapsed = Date.now() - start;
|
|
520
|
+
|
|
521
|
+
expect(result.outcome).toBe("timeout");
|
|
522
|
+
// Bounded well under TC_GRANT_WAIT_MAX_MS: a regression that ignores
|
|
523
|
+
// config and falls back to the constant blows this ceiling by ~400x.
|
|
524
|
+
expect(elapsed).toBeGreaterThanOrEqual(120);
|
|
525
|
+
expect(elapsed).toBeLessThan(3_000);
|
|
526
|
+
expect(elapsed).toBeLessThan(TC_GRANT_WAIT_MAX_MS);
|
|
527
|
+
} finally {
|
|
528
|
+
setConfig("timeouts", priorTimeouts);
|
|
529
|
+
}
|
|
530
|
+
}, 10_000);
|
|
531
|
+
|
|
493
532
|
test("waitForInlineGrant returns aborted when signal fires during wait", async () => {
|
|
494
533
|
const req = seedGrantRequest("sha256:abortwait");
|
|
495
534
|
|
|
@@ -170,6 +170,7 @@ mock.module("../channels/gateway-guardian-requests.js", () => sim.module);
|
|
|
170
170
|
import { applyGuardianDecision } from "../approvals/guardian-decision-primitive.js";
|
|
171
171
|
import type { ActorContext } from "../approvals/guardian-request-resolvers.js";
|
|
172
172
|
import { getResolver } from "../approvals/guardian-request-resolvers.js";
|
|
173
|
+
import { getConfig } from "../config/loader.js";
|
|
173
174
|
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
174
175
|
import { getDb } from "../persistence/db-connection.js";
|
|
175
176
|
import { initializeDb } from "../persistence/db-init.js";
|
|
@@ -177,12 +178,14 @@ import { scopedApprovalGrants } from "../persistence/schema/index.js";
|
|
|
177
178
|
import { bridgeConfirmationRequestToGuardian } from "../runtime/confirmation-request-guardian-bridge.js";
|
|
178
179
|
import { resolveRoutingState } from "../runtime/trust-context-resolver.js";
|
|
179
180
|
import {
|
|
181
|
+
resolveInlineGrantWaitMs,
|
|
180
182
|
TC_GRANT_WAIT_MAX_MS,
|
|
181
183
|
ToolApprovalHandler,
|
|
182
184
|
waitForInlineGrant,
|
|
183
185
|
} from "../tools/tool-approval-handler.js";
|
|
184
186
|
import type { ToolContext } from "../tools/types.js";
|
|
185
187
|
import { seedContactChannel } from "./helpers/seed-contact-channel.js";
|
|
188
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
186
189
|
|
|
187
190
|
await initializeDb();
|
|
188
191
|
|
|
@@ -754,7 +757,10 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
|
|
|
754
757
|
test("inline_wait_active staleness guard: expired marker allows retry notification", async () => {
|
|
755
758
|
// Create a guardian request with a stale inline_wait_active marker
|
|
756
759
|
// that simulates a daemon crash during the wait.
|
|
757
|
-
|
|
760
|
+
// Age the marker past the real wait budget, which the resolver's
|
|
761
|
+
// staleness threshold tracks. Deriving it from the same helper the
|
|
762
|
+
// waiter uses keeps this case meaningful if the budget changes.
|
|
763
|
+
const staleTimestamp = Date.now() - resolveInlineGrantWaitMs() - 60_000;
|
|
758
764
|
const req = sim.seedRequest({
|
|
759
765
|
id: `req-stale-${Date.now()}`,
|
|
760
766
|
kind: "tool_grant_request",
|
|
@@ -802,6 +808,67 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
|
|
|
802
808
|
expect(retryNotifications.length).toBeGreaterThan(0);
|
|
803
809
|
});
|
|
804
810
|
|
|
811
|
+
test("inline_wait_active marker older than the fallback constant but inside the real budget still suppresses retry", async () => {
|
|
812
|
+
// The staleness threshold must track the wait budget, not the fallback
|
|
813
|
+
// constant. A marker aged past TC_GRANT_WAIT_MAX_MS + buffer but still
|
|
814
|
+
// well inside the configured budget belongs to a waiter that is very
|
|
815
|
+
// much alive: telling the requester to retry would race a call that is
|
|
816
|
+
// about to resume, and the retry then fails against the one-time grant
|
|
817
|
+
// the live waiter consumes.
|
|
818
|
+
const priorTimeouts = getConfig().timeouts;
|
|
819
|
+
// Seed the budget rather than leaning on the ambient default, so the
|
|
820
|
+
// window this case probes exists no matter what the suite's config holds.
|
|
821
|
+
setConfig("timeouts", { ...priorTimeouts, permissionTimeoutSec: 300 });
|
|
822
|
+
try {
|
|
823
|
+
const budgetMs = resolveInlineGrantWaitMs();
|
|
824
|
+
// Comfortably past the old 90s threshold, comfortably short of the budget.
|
|
825
|
+
const markerAgeMs = TC_GRANT_WAIT_MAX_MS + 45_000;
|
|
826
|
+
const liveTimestamp = Date.now() - markerAgeMs;
|
|
827
|
+
expect(markerAgeMs).toBeLessThan(budgetMs);
|
|
828
|
+
|
|
829
|
+
const req = sim.seedRequest({
|
|
830
|
+
id: `req-live-${Date.now()}`,
|
|
831
|
+
kind: "tool_grant_request",
|
|
832
|
+
sourceType: "channel",
|
|
833
|
+
sourceChannel: "telegram",
|
|
834
|
+
sourceConversationId: "conv-live-1",
|
|
835
|
+
requesterExternalUserId: "requester-1",
|
|
836
|
+
requesterChatId: "requester-chat-1",
|
|
837
|
+
guardianExternalUserId: "guardian-1",
|
|
838
|
+
guardianPrincipalId: "test-principal-id",
|
|
839
|
+
toolName: "bash",
|
|
840
|
+
inputDigest: "sha256:livewait",
|
|
841
|
+
expiresAt: Date.now() + 60_000,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
await sim.module.updateGuardianRequest(req.id, {
|
|
845
|
+
followupState: `inline_wait_active:${liveTimestamp}`,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
deliveredReplies.length = 0;
|
|
849
|
+
const approvalResult = await applyGuardianDecision({
|
|
850
|
+
requestId: req.id,
|
|
851
|
+
action: "approve_once",
|
|
852
|
+
actorContext: guardianActor(),
|
|
853
|
+
channelDeliveryContext: {
|
|
854
|
+
replyCallbackUrl: "http://localhost:3000/reply",
|
|
855
|
+
guardianChatId: "guardian-chat-1",
|
|
856
|
+
assistantId: "self",
|
|
857
|
+
},
|
|
858
|
+
});
|
|
859
|
+
expect(approvalResult.applied).toBe(true);
|
|
860
|
+
|
|
861
|
+
const retryNotifications = deliveredReplies.filter(
|
|
862
|
+
(r) =>
|
|
863
|
+
typeof r.payload.text === "string" &&
|
|
864
|
+
(r.payload.text as string).includes("Please retry"),
|
|
865
|
+
);
|
|
866
|
+
expect(retryNotifications.length).toBe(0);
|
|
867
|
+
} finally {
|
|
868
|
+
setConfig("timeouts", priorTimeouts);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
805
872
|
test("fresh inline_wait_active marker suppresses retry notification", async () => {
|
|
806
873
|
// Create a request with a FRESH inline_wait_active marker
|
|
807
874
|
const freshTimestamp = Date.now();
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
readBatchMetadata,
|
|
55
55
|
resolvePendingQuestion,
|
|
56
56
|
} from "../runtime/question-resolution.js";
|
|
57
|
-
import {
|
|
57
|
+
import { resolveInlineGrantWaitMs } from "../tools/tool-approval-handler.js";
|
|
58
58
|
import { getLogger } from "../util/logger.js";
|
|
59
59
|
import {
|
|
60
60
|
channelCanAddressOneReaderInBand,
|
|
@@ -1587,7 +1587,10 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
|
|
|
1587
1587
|
// outlive the actual waiter if the daemon crashes or restarts during
|
|
1588
1588
|
// the wait. To avoid permanently suppressing the retry notification, we
|
|
1589
1589
|
// treat the marker as stale if the encoded start timestamp is older than
|
|
1590
|
-
// the maximum wait budget plus a 30s buffer.
|
|
1590
|
+
// the maximum wait budget plus a 30s buffer. The budget is read from the
|
|
1591
|
+
// same resolver the waiter itself uses, so a config change moves both
|
|
1592
|
+
// together; sizing this off a constant would let it fall below the real
|
|
1593
|
+
// wait and declare a live waiter dead.
|
|
1591
1594
|
const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000;
|
|
1592
1595
|
const freshRequest = await getGuardianRequestOrNull(request.id);
|
|
1593
1596
|
const followupState = freshRequest?.followupState ?? "";
|
|
@@ -1604,7 +1607,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
|
|
|
1604
1607
|
? Date.now() - waitStartMs
|
|
1605
1608
|
: Infinity; // Treat unparseable timestamps as stale for safety.
|
|
1606
1609
|
const stalenessThresholdMs =
|
|
1607
|
-
|
|
1610
|
+
resolveInlineGrantWaitMs() + INLINE_WAIT_STALENESS_BUFFER_MS;
|
|
1608
1611
|
if (markerAgeMs > stalenessThresholdMs) {
|
|
1609
1612
|
log.warn(
|
|
1610
1613
|
{
|
|
@@ -26,7 +26,7 @@ export const TimeoutConfigSchema = z
|
|
|
26
26
|
.positive("timeouts.permissionTimeoutSec must be a positive number")
|
|
27
27
|
.default(300)
|
|
28
28
|
.describe(
|
|
29
|
-
"How long to wait for
|
|
29
|
+
"How long to wait for a human to approve a tool call before timing out (seconds). Spent by both approval paths: the interactive permission prompt, and the inline wait when a contact's sensitive tool call is escalated to the guardian.",
|
|
30
30
|
),
|
|
31
31
|
questionResponseTimeoutSec: z
|
|
32
32
|
.number({ error: "timeouts.questionResponseTimeoutSec must be a number" })
|
|
@@ -366,6 +366,13 @@ export async function runAgentLoopImpl(
|
|
|
366
366
|
* scheduled execute turn attributes its LLM spend to that firing.
|
|
367
367
|
*/
|
|
368
368
|
cronRunId?: string | null;
|
|
369
|
+
/**
|
|
370
|
+
* Trust this turn runs under. Queue drains pass the trust captured from
|
|
371
|
+
* the sender at enqueue; without it the initialization below would reset
|
|
372
|
+
* the turn to the conversation slot, which holds whichever actor sent
|
|
373
|
+
* most recently rather than the one this turn belongs to.
|
|
374
|
+
*/
|
|
375
|
+
turnTrustContext?: TrustContext;
|
|
369
376
|
},
|
|
370
377
|
): Promise<void> {
|
|
371
378
|
if (!ctx.abortController) {
|
|
@@ -376,9 +383,21 @@ export async function runAgentLoopImpl(
|
|
|
376
383
|
// voice-session-bridge, regenerate, etc.) that invoke runAgentLoop directly
|
|
377
384
|
// without going through processMessage/drainQueue. This ensures the system
|
|
378
385
|
// prompt callback always reads a valid snapshot rather than undefined.
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
386
|
+
//
|
|
387
|
+
// The invariant: a turn runs under the trust captured when that turn
|
|
388
|
+
// started, never under whatever the slot holds when the loop happens to
|
|
389
|
+
// open. Awaits sit between capture and this line, and the slot is writable
|
|
390
|
+
// throughout by paths that do not own the turn (channel ingress for another
|
|
391
|
+
// actor, live-voice hydration, pointer elevation, the voice bridge).
|
|
392
|
+
//
|
|
393
|
+
// Callers that own a turn are expected to capture at turn start and pass
|
|
394
|
+
// `turnTrustContext`. This has not been swept across all 13 call sites, so
|
|
395
|
+
// the fallback below is load-bearing for two different populations: callers
|
|
396
|
+
// that legitimately have no turn to capture (subagent manager, voice
|
|
397
|
+
// bridge, regenerate), and callers that should pass it and do not yet.
|
|
398
|
+
// Treat a caller reaching the fallback as unverified, not as correct.
|
|
399
|
+
// LUM-3148 removes the ambiguity by making trust ride the turn.
|
|
400
|
+
ctx.currentTurnTrustContext = options?.turnTrustContext ?? ctx.trustContext;
|
|
382
401
|
ctx.currentTurnChannelCapabilities = ctx.channelCapabilities;
|
|
383
402
|
|
|
384
403
|
// Re-resolve the system prompt under the snapshots just set and push it into
|
|
@@ -565,6 +565,12 @@ export interface EnqueueMessageOptions {
|
|
|
565
565
|
sourceActorPrincipalId?: string;
|
|
566
566
|
/** Auth context snapshot captured for queued turn-scoped authorization. */
|
|
567
567
|
authContext?: AuthContext;
|
|
568
|
+
/**
|
|
569
|
+
* Sender's trust, for the drain to run this message under. Defaults to the
|
|
570
|
+
* conversation's trust at enqueue time, which the sending route has just
|
|
571
|
+
* set to this sender.
|
|
572
|
+
*/
|
|
573
|
+
trustContext?: TrustContext;
|
|
568
574
|
}
|
|
569
575
|
|
|
570
576
|
// ── enqueueMessage ───────────────────────────────────────────────────
|
|
@@ -593,6 +599,9 @@ export function enqueueMessage(
|
|
|
593
599
|
options.sourceActorPrincipalId ??
|
|
594
600
|
ctx.currentTurnSourceActorPrincipalId ??
|
|
595
601
|
queuedAuthContext?.actorPrincipalId;
|
|
602
|
+
// Deliberately not falling back to `currentTurnTrustContext`: that is the
|
|
603
|
+
// in-flight turn's actor, which is precisely who this message is not from.
|
|
604
|
+
const queuedTrustContext = options.trustContext ?? ctx.trustContext;
|
|
596
605
|
|
|
597
606
|
if (!ctx.isProcessing()) {
|
|
598
607
|
return { queued: false, requestId };
|
|
@@ -619,6 +628,7 @@ export function enqueueMessage(
|
|
|
619
628
|
isInteractive,
|
|
620
629
|
sourceActorPrincipalId,
|
|
621
630
|
authContext: queuedAuthContext,
|
|
631
|
+
trustContext: queuedTrustContext,
|
|
622
632
|
transport,
|
|
623
633
|
displayContent,
|
|
624
634
|
sentAt: Date.now(),
|
|
@@ -668,6 +678,14 @@ export interface PersistMessageOptions {
|
|
|
668
678
|
metadata?: Record<string, unknown>;
|
|
669
679
|
displayContent?: string;
|
|
670
680
|
clientMessageId?: string;
|
|
681
|
+
/**
|
|
682
|
+
* Trust to attribute the stored row to. Queue drains pass the sender's
|
|
683
|
+
* captured trust so persisted provenance names the same actor the turn
|
|
684
|
+
* executes as; the conversation slot may by then hold someone else.
|
|
685
|
+
* Defaults to the conversation's trust, which is correct for callers
|
|
686
|
+
* persisting a message the current actor just sent.
|
|
687
|
+
*/
|
|
688
|
+
trustContext?: TrustContext;
|
|
671
689
|
/**
|
|
672
690
|
* Persist the row without indexing it (no memory segments, embeddings, or
|
|
673
691
|
* lexical-index entry). For machine-authored prompts that must not enter
|
|
@@ -844,7 +862,9 @@ export async function persistQueuedMessageBody(
|
|
|
844
862
|
extractTurnChannelContext(metadata) ?? ctx.getTurnChannelContext();
|
|
845
863
|
const turnIfCtx =
|
|
846
864
|
extractTurnInterfaceContext(metadata) ?? ctx.getTurnInterfaceContext();
|
|
847
|
-
const provenance = provenanceFromTrustContext(
|
|
865
|
+
const provenance = provenanceFromTrustContext(
|
|
866
|
+
options.trustContext ?? ctx.trustContext,
|
|
867
|
+
);
|
|
848
868
|
const imageSourcePaths = extractImageSourcePaths(attachments);
|
|
849
869
|
|
|
850
870
|
// Strip the transient `slackInbound` carrier key from the persisted
|
|
@@ -62,6 +62,7 @@ import { getModelInfo } from "./handlers/config-model.js";
|
|
|
62
62
|
import { preactivateHostProxySkills } from "./host-proxy-preactivation.js";
|
|
63
63
|
import type { UserMessageAttachment } from "./message-protocol.js";
|
|
64
64
|
import { buildTransportHints } from "./transport-hints.js";
|
|
65
|
+
import { sameTrustIdentity, type TrustContext } from "./trust-context-types.js";
|
|
65
66
|
import { resolveVerificationSessionIntent } from "./verification-session-intent.js";
|
|
66
67
|
|
|
67
68
|
const log = getLogger("conversation-process");
|
|
@@ -301,6 +302,13 @@ async function buildPassthroughBatch(
|
|
|
301
302
|
if (candidate.sourceActorPrincipalId !== head.sourceActorPrincipalId) {
|
|
302
303
|
break;
|
|
303
304
|
}
|
|
305
|
+
// Channel senders carry no principal, so the check above leaves two
|
|
306
|
+
// different Slack contacts looking identical (`undefined === undefined`).
|
|
307
|
+
// The batch runs under a single trust context, so split on the sender's
|
|
308
|
+
// trust identity too or a tail executes with the head's privileges.
|
|
309
|
+
if (!sameTrustIdentity(candidate.trustContext, head.trustContext)) {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
304
312
|
if (classifySlash(candidate.content) !== "passthrough") {
|
|
305
313
|
break;
|
|
306
314
|
}
|
|
@@ -795,7 +803,11 @@ async function drainSingleMessage(
|
|
|
795
803
|
|
|
796
804
|
// Snapshot persona context at turn start so later tool turns can't pick up
|
|
797
805
|
// a different actor's context if a concurrent request mutates the live fields.
|
|
798
|
-
|
|
806
|
+
// Trust comes from the queued message, not the live slot: the slot holds
|
|
807
|
+
// whichever actor sent most recently, which is this sender only when nobody
|
|
808
|
+
// else sent while this message waited.
|
|
809
|
+
conversation.currentTurnTrustContext =
|
|
810
|
+
next.trustContext ?? conversation.trustContext;
|
|
799
811
|
conversation.currentTurnChannelCapabilities =
|
|
800
812
|
conversation.channelCapabilities;
|
|
801
813
|
|
|
@@ -1135,6 +1147,9 @@ async function drainSingleMessage(
|
|
|
1135
1147
|
metadata: { ...next.metadata, sentAt: next.sentAt },
|
|
1136
1148
|
displayContent: next.displayContent,
|
|
1137
1149
|
clientMessageId: next.clientMessageId,
|
|
1150
|
+
// Attribute the stored row to the sender this turn runs as, not to
|
|
1151
|
+
// whoever happens to occupy the conversation slot at drain time.
|
|
1152
|
+
trustContext: next.trustContext,
|
|
1138
1153
|
...(next.transport?.clientOs
|
|
1139
1154
|
? { requestClientOs: next.transport.clientOs }
|
|
1140
1155
|
: {}),
|
|
@@ -1258,7 +1273,14 @@ async function drainSingleMessage(
|
|
|
1258
1273
|
isUserMessage?: boolean;
|
|
1259
1274
|
titleText?: string;
|
|
1260
1275
|
isHiddenPrompt?: boolean;
|
|
1261
|
-
|
|
1276
|
+
turnTrustContext?: TrustContext;
|
|
1277
|
+
} = {
|
|
1278
|
+
isUserMessage: true,
|
|
1279
|
+
// Carry the sender's trust into the run. The loop re-initializes the
|
|
1280
|
+
// per-turn snapshot on entry, so without this the stamp above is undone
|
|
1281
|
+
// and the turn reverts to the conversation's most recent actor.
|
|
1282
|
+
turnTrustContext: conversation.currentTurnTrustContext,
|
|
1283
|
+
};
|
|
1262
1284
|
if (next.isInteractive !== undefined) {
|
|
1263
1285
|
drainLoopOptions.isInteractive = next.isInteractive;
|
|
1264
1286
|
}
|
|
@@ -1383,7 +1405,12 @@ async function drainBatch(
|
|
|
1383
1405
|
|
|
1384
1406
|
// Snapshot persona context at turn start so later tool turns can't pick up
|
|
1385
1407
|
// a different actor's context if a concurrent request mutates the live fields.
|
|
1386
|
-
|
|
1408
|
+
// The head's trust governs the batch, which is sound only because
|
|
1409
|
+
// `buildPassthroughBatch` refuses to coalesce messages from different
|
|
1410
|
+
// actors; without that boundary this would run a tail under the head's
|
|
1411
|
+
// trust.
|
|
1412
|
+
conversation.currentTurnTrustContext =
|
|
1413
|
+
head.trustContext ?? conversation.trustContext;
|
|
1387
1414
|
conversation.currentTurnChannelCapabilities =
|
|
1388
1415
|
conversation.channelCapabilities;
|
|
1389
1416
|
|
|
@@ -1488,6 +1515,9 @@ async function drainBatch(
|
|
|
1488
1515
|
metadata: { ...qm.metadata, sentAt: qm.sentAt },
|
|
1489
1516
|
displayContent: qm.displayContent,
|
|
1490
1517
|
clientMessageId: qm.clientMessageId,
|
|
1518
|
+
// Same attribution rule as the single-message drain. Batch members
|
|
1519
|
+
// share one sender, so every row here names that sender.
|
|
1520
|
+
trustContext: qm.trustContext,
|
|
1491
1521
|
...(qm.transport?.clientOs
|
|
1492
1522
|
? { requestClientOs: qm.transport.clientOs }
|
|
1493
1523
|
: {}),
|
|
@@ -1719,8 +1749,12 @@ async function drainBatch(
|
|
|
1719
1749
|
titleText?: string;
|
|
1720
1750
|
isHiddenPrompt?: boolean;
|
|
1721
1751
|
notifyUserMessageId?: string;
|
|
1752
|
+
turnTrustContext?: TrustContext;
|
|
1722
1753
|
} = {
|
|
1723
1754
|
isUserMessage: true,
|
|
1755
|
+
// Same reason as the single-message drain: the loop re-initializes the
|
|
1756
|
+
// per-turn snapshot, so the head's trust has to travel with the call.
|
|
1757
|
+
turnTrustContext: conversation.currentTurnTrustContext,
|
|
1724
1758
|
};
|
|
1725
1759
|
if (lastPushEligibleUserMessageId !== undefined) {
|
|
1726
1760
|
drainLoopOptions.notifyUserMessageId = lastPushEligibleUserMessageId;
|
|
@@ -1842,7 +1876,14 @@ export async function processMessage(
|
|
|
1842
1876
|
await conversation.ensureActorScopedHistory();
|
|
1843
1877
|
// Snapshot persona context at turn start so later tool turns can't pick up
|
|
1844
1878
|
// a different actor's context if a concurrent request mutates the live fields.
|
|
1845
|
-
|
|
1879
|
+
//
|
|
1880
|
+
// Held in a local as well as on the conversation: the field is writable
|
|
1881
|
+
// out-of-band while this turn is in flight (`agent-wake` stamps it and
|
|
1882
|
+
// restores the prior value in a `finally`), so reading it back at the agent
|
|
1883
|
+
// loop call below would reintroduce the late read this capture exists to
|
|
1884
|
+
// avoid. The local is what the loop runs under.
|
|
1885
|
+
const turnTrustContext = conversation.trustContext;
|
|
1886
|
+
conversation.currentTurnTrustContext = turnTrustContext;
|
|
1846
1887
|
conversation.currentTurnAuthContext = conversation.authContext;
|
|
1847
1888
|
conversation.currentTurnSourceActorPrincipalId =
|
|
1848
1889
|
sourceActorPrincipalId ?? conversation.authContext?.actorPrincipalId;
|
|
@@ -2311,7 +2352,16 @@ export async function processMessage(
|
|
|
2311
2352
|
titleText?: string;
|
|
2312
2353
|
callSite?: LLMCallSite;
|
|
2313
2354
|
overrideProfile?: string;
|
|
2314
|
-
|
|
2355
|
+
turnTrustContext?: TrustContext;
|
|
2356
|
+
} = {
|
|
2357
|
+
isUserMessage: true,
|
|
2358
|
+
// Carry the trust captured at turn start into the run. Several awaits sit
|
|
2359
|
+
// between that capture and the loop opening, and both the conversation
|
|
2360
|
+
// slot and the per-turn field are writable throughout that window, so
|
|
2361
|
+
// reading either here would run this turn as whoever wrote last. The
|
|
2362
|
+
// local captured at turn start is the only value no other writer can move.
|
|
2363
|
+
turnTrustContext,
|
|
2364
|
+
};
|
|
2315
2365
|
if (isInteractive !== undefined) {
|
|
2316
2366
|
loopOptions.isInteractive = isInteractive;
|
|
2317
2367
|
}
|
|
@@ -14,6 +14,7 @@ import type { AuthContext } from "../runtime/auth/types.js";
|
|
|
14
14
|
import { getLogger } from "../util/logger.js";
|
|
15
15
|
import type { UserMessageAttachment } from "./message-protocol.js";
|
|
16
16
|
import type { ConversationTransportMetadata } from "./message-types/conversations.js";
|
|
17
|
+
import type { TrustContext } from "./trust-context-types.js";
|
|
17
18
|
|
|
18
19
|
const log = getLogger("conversation-queue");
|
|
19
20
|
|
|
@@ -33,6 +34,15 @@ export interface QueuedMessage {
|
|
|
33
34
|
sourceActorPrincipalId?: string;
|
|
34
35
|
/** Full auth snapshot captured at enqueue time for turn-scoped authorization decisions. */
|
|
35
36
|
authContext?: AuthContext;
|
|
37
|
+
/**
|
|
38
|
+
* Sender's trust captured at enqueue time. The conversation-level
|
|
39
|
+
* `trustContext` is a single mutable slot holding whichever actor last sent
|
|
40
|
+
* a message, so a drain that read it would run this message under a
|
|
41
|
+
* different actor's trust whenever another actor sent in between. Trust
|
|
42
|
+
* governs `trustClass`, `executionChannel`, and `requesterExternalUserId`,
|
|
43
|
+
* so reading the wrong one misroutes the whole tool-approval path.
|
|
44
|
+
*/
|
|
45
|
+
trustContext?: TrustContext;
|
|
36
46
|
/** Transport metadata snapshot captured at enqueue time, applied when this message becomes active. */
|
|
37
47
|
transport?: ConversationTransportMetadata;
|
|
38
48
|
/** Original user message text to persist to DB when recording intent stripping produced a different `content`. */
|
|
@@ -2661,6 +2661,12 @@ export class Conversation {
|
|
|
2661
2661
|
* forwarded into {@link runAgentLoopImpl} and threaded to `recordUsage`.
|
|
2662
2662
|
*/
|
|
2663
2663
|
cronRunId?: string | null;
|
|
2664
|
+
/**
|
|
2665
|
+
* See {@link runAgentLoopImpl}: trust this turn runs under. Queue
|
|
2666
|
+
* drains pass the sender's trust captured at enqueue so the run is not
|
|
2667
|
+
* reset to the conversation's most recent actor.
|
|
2668
|
+
*/
|
|
2669
|
+
turnTrustContext?: TrustContext;
|
|
2664
2670
|
},
|
|
2665
2671
|
): Promise<void> {
|
|
2666
2672
|
const { onEvent, ...rest } = options ?? {};
|
|
@@ -67,3 +67,42 @@ export interface TrustContext {
|
|
|
67
67
|
*/
|
|
68
68
|
requesterInteractionCount?: number;
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether two trust contexts describe the same acting identity at the same
|
|
73
|
+
* privilege, for callers that may only run work under one of them (batched
|
|
74
|
+
* turns being the case that matters).
|
|
75
|
+
*
|
|
76
|
+
* Compares the privilege (`trustClass`), the channel a grant is scoped to
|
|
77
|
+
* (`sourceChannel`), and every field that can carry who the actor is. The
|
|
78
|
+
* identity fields are covered exhaustively rather than by picking the usual
|
|
79
|
+
* ones: an ingress that populates only `requesterIdentifier` or
|
|
80
|
+
* `requesterContactId` would otherwise leave two distinct senders comparing
|
|
81
|
+
* equal on a pair of undefineds, which is the exact case this guards.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately conservative: an absent field never matches a present one, so
|
|
84
|
+
* unknown identities are treated as distinct. Answering "different" when they
|
|
85
|
+
* match only costs a batching opportunity; answering "same" when they differ
|
|
86
|
+
* runs one actor's work under another's privileges.
|
|
87
|
+
*/
|
|
88
|
+
export function sameTrustIdentity(
|
|
89
|
+
a: TrustContext | undefined,
|
|
90
|
+
b: TrustContext | undefined,
|
|
91
|
+
): boolean {
|
|
92
|
+
if (a === b) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (!a || !b) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
a.trustClass === b.trustClass &&
|
|
100
|
+
a.sourceChannel === b.sourceChannel &&
|
|
101
|
+
a.requesterExternalUserId === b.requesterExternalUserId &&
|
|
102
|
+
a.requesterChatId === b.requesterChatId &&
|
|
103
|
+
a.requesterIdentifier === b.requesterIdentifier &&
|
|
104
|
+
a.requesterContactId === b.requesterContactId &&
|
|
105
|
+
a.guardianExternalUserId === b.guardianExternalUserId &&
|
|
106
|
+
a.guardianPrincipalId === b.guardianPrincipalId
|
|
107
|
+
);
|
|
108
|
+
}
|
package/src/oauth/AGENTS.md
CHANGED
|
@@ -35,9 +35,11 @@ Managed-sign-in users should get the integration pre-enabled by setting `service
|
|
|
35
35
|
|
|
36
36
|
### 4. Set the logo URL — `seed-providers.ts`
|
|
37
37
|
|
|
38
|
-
The `logoUrl` field in `seed-providers.ts` is the source of truth for a provider's logo. Most providers use a [Simple Icons](https://simpleicons.org) (CC0-licensed) CDN URL like `https://cdn.simpleicons.org/acme`.
|
|
38
|
+
The `logoUrl` field in `seed-providers.ts` is the source of truth for a provider's logo. Most providers use a [Simple Icons](https://simpleicons.org) (CC0-licensed) CDN URL like `https://cdn.simpleicons.org/acme`.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
**Verify the URL actually resolves before you commit it.** `oauth-provider-seed-logos.test.ts` only checks the prefix, so a slug that Simple Icons has never hosted (or has since dropped) passes CI and then renders as an initials avatar in the client. Simple Icons removes brands on trademark request: Salesforce is gone, every Microsoft product went in v13, and Slack is currently absent pending permission from the trademark owner. For those, use the `glincker/thesvg` source via jsDelivr: `https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/<key>/default.svg`. The recognised `logoUrl` prefixes are enforced by `oauth-provider-seed-logos.test.ts`; if you need a third source, extend that allowlist.
|
|
41
|
+
|
|
42
|
+
`logoUrl` is a fallback, not the last word. The web client prefers a logo bundled in `clients/web/public/images/integrations/` when one exists for the provider key, uses `logoUrl` when it doesn't, and only then falls back to an initials avatar (see `BUNDLED_LOGO_URLS` in `clients/web/src/components/integrations/integration-icon.tsx`). Bundling an asset for a new first-class provider is optional but preferred: it survives a CDN removal, works offline, and keeps the integrations list from telling a third party which providers a user is looking at.
|
|
41
43
|
|
|
42
44
|
### 5. Secret patterns (if applicable) — `packages/service-contracts/src/secret-detection.ts`
|
|
43
45
|
|
|
@@ -167,7 +167,8 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
167
167
|
description: "Workspace messaging",
|
|
168
168
|
dashboardUrl: "https://api.slack.com/apps",
|
|
169
169
|
clientIdPlaceholder: null,
|
|
170
|
-
logoUrl:
|
|
170
|
+
logoUrl:
|
|
171
|
+
"https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/slack/default.svg",
|
|
171
172
|
defaultScopes: [
|
|
172
173
|
"channels:join",
|
|
173
174
|
"channels:read",
|
|
@@ -710,7 +711,8 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
710
711
|
dashboardUrl:
|
|
711
712
|
"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
|
|
712
713
|
clientIdPlaceholder: "Application (client) ID from Azure portal",
|
|
713
|
-
logoUrl:
|
|
714
|
+
logoUrl:
|
|
715
|
+
"https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/microsoft-outlook/default.svg",
|
|
714
716
|
defaultScopes: [
|
|
715
717
|
"openid",
|
|
716
718
|
"profile",
|
|
@@ -764,7 +766,8 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
764
766
|
dashboardUrl: null,
|
|
765
767
|
clientIdPlaceholder: null,
|
|
766
768
|
requiresClientSecret: false,
|
|
767
|
-
logoUrl:
|
|
769
|
+
logoUrl:
|
|
770
|
+
"https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/slack/default.svg",
|
|
768
771
|
defaultScopes: [],
|
|
769
772
|
injectionTemplates: [
|
|
770
773
|
{
|
|
@@ -1915,6 +1915,15 @@ export async function handleSendMessage(
|
|
|
1915
1915
|
conversation.setTrustContext({ trustClass: "guardian", sourceChannel });
|
|
1916
1916
|
}
|
|
1917
1917
|
|
|
1918
|
+
// The trust this request's turn runs under, captured here rather than read
|
|
1919
|
+
// back at the agent loop below. Every branch above has just written it and
|
|
1920
|
+
// nothing awaits in between, so this is the resolved sender. Between here
|
|
1921
|
+
// and the loop the conversation slot is writable by paths that do not own
|
|
1922
|
+
// this turn (channel ingress for another actor, live-voice hydration,
|
|
1923
|
+
// pointer elevation, the voice bridge), and the loop would otherwise
|
|
1924
|
+
// re-read it and run this turn as whoever wrote last.
|
|
1925
|
+
const turnTrustContext = conversation.trustContext;
|
|
1926
|
+
|
|
1918
1927
|
const isInteractive = isInteractiveInterface(sourceInterface);
|
|
1919
1928
|
// Translate the dev-bypass actor principal to the real guardian principal
|
|
1920
1929
|
// before the same-actor host-proxy gate so web/iOS turns match the macOS
|
|
@@ -2691,6 +2700,7 @@ export async function handleSendMessage(
|
|
|
2691
2700
|
onEvent: broadcastMessage,
|
|
2692
2701
|
isInteractive,
|
|
2693
2702
|
isUserMessage: true,
|
|
2703
|
+
turnTrustContext,
|
|
2694
2704
|
...(body.hidden === true ? { isHiddenPrompt: true } : {}),
|
|
2695
2705
|
})
|
|
2696
2706
|
.catch((err) => {
|
package/src/subagent/manager.ts
CHANGED
|
@@ -701,10 +701,17 @@ export class SubagentManager {
|
|
|
701
701
|
// wins over parent inheritance: a parent that stamps trust per-turn (the
|
|
702
702
|
// live-voice bridge) has already cleared it by the time a detached spawn
|
|
703
703
|
// reads it, so its spawner resolves trust itself.
|
|
704
|
+
//
|
|
705
|
+
// Inherit the parent's *turn* trust ahead of its conversation-level slot:
|
|
706
|
+
// the slot holds whichever actor sent most recently, so a spawn during one
|
|
707
|
+
// actor's turn would otherwise run under another's privileges.
|
|
708
|
+
const parentTurnTrust =
|
|
709
|
+
parentConversation?.currentTurnTrustContext ??
|
|
710
|
+
parentConversation?.trustContext;
|
|
704
711
|
if (config.trustContext) {
|
|
705
712
|
conversation.setTrustContext({ ...config.trustContext });
|
|
706
|
-
} else if (
|
|
707
|
-
conversation.setTrustContext({ ...
|
|
713
|
+
} else if (parentTurnTrust) {
|
|
714
|
+
conversation.setTrustContext({ ...parentTurnTrust });
|
|
708
715
|
}
|
|
709
716
|
const parentAuthContext = parentConversation?.getAuthContext();
|
|
710
717
|
if (parentAuthContext) {
|
|
@@ -6,11 +6,18 @@ const TIMEOUT_SENTINEL = Symbol("tool-timeout");
|
|
|
6
6
|
/**
|
|
7
7
|
* Convert a config-provided seconds value to a safe milliseconds value,
|
|
8
8
|
* falling back to the default if the input is NaN, non-finite, zero, or negative.
|
|
9
|
+
*
|
|
10
|
+
* `fallbackMs` lets callers governed by a different budget (e.g. the inline
|
|
11
|
+
* grant wait) keep their own floor instead of inheriting the tool-execution
|
|
12
|
+
* default.
|
|
9
13
|
*/
|
|
10
|
-
export function safeTimeoutMs(
|
|
14
|
+
export function safeTimeoutMs(
|
|
15
|
+
sec: unknown,
|
|
16
|
+
fallbackMs: number = DEFAULT_TOOL_EXECUTION_TIMEOUT_SEC * 1000,
|
|
17
|
+
): number {
|
|
11
18
|
const n = Number(sec);
|
|
12
19
|
if (!Number.isFinite(n) || n <= 0) {
|
|
13
|
-
return
|
|
20
|
+
return fallbackMs;
|
|
14
21
|
}
|
|
15
22
|
return n * 1000;
|
|
16
23
|
}
|
|
@@ -39,6 +39,7 @@ import { computeToolApprovalDigest } from "../security/tool-approval-digest.js";
|
|
|
39
39
|
import { recordToolDenied, recordToolError } from "../telemetry/tool-audit.js";
|
|
40
40
|
import { getLogger } from "../util/logger.js";
|
|
41
41
|
import { resolveExecutionTarget } from "./execution-target.js";
|
|
42
|
+
import { safeTimeoutMs } from "./execution-timeout.js";
|
|
42
43
|
import { channelCoordinatesFromToolContext } from "./policy-context.js";
|
|
43
44
|
import { getAllTools, getTool, getToolOwner } from "./registry.js";
|
|
44
45
|
import { isSideEffectTool } from "./side-effects.js";
|
|
@@ -127,9 +128,39 @@ export function buildInactiveToolMessage(args: {
|
|
|
127
128
|
|
|
128
129
|
/** Default polling interval for inline grant wait (ms). */
|
|
129
130
|
const TC_GRANT_WAIT_INTERVAL_MS = 500;
|
|
130
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Fallback maximum wait for the inline grant wait (ms), used only when the
|
|
133
|
+
* deployed config cannot be read. The governing budget is
|
|
134
|
+
* `timeouts.permissionTimeoutSec` (see {@link resolveInlineGrantWaitMs}).
|
|
135
|
+
*/
|
|
131
136
|
export const TC_GRANT_WAIT_MAX_MS = 60_000;
|
|
132
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the wait budget for an escalated tool grant.
|
|
140
|
+
*
|
|
141
|
+
* A guardian answering an escalated tool call is making the same decision as a
|
|
142
|
+
* local user answering a permission prompt, so both paths spend the same
|
|
143
|
+
* budget: `timeouts.permissionTimeoutSec` (read by `permissions/prompter.ts`).
|
|
144
|
+
* If anything, this path needs the larger share of it: the prompter's user
|
|
145
|
+
* already has the prompt on screen, while the guardian is notified
|
|
146
|
+
* out-of-band and has to context-switch before deciding.
|
|
147
|
+
*
|
|
148
|
+
* Falls back to {@link TC_GRANT_WAIT_MAX_MS} rather than the tool-execution
|
|
149
|
+
* default on a non-positive value, so a bad config can never collapse the
|
|
150
|
+
* window to zero and auto-deny every escalation.
|
|
151
|
+
*
|
|
152
|
+
* Exported because the grant resolver sizes its `inline_wait_active` staleness
|
|
153
|
+
* threshold off this same budget: if the two drift, an approval arriving while
|
|
154
|
+
* a waiter is still live gets misread as a dead waiter and the requester is
|
|
155
|
+
* told to retry a call that is about to resume on its own.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveInlineGrantWaitMs(): number {
|
|
158
|
+
return safeTimeoutMs(
|
|
159
|
+
getConfig().timeouts.permissionTimeoutSec,
|
|
160
|
+
TC_GRANT_WAIT_MAX_MS,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
133
164
|
/**
|
|
134
165
|
* Inline wait result for trusted-contact grant polling.
|
|
135
166
|
* - `granted`: a grant was minted and consumed within the wait window.
|
|
@@ -152,13 +183,16 @@ export type InlineGrantWaitOutcome =
|
|
|
152
183
|
* and atomically consume the grant).
|
|
153
184
|
*
|
|
154
185
|
* Only called for trusted_contact actors with valid guardian bindings.
|
|
186
|
+
*
|
|
187
|
+
* `options.maxWaitMs` overrides the wait budget; omitting it spends the
|
|
188
|
+
* configured one from {@link resolveInlineGrantWaitMs}.
|
|
155
189
|
*/
|
|
156
190
|
export async function waitForInlineGrant(
|
|
157
191
|
escalationRequestId: string,
|
|
158
192
|
consumeParams: Parameters<typeof consumeGrantForInvocation>[0],
|
|
159
193
|
options?: { maxWaitMs?: number; intervalMs?: number; signal?: AbortSignal },
|
|
160
194
|
): Promise<InlineGrantWaitOutcome> {
|
|
161
|
-
const maxWait = options?.maxWaitMs ??
|
|
195
|
+
const maxWait = options?.maxWaitMs ?? resolveInlineGrantWaitMs();
|
|
162
196
|
const interval = options?.intervalMs ?? TC_GRANT_WAIT_INTERVAL_MS;
|
|
163
197
|
const signal = options?.signal;
|
|
164
198
|
const deadline = Date.now() + maxWait;
|
|
@@ -592,9 +626,16 @@ export type PreExecutionGateResult =
|
|
|
592
626
|
}
|
|
593
627
|
| { allowed: false; result: ToolExecutionResult };
|
|
594
628
|
|
|
595
|
-
/**
|
|
629
|
+
/**
|
|
630
|
+
* Overrides for the inline grant wait behavior. Production leaves this empty
|
|
631
|
+
* so the wait spends the configured budget; tests inject short waits to keep
|
|
632
|
+
* escalation cases fast.
|
|
633
|
+
*/
|
|
596
634
|
export interface InlineGrantWaitConfig {
|
|
597
|
-
/**
|
|
635
|
+
/**
|
|
636
|
+
* Maximum time to wait for guardian approval (ms). Defaults to the budget
|
|
637
|
+
* from {@link resolveInlineGrantWaitMs}.
|
|
638
|
+
*/
|
|
598
639
|
maxWaitMs?: number;
|
|
599
640
|
/** Polling interval during the wait (ms). Defaults to TC_GRANT_WAIT_INTERVAL_MS. */
|
|
600
641
|
intervalMs?: number;
|