@stigmer/runner 3.5.3 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/discover-mcp-server.js +9 -1
  3. package/dist/activities/discover-mcp-server.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.d.ts +5 -0
  5. package/dist/activities/execute-cursor/index.js +49 -9
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -0
  8. package/dist/activities/execute-cursor/prompt-builder.js +9 -0
  9. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  10. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +6 -0
  11. package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
  12. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/setup.js +45 -9
  14. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  15. package/dist/client/stigmer-client.d.ts +26 -0
  16. package/dist/client/stigmer-client.js +37 -0
  17. package/dist/client/stigmer-client.js.map +1 -1
  18. package/dist/shared/caller-identity.d.ts +89 -0
  19. package/dist/shared/caller-identity.js +124 -0
  20. package/dist/shared/caller-identity.js.map +1 -0
  21. package/dist/shared/channel-attachment.d.ts +85 -0
  22. package/dist/shared/channel-attachment.js +203 -0
  23. package/dist/shared/channel-attachment.js.map +1 -0
  24. package/dist/shared/datastore-attachment.d.ts +2 -25
  25. package/dist/shared/datastore-attachment.js +1 -28
  26. package/dist/shared/datastore-attachment.js.map +1 -1
  27. package/dist/shared/synthesized-attachment.d.ts +51 -0
  28. package/dist/shared/synthesized-attachment.js +45 -0
  29. package/dist/shared/synthesized-attachment.js.map +1 -0
  30. package/package.json +2 -2
  31. package/src/__test-utils__/mock-client.ts +4 -0
  32. package/src/activities/__tests__/discover-mcp-server.test.ts +49 -0
  33. package/src/activities/discover-mcp-server.ts +13 -1
  34. package/src/activities/execute-cursor/index.ts +72 -13
  35. package/src/activities/execute-cursor/prompt-builder.ts +19 -0
  36. package/src/activities/execute-deep-agent/prompt-builder.ts +10 -0
  37. package/src/activities/execute-deep-agent/setup.ts +66 -10
  38. package/src/client/stigmer-client.ts +46 -0
  39. package/src/shared/__tests__/caller-identity.test.ts +159 -0
  40. package/src/shared/__tests__/channel-attachment.test.ts +276 -0
  41. package/src/shared/__tests__/datastore-attachment.test.ts +4 -4
  42. package/src/shared/caller-identity.ts +161 -0
  43. package/src/shared/channel-attachment.ts +237 -0
  44. package/src/shared/datastore-attachment.ts +2 -54
  45. package/src/shared/synthesized-attachment.ts +77 -0
@@ -49,6 +49,10 @@ import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "..
49
49
  import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
50
50
  import { readContextBridge } from "../../shared/context-bridge.js";
51
51
  import { readSenderIdentity } from "../../shared/sender-identity.js";
52
+ import {
53
+ injectCallerIdentityEnv,
54
+ resolveCallerIdentity,
55
+ } from "../../shared/caller-identity.js";
52
56
  import { readSessionContext } from "../../shared/session-context.js";
53
57
  import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
54
58
  import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
@@ -60,10 +64,12 @@ import { StreamingUpdateScheduler, loadStreamingConfig } from "../../shared/stre
60
64
  import { createCursorEventRecorder } from "./cursor-event-recorder.js";
61
65
  import { resolveMcpServers, toCursorMcpConfig, validateMcpServerEnv } from "./mcp-resolver.js";
62
66
  import { resolveMcpTransportPosture } from "../../shared/mcp-transport-guard.js";
67
+ import { synthesizeDatastoreAttachment } from "../../shared/datastore-attachment.js";
63
68
  import {
64
- injectDatastoreAttachment,
65
- synthesizeDatastoreAttachment,
66
- } from "../../shared/datastore-attachment.js";
69
+ discoverChannelMessaging,
70
+ synthesizeChannelAttachment,
71
+ } from "../../shared/channel-attachment.js";
72
+ import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
67
73
  import { mergeApprovalPolicies } from "./approval-policy.js";
68
74
  import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
69
75
  import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
@@ -590,11 +596,23 @@ async function executeCursorInner(
590
596
  }
591
597
  }
592
598
 
593
- // Phase 4: Resolve MCP servers with approval policies
599
+ // Phase 4: Resolve MCP servers with approval policies.
600
+ // The MCP-bound env map (and ONLY it — never the agent process env)
601
+ // carries the reserved caller-identity keys, so a server that declares
602
+ // them in spec.env can template the platform-verified caller into its
603
+ // headers. filterEnvToDeclaredKeys keeps every other server blind.
594
604
  await reportSetupProgress(client, executionId, "Resolving MCP servers");
595
605
  const transportPosture = resolveMcpTransportPosture(config.mode);
606
+ const mcpEnvVars = injectCallerIdentityEnv(
607
+ envVars,
608
+ resolveCallerIdentity(
609
+ blueprint.sessionSpec.metadata,
610
+ session.status?.audit?.specAudit?.createdBy,
611
+ ),
612
+ sessionId,
613
+ );
596
614
  let mcpResolution = await resolveMcpServers(
597
- client, blueprint.mergedMcpServerUsages, envVars, transportPosture,
615
+ client, blueprint.mergedMcpServerUsages, mcpEnvVars, transportPosture,
598
616
  );
599
617
  setupTiming.mark("resolve_mcp_servers");
600
618
 
@@ -602,11 +620,24 @@ async function executeCursorInner(
602
620
  heartbeatPhase = "resolving_mcp_servers";
603
621
  const sessionOrg = session.metadata?.org ?? "";
604
622
  mcpResolution = await backfillMcpServersIfNeeded(
605
- client, mcpResolution, blueprint.mergedMcpServerUsages, envVars, sessionOrg,
623
+ client, mcpResolution, blueprint.mergedMcpServerUsages, mcpEnvVars, sessionOrg,
606
624
  transportPosture, heartbeat, secretKeys,
607
625
  );
608
626
  setupTiming.mark("backfill_mcp");
609
627
 
628
+ // The synthesized attachments' credential story (DD-006 D4): the
629
+ // exchanged token authenticates the discovery reads per-call (a
630
+ // desktop runner's ambient embedded_runner credential is refused by
631
+ // the messaging reach; undefined lets a cloud sandbox runner's
632
+ // ambient session-scoped token or OSS's no-auth apply). The
633
+ // attachment header falls back to the ambient credential where no
634
+ // exchange happens.
635
+ const exchangedRunnerToken =
636
+ await client.acquireScopedRunnerToken({ agentExecutionId: executionId });
637
+ const attachmentCredential = exchangedRunnerToken
638
+ ?? config.stigmerTokenRef?.current
639
+ ?? config.stigmerToken;
640
+
610
641
  // Phase 4a2: Synthesize the datastore records attachment (T05).
611
642
  // Deliberately AFTER resolve + backfill: the attachment has no
612
643
  // McpServerUsage and reports discovered capabilities, so the
@@ -614,18 +645,38 @@ async function executeCursorInner(
614
645
  // delete_record (which on channels would be silently skipped).
615
646
  // Empty approval maps keep it approval-free by construction.
616
647
  if (blueprint.datastoreUsages.length > 0) {
617
- const scopedCredential =
618
- (await client.acquireScopedRunnerToken({ agentExecutionId: executionId }))
619
- ?? config.stigmerTokenRef?.current
620
- ?? config.stigmerToken;
621
648
  const attachment = synthesizeDatastoreAttachment(blueprint.datastoreUsages, {
622
649
  bridgeEndpoint: config.mcpBridgeEndpoint,
623
- credential: scopedCredential,
650
+ credential: attachmentCredential,
624
651
  backendEndpoint: config.stigmerBackendEndpoint,
625
652
  });
626
653
  if (attachment) {
627
- const resolvedServers = injectDatastoreAttachment(
628
- mcpResolution.resolvedServers, attachment,
654
+ const resolvedServers = injectSynthesizedAttachment(
655
+ mcpResolution.resolvedServers, attachment, "datastore records",
656
+ );
657
+ mcpResolution = {
658
+ resolvedServers,
659
+ cursorConfig: toCursorMcpConfig(resolvedServers),
660
+ };
661
+ }
662
+ }
663
+
664
+ // Phase 4a3: Synthesize the channel messaging attachment (DD-006
665
+ // D7/D8), the records attachment's twin. The discovery read is the
666
+ // attachment decision — the control plane runs the SAME candidate
667
+ // computation the send authorization uses — and every failure mode
668
+ // (no channel, OSS, registry down, pre-3a control plane) degrades
669
+ // to honest absence: no tool, no section, execution unharmed.
670
+ const channelMessaging = await discoverChannelMessaging(client, exchangedRunnerToken);
671
+ if (channelMessaging.length > 0) {
672
+ const attachment = synthesizeChannelAttachment(channelMessaging, {
673
+ bridgeEndpoint: config.mcpBridgeEndpoint,
674
+ credential: attachmentCredential,
675
+ backendEndpoint: config.stigmerBackendEndpoint,
676
+ });
677
+ if (attachment) {
678
+ const resolvedServers = injectSynthesizedAttachment(
679
+ mcpResolution.resolvedServers, attachment, "channel messaging",
629
680
  );
630
681
  mcpResolution = {
631
682
  resolvedServers,
@@ -992,6 +1043,7 @@ async function executeCursorInner(
992
1043
  userMessage: spec.message,
993
1044
  skills: skillMetadata,
994
1045
  datastoreUsages: blueprint.datastoreUsages,
1046
+ channelMessaging,
995
1047
  subAgents: blueprint.subAgents,
996
1048
  workspaceDirs: blueprint.workspaceDirs,
997
1049
  workspaceFileRefs: spec.workspaceFileRefs ?? [],
@@ -1605,6 +1657,7 @@ async function executeCursorInner(
1605
1657
  userMessage: spec.message,
1606
1658
  skills: skillMetadata,
1607
1659
  datastoreUsages: blueprint.datastoreUsages,
1660
+ channelMessaging,
1608
1661
  subAgents: blueprint.subAgents,
1609
1662
  workspaceDirs: blueprint.workspaceDirs,
1610
1663
  workspaceFileRefs: spec.workspaceFileRefs ?? [],
@@ -2169,6 +2222,11 @@ export interface BuildPromptInput {
2169
2222
  skills: import("./prompt-builder.js").SkillMetadata[];
2170
2223
  /** Datastores attached via `datastore_usages` — the `<available_datastores>` section. */
2171
2224
  datastoreUsages?: import("@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb").DatastoreUsage[];
2225
+ /**
2226
+ * Serving proactive channels + their templates (the DD-006 D2
2227
+ * discovery read) — the `<available_channel_templates>` section.
2228
+ */
2229
+ channelMessaging?: import("../../shared/channel-attachment.js").ChannelMessagingInfo[];
2172
2230
  subAgents: import("@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb").SubAgent[];
2173
2231
  workspaceDirs: string[];
2174
2232
  workspaceFileRefs: string[];
@@ -2276,6 +2334,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2276
2334
  userMessage,
2277
2335
  skills,
2278
2336
  datastoreUsages: input.datastoreUsages ?? [],
2337
+ channelMessaging: input.channelMessaging ?? [],
2279
2338
  subAgents,
2280
2339
  workspaceDirs,
2281
2340
  workspaceFileRefs,
@@ -22,6 +22,10 @@ import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentex
22
22
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
23
23
  import { formatContextBridgeText } from "../../shared/context-bridge.js";
24
24
  import { formatDatastoresSection } from "../../shared/datastore-attachment.js";
25
+ import {
26
+ formatChannelTemplatesSection,
27
+ type ChannelMessagingInfo,
28
+ } from "../../shared/channel-attachment.js";
25
29
  import {
26
30
  formatSenderIdentityText,
27
31
  type SenderIdentity,
@@ -61,6 +65,12 @@ export interface EnhancedPromptOptions {
61
65
  * pointing the model at the synthesized record tools.
62
66
  */
63
67
  datastoreUsages?: DatastoreUsage[];
68
+ /**
69
+ * Serving proactive channels + their approved templates — rendered as
70
+ * the `<available_channel_templates>` section (proactive-messaging
71
+ * DD-003 D5) beside the synthesized send_channel_message tool.
72
+ */
73
+ channelMessaging?: ChannelMessagingInfo[];
64
74
  subAgents: SubAgent[];
65
75
  workspaceDirs: string[];
66
76
  workspaceFileRefs: string[];
@@ -133,6 +143,15 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
133
143
  sections.push(formatDatastoresSection(options.datastoreUsages));
134
144
  }
135
145
 
146
+ if (options.channelMessaging !== undefined && options.channelMessaging.length > 0) {
147
+ // "" when nothing is sendable — the tool alone still serves text
148
+ // sends inside a 24-hour window (DD-006 D6).
149
+ const channelSection = formatChannelTemplatesSection(options.channelMessaging);
150
+ if (channelSection !== "") {
151
+ sections.push(channelSection);
152
+ }
153
+ }
154
+
136
155
  if (options.subAgents.length > 0) {
137
156
  sections.push(formatSubAgentsSection(options.subAgents));
138
157
  }
@@ -98,6 +98,12 @@ export interface PromptBuilderInput {
98
98
  * formatDatastoresSection); empty when the agent uses no datastores.
99
99
  */
100
100
  datastoresPromptSection?: string;
101
+ /**
102
+ * The `<available_channel_templates>` section
103
+ * (shared/channel-attachment.ts formatChannelTemplatesSection); absent
104
+ * when the agent serves no proactive channel or nothing is sendable.
105
+ */
106
+ channelTemplatesPromptSection?: string;
101
107
  workspaceFileRefs: string[];
102
108
  workspaceRoot: string;
103
109
  injectedFiles: InjectedFile[];
@@ -174,6 +180,10 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
174
180
  prompt += "\n\n" + input.datastoresPromptSection;
175
181
  }
176
182
 
183
+ if (input.channelTemplatesPromptSection) {
184
+ prompt += "\n\n" + input.channelTemplatesPromptSection;
185
+ }
186
+
177
187
  if (input.workspaceFileRefs.length > 0) {
178
188
  const refSection = buildReferencedFilesSection(
179
189
  input.workspaceFileRefs,
@@ -26,6 +26,10 @@ import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js
26
26
  import { createCheckpointer } from "../../shared/checkpointer/factory.js";
27
27
  import { readContextBridge } from "../../shared/context-bridge.js";
28
28
  import { readSenderIdentity } from "../../shared/sender-identity.js";
29
+ import {
30
+ injectCallerIdentityEnv,
31
+ resolveCallerIdentity,
32
+ } from "../../shared/caller-identity.js";
29
33
  import { readSessionContext } from "../../shared/session-context.js";
30
34
  import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-manager.js";
31
35
  import { resolveMcpServers } from "../../shared/mcp-resolver.js";
@@ -33,9 +37,14 @@ import { resolveMcpTransportPosture } from "../../shared/mcp-transport-guard.js"
33
37
  import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
34
38
  import {
35
39
  formatDatastoresSection,
36
- injectDatastoreAttachment,
37
40
  synthesizeDatastoreAttachment,
38
41
  } from "../../shared/datastore-attachment.js";
42
+ import {
43
+ discoverChannelMessaging,
44
+ formatChannelTemplatesSection,
45
+ synthesizeChannelAttachment,
46
+ } from "../../shared/channel-attachment.js";
47
+ import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
39
48
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
40
49
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
41
50
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
@@ -334,12 +343,41 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
334
343
  ];
335
344
  const datastoreUsages = agent.spec!.datastoreUsages || [];
336
345
 
346
+ // The synthesized attachments' credential story (DD-006 D4): the
347
+ // exchanged token authenticates the discovery reads per-call (the
348
+ // messaging reach refuses a desktop runner's ambient embedded_runner
349
+ // credential; undefined lets the ambient credential apply). The
350
+ // attachment header falls back to the ambient credential.
351
+ const exchangedRunnerToken =
352
+ await client.acquireScopedRunnerToken({ agentExecutionId: executionId });
353
+ const attachmentCredential = exchangedRunnerToken
354
+ ?? config.stigmerTokenRef?.current
355
+ ?? config.stigmerToken;
356
+
357
+ // The channel discovery read runs BEFORE the MCP gate below: an
358
+ // agent whose ONLY tool source is a proactive channel would
359
+ // otherwise never enter MCP resolution and never connect the
360
+ // attachment (DD-006 D7). Every failure mode degrades to an empty
361
+ // answer — no tool, no section, execution unharmed.
362
+ const channelMessaging = await discoverChannelMessaging(client, exchangedRunnerToken);
363
+
337
364
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
338
- if (mcpServerUsages.length > 0 || datastoreUsages.length > 0) {
365
+ if (mcpServerUsages.length > 0 || datastoreUsages.length > 0 || channelMessaging.length > 0) {
339
366
  await reportSetupProgress(client, executionId, "Connecting tools…");
340
367
  const transportPosture = resolveMcpTransportPosture(config.mode);
368
+ // The MCP-bound env map (and ONLY it) carries the reserved
369
+ // caller-identity keys — mirror of the Cursor harness's Phase 4
370
+ // injection; filterEnvToDeclaredKeys keeps undeclared servers blind.
371
+ const mcpEnvVars = injectCallerIdentityEnv(
372
+ envResult.mergedEnvVars,
373
+ resolveCallerIdentity(
374
+ session.spec!.metadata,
375
+ session.status?.audit?.specAudit?.createdBy,
376
+ ),
377
+ sessionId,
378
+ );
341
379
  resolvedMcpServers = await resolveMcpServers(
342
- client, mcpServerUsages, envResult.mergedEnvVars, transportPosture,
380
+ client, mcpServerUsages, mcpEnvVars, transportPosture,
343
381
  );
344
382
  timing.mark("resolve_mcp_servers");
345
383
 
@@ -348,7 +386,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
348
386
  client,
349
387
  resolvedMcpServers.resolvedServers,
350
388
  mcpServerUsages,
351
- envResult.mergedEnvVars,
389
+ mcpEnvVars,
352
390
  sessionOrg,
353
391
  transportPosture,
354
392
  undefined,
@@ -360,17 +398,30 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
360
398
  // approval maps keep it approval-free by construction (see
361
399
  // shared/datastore-attachment.ts).
362
400
  if (datastoreUsages.length > 0) {
363
- const scopedCredential =
364
- (await client.acquireScopedRunnerToken({ agentExecutionId: executionId }))
365
- ?? config.stigmerTokenRef?.current
366
- ?? config.stigmerToken;
367
401
  const attachment = synthesizeDatastoreAttachment(datastoreUsages, {
368
402
  bridgeEndpoint: config.mcpBridgeEndpoint,
369
- credential: scopedCredential,
403
+ credential: attachmentCredential,
404
+ backendEndpoint: config.stigmerBackendEndpoint,
405
+ });
406
+ if (attachment) {
407
+ backfilledServers = injectSynthesizedAttachment(
408
+ backfilledServers, attachment, "datastore records",
409
+ );
410
+ }
411
+ }
412
+
413
+ // The channel messaging attachment (DD-006 D7/D8) — the records
414
+ // attachment's twin, injected under the same after-backfill rule.
415
+ if (channelMessaging.length > 0) {
416
+ const attachment = synthesizeChannelAttachment(channelMessaging, {
417
+ bridgeEndpoint: config.mcpBridgeEndpoint,
418
+ credential: attachmentCredential,
370
419
  backendEndpoint: config.stigmerBackendEndpoint,
371
420
  });
372
421
  if (attachment) {
373
- backfilledServers = injectDatastoreAttachment(backfilledServers, attachment);
422
+ backfilledServers = injectSynthesizedAttachment(
423
+ backfilledServers, attachment, "channel messaging",
424
+ );
374
425
  }
375
426
  }
376
427
  resolvedMcpServers = { resolvedServers: backfilledServers };
@@ -445,6 +496,11 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
445
496
  datastoresPromptSection: datastoreUsages.length > 0
446
497
  ? formatDatastoresSection(datastoreUsages)
447
498
  : undefined,
499
+ // "" (nothing sendable) threads as undefined: the tool alone still
500
+ // serves text sends inside a 24-hour window (DD-006 D6).
501
+ channelTemplatesPromptSection: channelMessaging.length > 0
502
+ ? formatChannelTemplatesSection(channelMessaging) || undefined
503
+ : undefined,
448
504
  workspaceFileRefs: execution.spec!.workspaceFileRefs || [],
449
505
  workspaceRoot: workspaceBackend.rootDir,
450
506
  injectedFiles,
@@ -50,6 +50,8 @@ import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/ap
50
50
  import { WorkflowInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/query_pb";
51
51
  import type { WorkflowInstance } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
52
52
  import { PlatformQueryController, GetRunnerScopedTokenInputSchema } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
53
+ import { ChannelMessageQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_query_pb";
54
+ import type { ChannelTemplate, MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
53
55
  import { isEmbeddedRunnerToken } from "./token-claims.js";
54
56
  import { assertCreateRequirements, assertReferenceRequirements } from "./server-contracts.js";
55
57
 
@@ -156,6 +158,7 @@ export class StigmerClient {
156
158
  private readonly workflowQuery: Client<typeof WorkflowQueryController>;
157
159
  private readonly workflowInstanceQuery: Client<typeof WorkflowInstanceQueryController>;
158
160
  private readonly platformQuery: Client<typeof PlatformQueryController>;
161
+ private readonly channelMessageQuery: Client<typeof ChannelMessageQueryController>;
159
162
 
160
163
  private readonly tokenRef: TokenRef | null;
161
164
  private readonly runnerTokenRef: TokenRef | null;
@@ -239,6 +242,7 @@ export class StigmerClient {
239
242
  this.workflowQuery = createClient(WorkflowQueryController, this.transport);
240
243
  this.workflowInstanceQuery = createClient(WorkflowInstanceQueryController, this.transport);
241
244
  this.platformQuery = createClient(PlatformQueryController, this.transport);
245
+ this.channelMessageQuery = createClient(ChannelMessageQueryController, this.transport);
242
246
  }
243
247
 
244
248
  /**
@@ -380,6 +384,48 @@ export class StigmerClient {
380
384
  }
381
385
  }
382
386
 
387
+ /**
388
+ * The agent's serving proactive-messaging channels, as data
389
+ * (proactive-messaging DD-006 D2) — the runner's tool-attachment
390
+ * decision. An empty list is the everyday answer (most agents have no
391
+ * proactive channel).
392
+ *
393
+ * When a scoped runner token is supplied, the read authenticates with
394
+ * it per-call (the {@link getExecutionContextByExecutionId} precedent):
395
+ * the messaging reach refuses the desktop runner's ambient
396
+ * embedded_runner credential outright, and one desktop runner process
397
+ * serves many sessions concurrently, so the credential cannot live in
398
+ * a shared ref. A cloud sandbox runner's ambient credential is already
399
+ * the session-scoped token; OSS sends nothing and answers empty.
400
+ */
401
+ async listMessagingChannels(scopedToken?: string): Promise<MessagingChannel[]> {
402
+ const res = await this.channelMessageQuery.listMessagingChannels(
403
+ {},
404
+ scopedToken
405
+ ? { headers: { authorization: `Bearer ${scopedToken}` } }
406
+ : undefined,
407
+ );
408
+ return res.entries;
409
+ }
410
+
411
+ /**
412
+ * The channel's provider template registry, approved entries only —
413
+ * the `<available_channel_templates>` prompt section's source
414
+ * (proactive-messaging DD-003 D5). Entries carry the DD-006 D1
415
+ * sendability verdict (`unsupportedReason`, empty means sendable);
416
+ * the section formatter filters on it. Credential rules as
417
+ * {@link listMessagingChannels}.
418
+ */
419
+ async listChannelTemplates(channel: string, scopedToken?: string): Promise<ChannelTemplate[]> {
420
+ const res = await this.channelMessageQuery.listTemplates(
421
+ { channel, approvedOnly: true },
422
+ scopedToken
423
+ ? { headers: { authorization: `Bearer ${scopedToken}` } }
424
+ : undefined,
425
+ );
426
+ return res.entries;
427
+ }
428
+
383
429
  async getSession(sessionId: string): Promise<Session> {
384
430
  return this.sessionQuery.get({ value: sessionId });
385
431
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Unit tests for the caller identity: the reserved env keys, the
3
+ * precedence chain, the authoritative injection, and the discovery
4
+ * sentinel that keeps identity-templating servers discoverable.
5
+ */
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import {
9
+ ANONYMOUS_KIND,
10
+ CALLER_IDENTITY_KIND_ENV_KEY,
11
+ CALLER_IDENTITY_VALUE_ENV_KEY,
12
+ SESSION_ID_ENV_KEY,
13
+ STIGMER_USER_KIND,
14
+ anonymousCallerIdentity,
15
+ injectAnonymousCallerIdentityForDiscovery,
16
+ injectCallerIdentityEnv,
17
+ resolveCallerIdentity,
18
+ } from "../caller-identity.js";
19
+ import {
20
+ SENDER_IDENTITY_METADATA_KEY,
21
+ SENDER_KIND_METADATA_KEY,
22
+ } from "../sender-identity.js";
23
+ import { resolveHeaders } from "../placeholder-resolver.js";
24
+
25
+ describe("reserved env keys", () => {
26
+ it("are pinned verbatim — MCP server specs template these names (contract guard)", () => {
27
+ // These names appear in user-authored McpServer YAML (spec.env
28
+ // declarations and ${...} header templates). Renaming them breaks
29
+ // every deployed server that consumes caller identity.
30
+ expect(CALLER_IDENTITY_KIND_ENV_KEY).toBe("STIGMER_CALLER_IDENTITY_KIND");
31
+ expect(CALLER_IDENTITY_VALUE_ENV_KEY).toBe("STIGMER_CALLER_IDENTITY_VALUE");
32
+ expect(SESSION_ID_ENV_KEY).toBe("STIGMER_SESSION_ID");
33
+ });
34
+ });
35
+
36
+ describe("resolveCallerIdentity precedence", () => {
37
+ const channelMetadata = {
38
+ [SENDER_IDENTITY_METADATA_KEY]: "919800000001",
39
+ [SENDER_KIND_METADATA_KEY]: "whatsapp_phone",
40
+ };
41
+
42
+ it("channel sender wins, kind passed through VERBATIM from the broker", () => {
43
+ expect(
44
+ resolveCallerIdentity(channelMetadata, { id: "idacc_1", email: "owner@example.com" }),
45
+ ).toEqual({ kind: "whatsapp_phone", value: "919800000001" });
46
+ });
47
+
48
+ it("falls to the session creator (stigmer_user) when no channel sender exists", () => {
49
+ expect(
50
+ resolveCallerIdentity({}, { id: "idacc_1", email: "owner@example.com" }),
51
+ ).toEqual({ kind: STIGMER_USER_KIND, value: "owner@example.com" });
52
+ });
53
+
54
+ it("prefers the creator's email over the historically-mixed id field", () => {
55
+ // The audit actor's `id` is sometimes an identity-account id and
56
+ // sometimes an email (per the proto's own @internal note) — email is
57
+ // what humans bind against, so it wins when present.
58
+ expect(resolveCallerIdentity(undefined, { id: "idacc_1", email: "a@b.c" }).value).toBe("a@b.c");
59
+ expect(resolveCallerIdentity(undefined, { id: "idacc_1" }).value).toBe("idacc_1");
60
+ });
61
+
62
+ it("falls to anonymous when neither source exists (OSS sparse audit, no metadata)", () => {
63
+ expect(resolveCallerIdentity(undefined, undefined)).toEqual({
64
+ kind: ANONYMOUS_KIND,
65
+ value: "",
66
+ });
67
+ expect(resolveCallerIdentity({}, { id: " ", email: "" })).toEqual({
68
+ kind: ANONYMOUS_KIND,
69
+ value: "",
70
+ });
71
+ });
72
+
73
+ it("a half-present channel identity (value without kind) is NOT a channel sender", () => {
74
+ // readSenderIdentity requires value AND kind; a half-stamped session
75
+ // falls through to the creator rather than fabricating a kind.
76
+ expect(
77
+ resolveCallerIdentity(
78
+ { [SENDER_IDENTITY_METADATA_KEY]: "919800000001" },
79
+ { email: "owner@example.com" },
80
+ ),
81
+ ).toEqual({ kind: STIGMER_USER_KIND, value: "owner@example.com" });
82
+ });
83
+ });
84
+
85
+ describe("injectCallerIdentityEnv", () => {
86
+ const identity = { kind: "whatsapp_phone", value: "919800000001" };
87
+
88
+ it("returns a NEW map with the reserved keys set — input never mutated", () => {
89
+ const input = { GRIST_API_KEY: "secret" };
90
+ const result = injectCallerIdentityEnv(input, identity, "sess_1");
91
+
92
+ expect(result).toEqual({
93
+ GRIST_API_KEY: "secret",
94
+ [CALLER_IDENTITY_KIND_ENV_KEY]: "whatsapp_phone",
95
+ [CALLER_IDENTITY_VALUE_ENV_KEY]: "919800000001",
96
+ [SESSION_ID_ENV_KEY]: "sess_1",
97
+ });
98
+ expect(input).toEqual({ GRIST_API_KEY: "secret" });
99
+ });
100
+
101
+ it("platform values are authoritative — a user env var cannot impersonate a caller", () => {
102
+ const result = injectCallerIdentityEnv(
103
+ { [CALLER_IDENTITY_VALUE_ENV_KEY]: "999999999999" },
104
+ identity,
105
+ "sess_1",
106
+ );
107
+ expect(result[CALLER_IDENTITY_VALUE_ENV_KEY]).toBe("919800000001");
108
+ });
109
+
110
+ it("the reserved keys can never hit the unresolved-placeholder silent-skip path", () => {
111
+ // At runtime PlaceholderResolutionError is caught and the server
112
+ // silently dropped from the execution — always-present injection makes
113
+ // that unreachable for these keys, whatever the identity resolved to.
114
+ const env = injectCallerIdentityEnv({}, anonymousCallerIdentity(), "");
115
+ const headers = resolveHeaders(
116
+ {
117
+ "X-Stigmer-Caller-Kind": `\${${CALLER_IDENTITY_KIND_ENV_KEY}}`,
118
+ "X-Stigmer-Caller-Value": `\${${CALLER_IDENTITY_VALUE_ENV_KEY}}`,
119
+ },
120
+ env,
121
+ );
122
+ expect(headers).toEqual({
123
+ "X-Stigmer-Caller-Kind": ANONYMOUS_KIND,
124
+ "X-Stigmer-Caller-Value": "",
125
+ });
126
+ });
127
+ });
128
+
129
+ describe("injectAnonymousCallerIdentityForDiscovery", () => {
130
+ it("resolves declared identity placeholders with the anonymous sentinel", () => {
131
+ // Discovery has no session: without the sentinel, a server templating
132
+ // ${STIGMER_CALLER_IDENTITY_VALUE} would throw
133
+ // PlaceholderResolutionError and never get its tools classified.
134
+ const declared = new Set([
135
+ "GRIST_API_KEY",
136
+ CALLER_IDENTITY_KIND_ENV_KEY,
137
+ CALLER_IDENTITY_VALUE_ENV_KEY,
138
+ ]);
139
+ const env = injectAnonymousCallerIdentityForDiscovery(declared, {
140
+ GRIST_API_KEY: "secret",
141
+ });
142
+
143
+ expect(
144
+ resolveHeaders(
145
+ { "X-Stigmer-Caller-Kind": `\${${CALLER_IDENTITY_KIND_ENV_KEY}}` },
146
+ env,
147
+ ),
148
+ ).toEqual({ "X-Stigmer-Caller-Kind": ANONYMOUS_KIND });
149
+ expect(env[CALLER_IDENTITY_VALUE_ENV_KEY]).toBe("");
150
+ expect(env.GRIST_API_KEY).toBe("secret");
151
+ });
152
+
153
+ it("is declaration-gated: servers that never declared the keys get an untouched map", () => {
154
+ const input = { OTHER: "x" };
155
+ const result = injectAnonymousCallerIdentityForDiscovery(new Set(["OTHER"]), input);
156
+ expect(result).toBe(input);
157
+ expect(result).not.toHaveProperty(CALLER_IDENTITY_KIND_ENV_KEY);
158
+ });
159
+ });