@stigmer/runner 3.5.3 → 3.7.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 (101) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-agent.js +85 -10
  3. package/dist/activities/call-agent.js.map +1 -1
  4. package/dist/activities/discover-mcp-server.js +9 -1
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/execute-cursor/index.d.ts +5 -0
  7. package/dist/activities/execute-cursor/index.js +88 -14
  8. package/dist/activities/execute-cursor/index.js.map +1 -1
  9. package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
  10. package/dist/activities/execute-cursor/model-pricing.js +19 -0
  11. package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
  12. package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -0
  13. package/dist/activities/execute-cursor/prompt-builder.js +9 -0
  14. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  15. package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
  16. package/dist/activities/execute-cursor/service-tier.js +187 -0
  17. package/dist/activities/execute-cursor/service-tier.js.map +1 -0
  18. package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
  19. package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
  20. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  21. package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
  22. package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
  23. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +6 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
  26. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/setup.js +45 -9
  28. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  29. package/dist/client/stigmer-client.d.ts +32 -1
  30. package/dist/client/stigmer-client.js +42 -2
  31. package/dist/client/stigmer-client.js.map +1 -1
  32. package/dist/main.js +18 -0
  33. package/dist/main.js.map +1 -1
  34. package/dist/runner.js +48 -0
  35. package/dist/runner.js.map +1 -1
  36. package/dist/sandbox-token-renewal.d.ts +65 -0
  37. package/dist/sandbox-token-renewal.js +169 -0
  38. package/dist/sandbox-token-renewal.js.map +1 -0
  39. package/dist/shared/artifact-storage.d.ts +17 -3
  40. package/dist/shared/artifact-storage.js +22 -4
  41. package/dist/shared/artifact-storage.js.map +1 -1
  42. package/dist/shared/caller-identity.d.ts +89 -0
  43. package/dist/shared/caller-identity.js +124 -0
  44. package/dist/shared/caller-identity.js.map +1 -0
  45. package/dist/shared/channel-attachment.d.ts +85 -0
  46. package/dist/shared/channel-attachment.js +203 -0
  47. package/dist/shared/channel-attachment.js.map +1 -0
  48. package/dist/shared/datastore-attachment.d.ts +2 -25
  49. package/dist/shared/datastore-attachment.js +1 -28
  50. package/dist/shared/datastore-attachment.js.map +1 -1
  51. package/dist/shared/synthesized-attachment.d.ts +51 -0
  52. package/dist/shared/synthesized-attachment.js +45 -0
  53. package/dist/shared/synthesized-attachment.js.map +1 -0
  54. package/dist/workflow-engine/loader.js +99 -2
  55. package/dist/workflow-engine/loader.js.map +1 -1
  56. package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
  57. package/dist/workflow-engine/tasks/call-agent.js +0 -2
  58. package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
  59. package/dist/workflow-engine/types.d.ts +39 -7
  60. package/dist/workflow-engine/types.js.map +1 -1
  61. package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
  62. package/dist/workflows/call-agent-orchestrator.js +8 -2
  63. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/__test-utils__/mock-client.ts +4 -0
  66. package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
  67. package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
  68. package/src/activities/__tests__/call-agent.test.ts +219 -4
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +49 -0
  70. package/src/activities/call-agent.ts +94 -10
  71. package/src/activities/discover-mcp-server.ts +13 -1
  72. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
  73. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
  74. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
  75. package/src/activities/execute-cursor/index.ts +121 -20
  76. package/src/activities/execute-cursor/model-pricing.ts +23 -0
  77. package/src/activities/execute-cursor/prompt-builder.ts +19 -0
  78. package/src/activities/execute-cursor/service-tier.ts +244 -0
  79. package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
  80. package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
  81. package/src/activities/execute-deep-agent/prompt-builder.ts +10 -0
  82. package/src/activities/execute-deep-agent/setup.ts +66 -10
  83. package/src/client/stigmer-client.ts +57 -4
  84. package/src/main.ts +20 -0
  85. package/src/runner.ts +62 -0
  86. package/src/sandbox-token-renewal.ts +212 -0
  87. package/src/shared/__tests__/caller-identity.test.ts +159 -0
  88. package/src/shared/__tests__/channel-attachment.test.ts +276 -0
  89. package/src/shared/__tests__/datastore-attachment.test.ts +4 -4
  90. package/src/shared/artifact-storage.ts +32 -7
  91. package/src/shared/caller-identity.ts +161 -0
  92. package/src/shared/channel-attachment.ts +237 -0
  93. package/src/shared/datastore-attachment.ts +2 -54
  94. package/src/shared/synthesized-attachment.ts +77 -0
  95. package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
  96. package/src/workflow-engine/__tests__/loader.test.ts +192 -7
  97. package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
  98. package/src/workflow-engine/loader.ts +113 -2
  99. package/src/workflow-engine/tasks/call-agent.ts +0 -2
  100. package/src/workflow-engine/types.ts +40 -7
  101. package/src/workflows/call-agent-orchestrator.ts +8 -2
@@ -89,11 +89,23 @@ export class LocalArtifactStorage implements ArtifactStorage {
89
89
 
90
90
  export class ProxyArtifactStorage implements ArtifactStorage {
91
91
  private readonly baseUrl: string;
92
- private readonly authToken: string;
92
+ private readonly authTokenSource: ProxyAuthTokenSource;
93
93
 
94
- constructor(proxyEndpoint: string, authToken: string) {
94
+ constructor(proxyEndpoint: string, authToken: ProxyAuthTokenSource) {
95
95
  this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/artifacts`;
96
- this.authToken = authToken;
96
+ this.authTokenSource = authToken;
97
+ }
98
+
99
+ /**
100
+ * Resolve the credential per call rather than pinning the boot token: a
101
+ * cloud sandbox's control-plane token rotates in place (see
102
+ * sandbox-token-renewal.ts), and this storage lives for the pod's whole
103
+ * life — a captured string would silently 401 after the first rotation.
104
+ */
105
+ private get authToken(): string {
106
+ return typeof this.authTokenSource === "string"
107
+ ? this.authTokenSource
108
+ : (this.authTokenSource.current ?? "");
97
109
  }
98
110
 
99
111
  async upload(key: string, content: Buffer, contentType?: string): Promise<string> {
@@ -214,12 +226,18 @@ export class ProxyArtifactStorage implements ArtifactStorage {
214
226
 
215
227
  // ── Factory ──────────────────────────────────────────────────────────
216
228
 
229
+ /**
230
+ * The proxy credential, either fixed (a caller-supplied string) or live (a
231
+ * shared mutable ref, read per call — the sandbox token-renewal posture).
232
+ */
233
+ export type ProxyAuthTokenSource = string | { readonly current: string | null };
234
+
217
235
  export interface ArtifactStorageConfig {
218
236
  readonly type: ArtifactStorageType;
219
237
  readonly localPath: string;
220
238
  readonly localServeUrl: string;
221
239
  readonly proxyEndpoint: string | null;
222
- readonly proxyAuthToken: string | null;
240
+ readonly proxyAuthToken: ProxyAuthTokenSource | null;
223
241
  }
224
242
 
225
243
  export function loadArtifactStorageConfig(config: Config): ArtifactStorageConfig {
@@ -239,7 +257,11 @@ export function loadArtifactStorageConfig(config: Config): ArtifactStorageConfig
239
257
  localPath: process.env.LOCAL_ARTIFACT_PATH ?? "/var/stigmer/artifacts",
240
258
  localServeUrl: process.env.LOCAL_ARTIFACT_SERVE_URL ?? "http://localhost:7235",
241
259
  proxyEndpoint: type === "proxy" ? (config.proxyEndpoint ?? null) : null,
242
- proxyAuthToken: type === "proxy" ? (config.stigmerToken ?? null) : null,
260
+ // Prefer the live ref: renewal rotates the token in place and uploads
261
+ // must present the current credential, not the boot one.
262
+ proxyAuthToken: type === "proxy"
263
+ ? (config.stigmerTokenRef ?? config.stigmerToken ?? null)
264
+ : null,
243
265
  };
244
266
  }
245
267
 
@@ -248,10 +270,13 @@ export function createArtifactStorage(cfg: ArtifactStorageConfig): ArtifactStora
248
270
  if (!cfg.proxyEndpoint) {
249
271
  throw new Error("Proxy artifact storage requires STIGMER_PROXY_ENDPOINT");
250
272
  }
251
- if (!cfg.proxyAuthToken) {
273
+ const tokenAtBoot = typeof cfg.proxyAuthToken === "string"
274
+ ? cfg.proxyAuthToken
275
+ : cfg.proxyAuthToken?.current;
276
+ if (!tokenAtBoot) {
252
277
  throw new Error("Proxy artifact storage requires STIGMER_TOKEN");
253
278
  }
254
- return new ProxyArtifactStorage(cfg.proxyEndpoint, cfg.proxyAuthToken);
279
+ return new ProxyArtifactStorage(cfg.proxyEndpoint, cfg.proxyAuthToken!);
255
280
  }
256
281
 
257
282
  return new LocalArtifactStorage(cfg.localPath, cfg.localServeUrl);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The caller identity for MCP server configs — reserved platform env keys
3
+ * that carry the platform-verified "who is calling" into user-defined MCP
4
+ * server headers/args, without ever passing through the model.
5
+ *
6
+ * The identity is a (kind, value) pair with fixed precedence:
7
+ * 1. The channel sender (Meta/Slack-verified, stamped into
8
+ * `SessionSpec.metadata` by the cloud broker — sender-identity.ts is
9
+ * the reader).
10
+ * 2. The session creator (`stigmer_user`) from the Session resource's
11
+ * audit actor — console/CLI sessions have no channel sender, but the
12
+ * platform knows exactly who created the session.
13
+ * 3. The anonymous sentinel — discovery (no session exists) and sessions
14
+ * with no readable creator. Consumers must treat anonymous as a
15
+ * first-class caller: answer tools/list, refuse tool calls.
16
+ *
17
+ * Injection is opt-in by construction: the values enter the env map used
18
+ * for MCP placeholder resolution, and `filterEnvToDeclaredKeys` already
19
+ * restricts every server to the keys it declared in `spec.env`. A server
20
+ * that never declares the reserved keys never receives identity.
21
+ * Declarations MUST be `optional: true` — execution creation validates
22
+ * declared-env completeness in both editions, and these keys have no value
23
+ * until the runner injects them.
24
+ *
25
+ * Trust model: the resulting header is RUNNER-asserted, not signed. It
26
+ * closes the prompt-injection hole (the model cannot influence the value),
27
+ * but the receiving server must pair it with a shared secret and be
28
+ * operated by someone who trusts the runner's network path.
29
+ */
30
+
31
+ import { readSenderIdentity } from "./sender-identity.js";
32
+
33
+ /** Reserved env key: the identity's kind token. */
34
+ export const CALLER_IDENTITY_KIND_ENV_KEY = "STIGMER_CALLER_IDENTITY_KIND";
35
+
36
+ /** Reserved env key: the identity's value. */
37
+ export const CALLER_IDENTITY_VALUE_ENV_KEY = "STIGMER_CALLER_IDENTITY_VALUE";
38
+
39
+ /** Reserved env key: the session the identity was resolved for. */
40
+ export const SESSION_ID_ENV_KEY = "STIGMER_SESSION_ID";
41
+
42
+ /**
43
+ * Kind token for a platform user (console/CLI session creator). Channel
44
+ * kinds (`whatsapp_phone`, `slack_user_id`, ...) pass through VERBATIM
45
+ * from the cloud broker's metadata — this module never rewrites them.
46
+ */
47
+ export const STIGMER_USER_KIND = "stigmer_user";
48
+
49
+ /**
50
+ * Kind token for the anonymous caller. Deliberately a real token rather
51
+ * than an absent key: every declared placeholder must resolve in every
52
+ * resolution context, or discovery fails with PlaceholderResolutionError
53
+ * before the server's tools are ever classified.
54
+ */
55
+ export const ANONYMOUS_KIND = "anonymous";
56
+
57
+ /** The resolved caller identity. */
58
+ export interface CallerIdentity {
59
+ kind: string;
60
+ value: string;
61
+ }
62
+
63
+ /** The audit actor shape read from `status.audit.spec_audit.created_by`. */
64
+ export interface SessionCreatorActor {
65
+ id?: string;
66
+ email?: string;
67
+ }
68
+
69
+ /** The identity injected when no session context exists (discovery). */
70
+ export function anonymousCallerIdentity(): CallerIdentity {
71
+ return { kind: ANONYMOUS_KIND, value: "" };
72
+ }
73
+
74
+ /**
75
+ * Resolve the caller identity for a session: channel sender first, then
76
+ * the session creator, then anonymous.
77
+ *
78
+ * The creator value prefers email over id: bindings are maintained by
79
+ * humans, and the audit actor's `id` field is historically mixed
80
+ * (identity-account id vs email — see the proto's own @internal note).
81
+ * Binding matchers should compare emails case-insensitively.
82
+ */
83
+ export function resolveCallerIdentity(
84
+ sessionMetadata: Record<string, string> | undefined,
85
+ creator?: SessionCreatorActor,
86
+ ): CallerIdentity {
87
+ const sender = readSenderIdentity(sessionMetadata);
88
+ if (sender) {
89
+ return { kind: sender.kind, value: sender.value };
90
+ }
91
+
92
+ const email = creator?.email?.trim();
93
+ const id = creator?.id?.trim();
94
+ const value = email || id;
95
+ if (value) {
96
+ return { kind: STIGMER_USER_KIND, value };
97
+ }
98
+
99
+ return anonymousCallerIdentity();
100
+ }
101
+
102
+ /**
103
+ * Return a NEW env map with the reserved caller-identity keys set —
104
+ * platform values are authoritative over same-named user entries (the
105
+ * injectPlatformEnv precedent: a user env var must never be able to
106
+ * impersonate a caller).
107
+ *
108
+ * Call this on the env map handed to MCP resolution ONLY — never on the
109
+ * map that reaches agent subprocess environments. Per-server opt-in is
110
+ * enforced downstream by filterEnvToDeclaredKeys.
111
+ */
112
+ export function injectCallerIdentityEnv(
113
+ envVars: Record<string, string>,
114
+ identity: CallerIdentity,
115
+ sessionId: string,
116
+ ): Record<string, string> {
117
+ const reserved: Record<string, string> = {
118
+ [CALLER_IDENTITY_KIND_ENV_KEY]: identity.kind,
119
+ [CALLER_IDENTITY_VALUE_ENV_KEY]: identity.value,
120
+ [SESSION_ID_ENV_KEY]: sessionId,
121
+ };
122
+
123
+ for (const [key, value] of Object.entries(reserved)) {
124
+ if (key in envVars && envVars[key] !== value) {
125
+ console.info(
126
+ `Platform env var '${key}' overrides value from ExecutionContext ` +
127
+ `(caller-identity vars are authoritative)`,
128
+ );
129
+ }
130
+ }
131
+
132
+ return { ...envVars, ...reserved };
133
+ }
134
+
135
+ /**
136
+ * Discovery-context injection: the connect workflow resolves the same
137
+ * header templates with no session, so every declared reserved key gets
138
+ * the anonymous sentinel — otherwise a caller-identity-templating server
139
+ * can never be discovered. Gated on the server's declared keys, matching
140
+ * injectPlatformEnv's contract in the discovery activity.
141
+ */
142
+ export function injectAnonymousCallerIdentityForDiscovery(
143
+ declaredEnvKeys: ReadonlySet<string>,
144
+ envVars: Record<string, string>,
145
+ ): Record<string, string> {
146
+ const anonymous = anonymousCallerIdentity();
147
+ const sentinels: Record<string, string> = {
148
+ [CALLER_IDENTITY_KIND_ENV_KEY]: anonymous.kind,
149
+ [CALLER_IDENTITY_VALUE_ENV_KEY]: anonymous.value,
150
+ [SESSION_ID_ENV_KEY]: "",
151
+ };
152
+
153
+ let result: Record<string, string> | undefined;
154
+ for (const [key, value] of Object.entries(sentinels)) {
155
+ if (!declaredEnvKeys.has(key)) continue;
156
+ if (!result) result = { ...envVars };
157
+ result[key] = value;
158
+ }
159
+
160
+ return result ?? envVars;
161
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The runner-synthesized channel messaging attachment (proactive-messaging
3
+ * DD-006 D7/D8) — the datastore records attachment's structural twin.
4
+ *
5
+ * When the control plane says an agent serves at least one
6
+ * proactive-messaging channel (the `listMessagingChannels` discovery
7
+ * read, DD-006 D2 — the SAME candidate computation the send
8
+ * authorization runs, so attachment and authority cannot disagree), the
9
+ * runner synthesizes ONE MCP attachment serving `send_channel_message`,
10
+ * and injects the `<available_channel_templates>` prompt section so the
11
+ * model composes template sends in context without spending a tool
12
+ * round (DD-003 D5).
13
+ *
14
+ * Two connection shapes, one roster (the records pattern):
15
+ * - Bridge endpoint configured (cloud): Streamable HTTP against the
16
+ * bridge's /channels route with the execution's own session-scoped
17
+ * credential as the Bearer token.
18
+ * - No bridge endpoint (OSS/local): a spawned `stigmer mcp-server`
19
+ * stdio child with STIGMER_MCP_ROSTER=channels. In practice OSS
20
+ * answers the discovery read with an empty list (DD-006 D3), so
21
+ * this shape only serves local deployments that grow a messaging
22
+ * runtime later — it exists for symmetry with the deployment
23
+ * topology, not for a live OSS path today.
24
+ *
25
+ * Approval-free by construction, and FORCED, not convenient (DD-002
26
+ * D6): both calling surfaces run APPROVAL_MODE_UNATTENDED, where a
27
+ * gated tool resolves as skip-and-adapt — a gated send tool would mean
28
+ * reminders never send. Empty approval maps + no McpServerUsage keep
29
+ * the connect backfill structurally unable to gate it (see
30
+ * synthesized-attachment.ts). Callers inject AFTER resolve + backfill.
31
+ *
32
+ * Failure posture (DD-006 D4): every discovery failure — OSS's empty
33
+ * answer, a registry outage, a control plane predating the RPC
34
+ * (UNIMPLEMENTED), a reach refusal — degrades to honest absence: no
35
+ * tool, no section, execution unharmed.
36
+ */
37
+
38
+ import { Code, ConnectError } from "@connectrpc/connect";
39
+ import type {
40
+ ChannelTemplate,
41
+ MessagingChannel,
42
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
43
+ import type { StigmerClient } from "../client/stigmer-client.js";
44
+ import type { ResolvedMcpServer } from "./mcp-resolver.js";
45
+ import { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
46
+
47
+ /**
48
+ * The synthesized attachment's slug. Reserved: a user McpServer with
49
+ * this slug is shadowed by the synthesized attachment, with a warning.
50
+ * Pinned cross-repo by the mcp-server integration test (the
51
+ * TOOL_CALL_LIMIT precedent).
52
+ */
53
+ export const CHANNEL_ATTACHMENT_SLUG = "stigmer-channels";
54
+
55
+ /** The bridge route serving the channels-only roster (mcp-server DD-006 D8). */
56
+ export const CHANNELS_ROUTE = "/channels";
57
+
58
+ /**
59
+ * The most templates the prompt section carries (DD-006 D6): Meta
60
+ * allows hundreds per WABA, and an unbounded section would tax every
61
+ * run's context. Deterministic (name, language) order plus a withheld
62
+ * count keep the agent's behavior independent of registry ordering.
63
+ */
64
+ export const TEMPLATE_SECTION_CAP = 30;
65
+
66
+ /** One channel plus its sendable approved templates, ready to format. */
67
+ export interface ChannelMessagingInfo {
68
+ channel: MessagingChannel;
69
+ templates: ChannelTemplate[];
70
+ }
71
+
72
+ /**
73
+ * The discovery read plus the per-channel template fetch, with the
74
+ * DD-006 D4 failure posture applied: this function NEVER throws — any
75
+ * failure returns an empty list (no tool, no section), because a
76
+ * messaging hiccup must not fail an execution that may not even want
77
+ * to send anything.
78
+ */
79
+ export async function discoverChannelMessaging(
80
+ client: StigmerClient,
81
+ scopedCredential: string | undefined,
82
+ ): Promise<ChannelMessagingInfo[]> {
83
+ let channels: MessagingChannel[];
84
+ try {
85
+ channels = await client.listMessagingChannels(scopedCredential);
86
+ } catch (err) {
87
+ logDiscoveryFailure("listMessagingChannels", err);
88
+ return [];
89
+ }
90
+ if (channels.length === 0) {
91
+ return [];
92
+ }
93
+
94
+ // Template reads degrade PER CHANNEL: a registry outage on one
95
+ // channel must not strip the section for another, and never the tool
96
+ // (a channel with unreadable templates can still send text inside a
97
+ // 24-hour window).
98
+ return Promise.all(channels.map(async (channel) => {
99
+ try {
100
+ return {
101
+ channel,
102
+ templates: await client.listChannelTemplates(channel.channel, scopedCredential),
103
+ };
104
+ } catch (err) {
105
+ logDiscoveryFailure(`listTemplates(${channel.channel})`, err);
106
+ return { channel, templates: [] };
107
+ }
108
+ }));
109
+ }
110
+
111
+ /**
112
+ * Synthesize the channel messaging attachment. Returns undefined when
113
+ * the agent serves no proactive channel — the attachment exists exactly
114
+ * when the discovery read says so.
115
+ */
116
+ export function synthesizeChannelAttachment(
117
+ channels: ChannelMessagingInfo[],
118
+ options: SynthesizedAttachmentOptions,
119
+ ): ResolvedMcpServer | undefined {
120
+ if (channels.length === 0) {
121
+ return undefined;
122
+ }
123
+
124
+ // Approval-free by construction + backfill-proof: see file header.
125
+ const base = {
126
+ slug: CHANNEL_ATTACHMENT_SLUG,
127
+ toolApprovals: [],
128
+ pinnedToolApprovals: [],
129
+ discoveredCapabilitiesEmpty: false,
130
+ };
131
+
132
+ if (options.bridgeEndpoint !== null && options.bridgeEndpoint !== "") {
133
+ return {
134
+ ...base,
135
+ connectionType: "http",
136
+ url: options.bridgeEndpoint.replace(/\/+$/, "") + CHANNELS_ROUTE,
137
+ headers: options.credential !== null && options.credential !== ""
138
+ ? { Authorization: `Bearer ${options.credential}` }
139
+ : undefined,
140
+ };
141
+ }
142
+
143
+ return {
144
+ ...base,
145
+ connectionType: "stdio",
146
+ command: "stigmer",
147
+ args: ["mcp-server"],
148
+ env: {
149
+ STIGMER_MCP_ROSTER: "channels",
150
+ STIGMER_SERVER_ADDRESS: grpcTarget(options.backendEndpoint),
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The `<available_channel_templates>` prompt section (DD-003 D5):
157
+ * approved AND sendable templates with their full body text, so the
158
+ * model fills positional placeholders beside the values it composes.
159
+ * Unsendable entries are filtered, not annotated (DD-006 D6 — the
160
+ * console panel is the diagnosis surface, the prompt is a composition
161
+ * aid). Returns "" when nothing survives the filter — the tool alone
162
+ * still serves text sends inside a 24-hour window.
163
+ */
164
+ export function formatChannelTemplatesSection(channels: ChannelMessagingInfo[]): string {
165
+ let withheld = 0;
166
+ let budget = TEMPLATE_SECTION_CAP;
167
+
168
+ const channelBlocks: string[] = [];
169
+ for (const { channel, templates } of channels) {
170
+ // Sendable-only (unsupportedReason empty), deterministic order —
171
+ // agent behavior must never depend on registry ordering (DD-006 D6).
172
+ const sendable = templates
173
+ .filter((t) => t.unsupportedReason === "")
174
+ .sort((a, b) => a.name.localeCompare(b.name) || a.language.localeCompare(b.language));
175
+
176
+ const kept = sendable.slice(0, Math.max(budget, 0));
177
+ withheld += sendable.length - kept.length;
178
+ budget -= kept.length;
179
+ if (kept.length === 0) {
180
+ continue;
181
+ }
182
+
183
+ const lines = kept.map((t) => {
184
+ const parameters = t.parameterNames.length > 0
185
+ ? `, parameters: ${t.parameterNames.join(", ")}`
186
+ : "";
187
+ const header = t.headerFormat === "IMAGE"
188
+ ? " (requires header_image_link: a public HTTPS image URL)"
189
+ : "";
190
+ return [
191
+ ` - ${t.name} (${t.language}) [${t.category}]${parameters}${header}`,
192
+ ` "${t.bodyText}"`,
193
+ ].join("\n");
194
+ });
195
+ channelBlocks.push([`channel: ${channel.channel} (${channel.provider})`, ...lines].join("\n"));
196
+ }
197
+
198
+ if (channelBlocks.length === 0) {
199
+ return "";
200
+ }
201
+
202
+ const footer = withheld > 0
203
+ ? [`(${withheld} more approved template${withheld === 1 ? "" : "s"} not shown)`]
204
+ : [];
205
+ return [
206
+ "<available_channel_templates>",
207
+ "You can send business-initiated messages on the channels below with the",
208
+ "send_channel_message tool. Outside a 24-hour customer-service window the",
209
+ "provider only accepts a pre-approved template, so prefer a template. Fill",
210
+ "every placeholder from the conversation; never invent a value.",
211
+ "",
212
+ ...channelBlocks,
213
+ ...footer,
214
+ "</available_channel_templates>",
215
+ ].join("\n");
216
+ }
217
+
218
+ /**
219
+ * Expected absences log quietly; anything else warns so an operator can
220
+ * diagnose a mis-provisioned credential without failing the execution.
221
+ * UNIMPLEMENTED is a control plane predating the discovery RPC — the
222
+ * DD-006 D4 deploy-order self-healing case.
223
+ */
224
+ function logDiscoveryFailure(what: string, err: unknown): void {
225
+ const ce = ConnectError.from(err);
226
+ const expected =
227
+ ce.code === Code.Unimplemented ||
228
+ ce.code === Code.FailedPrecondition ||
229
+ ce.code === Code.Unavailable;
230
+ if (expected) {
231
+ console.debug(`[channel-attachment] ${what} degraded to honest absence: ${ce.message}`);
232
+ } else {
233
+ console.warn(
234
+ `[channel-attachment] ${what} failed unexpectedly (no tool, no section): ${ce.message}`,
235
+ );
236
+ }
237
+ }
@@ -33,6 +33,7 @@
33
33
 
34
34
  import type { DatastoreUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
35
35
  import type { ResolvedMcpServer } from "./mcp-resolver.js";
36
+ import { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
36
37
 
37
38
  /**
38
39
  * The synthesized attachment's slug. Reserved: a user McpServer with
@@ -44,25 +45,6 @@ export const DATASTORE_ATTACHMENT_SLUG = "stigmer-records";
44
45
  /** The bridge route serving the records-only roster (mcp-server T05 R1). */
45
46
  export const RECORDS_ROUTE = "/records";
46
47
 
47
- export interface DatastoreAttachmentOptions {
48
- /**
49
- * The bridge's HTTP endpoint (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
50
- * https://mcp.stigmer.ai). Null selects the OSS stdio shape.
51
- */
52
- bridgeEndpoint: string | null;
53
- /**
54
- * The execution's session-scoped credential (the sandbox token a
55
- * cloud runner holds, or the desktop runner's exchanged scoped
56
- * token). Null attaches no Authorization header (OSS/local).
57
- */
58
- credential: string | null;
59
- /**
60
- * The stigmer backend endpoint the stdio child dials
61
- * (config.stigmerBackendEndpoint). Only used for the OSS shape.
62
- */
63
- backendEndpoint: string;
64
- }
65
-
66
48
  /**
67
49
  * Synthesize the records attachment for an agent's datastore usages.
68
50
  * Returns undefined when the agent uses no datastores — the attachment
@@ -70,7 +52,7 @@ export interface DatastoreAttachmentOptions {
70
52
  */
71
53
  export function synthesizeDatastoreAttachment(
72
54
  datastoreUsages: DatastoreUsage[],
73
- options: DatastoreAttachmentOptions,
55
+ options: SynthesizedAttachmentOptions,
74
56
  ): ResolvedMcpServer | undefined {
75
57
  if (datastoreUsages.length === 0) {
76
58
  return undefined;
@@ -107,28 +89,6 @@ export function synthesizeDatastoreAttachment(
107
89
  };
108
90
  }
109
91
 
110
- /**
111
- * Inject the synthesized attachment into a resolved server list —
112
- * AFTER resolve + backfill (see file header). A user server shadowing
113
- * the reserved slug is replaced, loudly.
114
- */
115
- export function injectDatastoreAttachment(
116
- resolvedServers: ResolvedMcpServer[],
117
- attachment: ResolvedMcpServer,
118
- ): ResolvedMcpServer[] {
119
- const shadowed = resolvedServers.some((s) => s.slug === attachment.slug);
120
- if (shadowed) {
121
- console.warn(
122
- `MCP server slug "${attachment.slug}" is reserved for the datastore ` +
123
- "records attachment; the user-defined server is replaced.",
124
- );
125
- }
126
- return [
127
- ...resolvedServers.filter((s) => s.slug !== attachment.slug),
128
- attachment,
129
- ];
130
- }
131
-
132
92
  /**
133
93
  * The `<available_datastores>` prompt section (DD-005 SD-5, the
134
94
  * skills-section precedent): names the attached datastores and points
@@ -152,15 +112,3 @@ export function formatDatastoresSection(datastoreUsages: DatastoreUsage[]): stri
152
112
  "</available_datastores>",
153
113
  ].join("\n");
154
114
  }
155
-
156
- /**
157
- * The gRPC dial target (host:port) for a backend endpoint URL — the
158
- * shape STIGMER_SERVER_ADDRESS wants (the bridge warns on schemes).
159
- */
160
- function grpcTarget(endpoint: string): string {
161
- try {
162
- return new URL(endpoint).host;
163
- } catch {
164
- return endpoint;
165
- }
166
- }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared mechanics of runner-synthesized MCP attachments — the pieces
3
+ * the datastore records attachment (T05) and the channel messaging
4
+ * attachment (proactive-messaging DD-006 D8) have in common, extracted
5
+ * when the second attachment arrived.
6
+ *
7
+ * A synthesized attachment is a first-party MCP server entry the runner
8
+ * builds itself (no McpServer resource, no Environment, no credential in
9
+ * any manifest) on a RESERVED slug. Approval-freedom is structural, not
10
+ * configured: empty toolApprovals + pinnedToolApprovals mean
11
+ * mergeApprovalPolicies emits no entries, and discoveredCapabilitiesEmpty
12
+ * false + no McpServerUsage keep the connect backfill's destructiveHint
13
+ * tightener structurally unable to touch it. Callers must still inject
14
+ * AFTER resolve + backfill; every harness call site does.
15
+ */
16
+
17
+ import type { ResolvedMcpServer } from "./mcp-resolver.js";
18
+
19
+ /**
20
+ * Connection options for a synthesized attachment — one shape for every
21
+ * attachment because the deployment topology, not the domain, decides
22
+ * the connection.
23
+ */
24
+ export interface SynthesizedAttachmentOptions {
25
+ /**
26
+ * The bridge's HTTP endpoint (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
27
+ * https://mcp.stigmer.ai). Null selects the OSS/local stdio shape.
28
+ */
29
+ bridgeEndpoint: string | null;
30
+ /**
31
+ * The execution's session-scoped credential (the sandbox token a
32
+ * cloud runner holds, or the desktop runner's exchanged scoped
33
+ * token). Null attaches no Authorization header (OSS/local).
34
+ */
35
+ credential: string | null;
36
+ /**
37
+ * The stigmer backend endpoint the stdio child dials
38
+ * (config.stigmerBackendEndpoint). Only used for the OSS shape.
39
+ */
40
+ backendEndpoint: string;
41
+ }
42
+
43
+ /**
44
+ * Inject a synthesized attachment into a resolved server list — AFTER
45
+ * resolve + backfill (see the module header). A user server shadowing
46
+ * the reserved slug is replaced, loudly; `label` names the attachment
47
+ * in that warning (e.g. "datastore records").
48
+ */
49
+ export function injectSynthesizedAttachment(
50
+ resolvedServers: ResolvedMcpServer[],
51
+ attachment: ResolvedMcpServer,
52
+ label: string,
53
+ ): ResolvedMcpServer[] {
54
+ const shadowed = resolvedServers.some((s) => s.slug === attachment.slug);
55
+ if (shadowed) {
56
+ console.warn(
57
+ `MCP server slug "${attachment.slug}" is reserved for the ${label} ` +
58
+ "attachment; the user-defined server is replaced.",
59
+ );
60
+ }
61
+ return [
62
+ ...resolvedServers.filter((s) => s.slug !== attachment.slug),
63
+ attachment,
64
+ ];
65
+ }
66
+
67
+ /**
68
+ * The gRPC dial target (host:port) for a backend endpoint URL — the
69
+ * shape STIGMER_SERVER_ADDRESS wants (the bridge warns on schemes).
70
+ */
71
+ export function grpcTarget(endpoint: string): string {
72
+ try {
73
+ return new URL(endpoint).host;
74
+ } catch {
75
+ return endpoint;
76
+ }
77
+ }
@@ -404,9 +404,9 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
404
404
  expect(config.message).not.toContain("${ $context");
405
405
  expect(config.message).not.toContain("${ $input");
406
406
  expect(config.env).toEqual({ GITHUB_TOKEN: "${.secrets.GITHUB_TOKEN}" });
407
- expect(config.config?.model).toBe("claude-3-5-sonnet");
408
- expect(config.config?.timeout).toBe(300);
409
- expect(config.config?.temperature).toBe(0.2);
407
+ expect(config.run_config?.model_name).toBe("claude-3-5-sonnet");
408
+ expect(config.run_config?.max_cost_usd).toBe(0.75);
409
+ expect(config.run_config?.service_tier).toBe("SERVICE_TIER_STANDARD");
410
410
  expect(config.harness).toBe("HARNESS_NATIVE");
411
411
  expect(config.output?.schema).toBeDefined();
412
412
  expect(config.output?.schema.required).toContain("severity");
@@ -428,7 +428,6 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
428
428
  " with:",
429
429
  ' agent: "stigmer/code-reviewer"',
430
430
  ' message: "Review this"',
431
- ' org: "stigmer"',
432
431
  ].join("\n");
433
432
 
434
433
  const model = loadWorkflowFromYaml(crossOrgYaml);
@@ -444,7 +443,6 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
444
443
  expect(mockCallAgent).toHaveBeenCalledOnce();
445
444
  const [config] = mockCallAgent.mock.calls[0];
446
445
  expect(config.agent).toBe("stigmer/code-reviewer");
447
- expect(config.org).toBe("stigmer");
448
446
  expect(config.message).toBe("Review this");
449
447
  });
450
448
 
@@ -1048,9 +1046,11 @@ describe("Golden Execution — Structured Output Pipeline", () => {
1048
1046
  expect(config.message).toContain("Date: 2026-05-26");
1049
1047
  expect(config.message).not.toContain("${ $env");
1050
1048
 
1051
- // Config and harness preserved
1052
- expect(config.config?.model).toBe("claude-sonnet-4");
1053
- expect(config.config?.timeout).toBe(300);
1049
+ // Run config and harness preserved. The tier arrives canonical from the
1050
+ // golden's "fast" shorthand — the loader mapping riding a full workflow
1051
+ // load (#357).
1052
+ expect(config.run_config?.model_name).toBe("claude-sonnet-4");
1053
+ expect(config.run_config?.service_tier).toBe("SERVICE_TIER_FAST");
1054
1054
  expect(config.harness).toBe("HARNESS_CURSOR");
1055
1055
  expect(metadata.taskName).toBe("analyze_player_data");
1056
1056