@stigmer/runner 3.2.2 → 3.3.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 (117) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
  3. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  4. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  5. package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
  6. package/dist/activities/execute-cursor/approval-state.js +13 -1
  7. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  8. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +9 -1
  9. package/dist/activities/execute-cursor/blueprint-resolver.js +1 -0
  10. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +38 -0
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +2 -0
  14. package/dist/activities/execute-cursor/index.js +56 -13
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
  17. package/dist/activities/execute-cursor/message-translator.js +78 -2
  18. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  19. package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -1
  20. package/dist/activities/execute-cursor/prompt-builder.js +4 -0
  21. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  22. package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
  23. package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
  24. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  25. package/dist/activities/execute-cursor/turn-boundary.js +15 -2
  26. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
  28. package/dist/activities/execute-deep-agent/hitl.js +48 -1
  29. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/index.js +10 -1
  31. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +5 -0
  33. package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
  34. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
  36. package/dist/activities/execute-deep-agent/setup.js +36 -3
  37. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
  39. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/status-builder.js +6 -0
  41. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
  43. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  44. package/dist/activities/workflow-event-activities.js +9 -0
  45. package/dist/activities/workflow-event-activities.js.map +1 -1
  46. package/dist/config.d.ts +8 -0
  47. package/dist/config.js +2 -0
  48. package/dist/config.js.map +1 -1
  49. package/dist/middleware/approval-gate.d.ts +20 -0
  50. package/dist/middleware/approval-gate.js +16 -1
  51. package/dist/middleware/approval-gate.js.map +1 -1
  52. package/dist/runner-manager.js +3 -0
  53. package/dist/runner-manager.js.map +1 -1
  54. package/dist/runner.js +3 -0
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/approval-policy.d.ts +24 -1
  57. package/dist/shared/approval-policy.js +35 -1
  58. package/dist/shared/approval-policy.js.map +1 -1
  59. package/dist/shared/datastore-attachment.d.ts +79 -0
  60. package/dist/shared/datastore-attachment.js +129 -0
  61. package/dist/shared/datastore-attachment.js.map +1 -0
  62. package/dist/shared/with-timeout.d.ts +10 -0
  63. package/dist/shared/with-timeout.js +14 -1
  64. package/dist/shared/with-timeout.js.map +1 -1
  65. package/dist/workflow-engine/tasks/human-input.js +1 -0
  66. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  67. package/dist/workflow-engine/types.d.ts +22 -0
  68. package/dist/workflow-engine/types.js.map +1 -1
  69. package/dist/workflows/human-input-orchestrator.d.ts +2 -1
  70. package/dist/workflows/human-input-orchestrator.js +2 -1
  71. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  74. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  75. package/src/activities/__tests__/workflow-event-activities.test.ts +29 -0
  76. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
  77. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +25 -0
  78. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
  79. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
  80. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
  81. package/src/activities/execute-cursor/approval-state.ts +32 -1
  82. package/src/activities/execute-cursor/blueprint-resolver.ts +10 -1
  83. package/src/activities/execute-cursor/hook-script.ts +40 -0
  84. package/src/activities/execute-cursor/index.ts +64 -15
  85. package/src/activities/execute-cursor/message-translator.ts +82 -1
  86. package/src/activities/execute-cursor/prompt-builder.ts +12 -1
  87. package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
  88. package/src/activities/execute-cursor/turn-boundary.ts +23 -0
  89. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  91. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  92. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
  93. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  94. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  95. package/src/activities/execute-deep-agent/hitl.ts +51 -0
  96. package/src/activities/execute-deep-agent/index.ts +11 -1
  97. package/src/activities/execute-deep-agent/prompt-builder.ts +9 -0
  98. package/src/activities/execute-deep-agent/setup.ts +57 -2
  99. package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
  100. package/src/activities/execute-deep-agent/status-builder.ts +7 -0
  101. package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
  102. package/src/activities/workflow-event-activities.ts +9 -0
  103. package/src/config.ts +11 -0
  104. package/src/middleware/__tests__/approval-gate.test.ts +95 -0
  105. package/src/middleware/approval-gate.ts +37 -0
  106. package/src/runner-manager.ts +3 -0
  107. package/src/runner.ts +3 -0
  108. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  109. package/src/shared/__tests__/datastore-attachment.test.ts +165 -0
  110. package/src/shared/__tests__/with-timeout.test.ts +16 -1
  111. package/src/shared/approval-policy.ts +41 -2
  112. package/src/shared/datastore-attachment.ts +166 -0
  113. package/src/shared/with-timeout.ts +15 -1
  114. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
  115. package/src/workflow-engine/tasks/human-input.ts +1 -0
  116. package/src/workflow-engine/types.ts +23 -0
  117. package/src/workflows/human-input-orchestrator.ts +2 -1
@@ -47,7 +47,7 @@ import { StatusBuilder } from "./status-builder.js";
47
47
  import { InlinePublisher } from "./inline-publisher.js";
48
48
  import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
49
49
  import { processPostStream } from "./post-stream.js";
50
- import { resolveResumeInput, reconcileNonExecutingDecisions, type GraphStateSnapshot } from "./hitl.js";
50
+ import { resolveResumeInput, reconcileNonExecutingDecisions, reconcileUnattendedSkips, type GraphStateSnapshot } from "./hitl.js";
51
51
  import { captureApprovalArtifacts } from "./approval-file-change.js";
52
52
  import {
53
53
  applyCaptureDecisions,
@@ -144,6 +144,7 @@ export function createDeepAgentActivities(config: Config) {
144
144
  toolServerMap: setup.toolServerMap,
145
145
  leasedCategories: setup.leasedCategories,
146
146
  globalBypass: setup.globalBypass,
147
+ unattended: setup.unattended,
147
148
  });
148
149
 
149
150
  const resume = resolveResumeInput(
@@ -408,6 +409,7 @@ export function createDeepAgentActivities(config: Config) {
408
409
  toolServerMap: setup.toolServerMap,
409
410
  leasedCategories: setup.leasedCategories,
410
411
  globalBypass: setup.globalBypass,
412
+ unattended: setup.unattended,
411
413
  },
412
414
  streamVersion: setup.streamVersion,
413
415
  // Mid-run live capture (DD-32 / DD-33): attach file_change_progress
@@ -465,6 +467,14 @@ export function createDeepAgentActivities(config: Config) {
465
467
  // completion persists below so the terminal status is what is persisted.
466
468
  reconcileNonExecutingDecisions(initialStatus);
467
469
 
470
+ // Terminalize every tool call the gate auto-skipped under UNATTENDED
471
+ // approval mode (DD-014): the skip has no human decision behind it, so
472
+ // reconcileNonExecutingDecisions cannot see it — this sibling folds the
473
+ // gate's registry into terminal SKIPPED rows with UNATTENDED_SKIP
474
+ // provenance, whatever transient status the stream left behind. No-op
475
+ // for interactive executions (empty registry).
476
+ reconcileUnattendedSkips(initialStatus, setup.unattendedSkips);
477
+
468
478
  // Turn boundary (capture mode): capture the candidate change set from the
469
479
  // git diff and author CANDIDATE_CAPTURED, then stamp the flowed file-edit
470
480
  // rows with the change set id — they stay visible in place as
@@ -92,6 +92,11 @@ export interface PromptBuilderInput {
92
92
  provisionResults: ProvisionResult[];
93
93
  containerRoot: string;
94
94
  skillsPromptSection: string;
95
+ /**
96
+ * The `<available_datastores>` section (shared/datastore-attachment.ts
97
+ * formatDatastoresSection); empty when the agent uses no datastores.
98
+ */
99
+ datastoresPromptSection?: string;
95
100
  workspaceFileRefs: string[];
96
101
  workspaceRoot: string;
97
102
  injectedFiles: InjectedFile[];
@@ -156,6 +161,10 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
156
161
  prompt += input.skillsPromptSection;
157
162
  }
158
163
 
164
+ if (input.datastoresPromptSection) {
165
+ prompt += "\n\n" + input.datastoresPromptSection;
166
+ }
167
+
159
168
  if (input.workspaceFileRefs.length > 0) {
160
169
  const refSection = buildReferencedFilesSection(
161
170
  input.workspaceFileRefs,
@@ -28,6 +28,11 @@ import { readSenderIdentity } from "../../shared/sender-identity.js";
28
28
  import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-manager.js";
29
29
  import { resolveMcpServers } from "../../shared/mcp-resolver.js";
30
30
  import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
31
+ import {
32
+ formatDatastoresSection,
33
+ injectDatastoreAttachment,
34
+ synthesizeDatastoreAttachment,
35
+ } from "../../shared/datastore-attachment.js";
31
36
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
32
37
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
33
38
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
@@ -59,6 +64,7 @@ import {
59
64
  import {
60
65
  mergeApprovalPolicies,
61
66
  deriveActiveLeases,
67
+ isUnattendedApprovalMode,
62
68
  type MergedToolPolicy,
63
69
  } from "../../shared/approval-policy.js";
64
70
  import type { ToolApprovalCategory } from "../../shared/tool-kind.js";
@@ -125,6 +131,20 @@ export interface SetupResult {
125
131
  * the gate (see ActiveLeases / deriveActiveLeases).
126
132
  */
127
133
  readonly globalBypass: boolean;
134
+ /**
135
+ * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED,
136
+ * stamped by approver-less surfaces — channels, guest shares). The gate
137
+ * resolves gated tools as automatic skips instead of interrupting; the
138
+ * builders never seed WAITING_APPROVAL. See isUnattendedApprovalMode.
139
+ */
140
+ readonly unattended: boolean;
141
+ /**
142
+ * Tool-call ids the gate auto-skipped under {@link unattended} this turn.
143
+ * Written ONLY by the approval gate (parent + inherited sub-agent gates
144
+ * share this instance); read by reconcileUnattendedSkips after the stream
145
+ * to stamp terminal SKIPPED rows. Empty set when not unattended.
146
+ */
147
+ readonly unattendedSkips: Set<string>;
128
148
  readonly hasStructuredOutput: boolean;
129
149
  readonly streamVersion: "v2" | "v3";
130
150
  /**
@@ -299,16 +319,17 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
299
319
  ...(agent.spec!.mcpServerUsages || []),
300
320
  ...(session.spec!.mcpServerUsages || []),
301
321
  ];
322
+ const datastoreUsages = agent.spec!.datastoreUsages || [];
302
323
 
303
324
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
304
- if (mcpServerUsages.length > 0) {
325
+ if (mcpServerUsages.length > 0 || datastoreUsages.length > 0) {
305
326
  await reportSetupProgress(client, executionId, "Connecting tools…");
306
327
  resolvedMcpServers = await resolveMcpServers(
307
328
  client, mcpServerUsages, envResult.mergedEnvVars,
308
329
  );
309
330
 
310
331
  const sessionOrg = session.metadata?.org ?? "";
311
- const backfilledServers = await backfillMcpServersIfNeeded(
332
+ let backfilledServers = await backfillMcpServersIfNeeded(
312
333
  client,
313
334
  resolvedMcpServers.resolvedServers,
314
335
  mcpServerUsages,
@@ -317,6 +338,25 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
317
338
  undefined,
318
339
  envResult.secretKeys,
319
340
  );
341
+
342
+ // The datastore records attachment (T05) — injected AFTER resolve +
343
+ // backfill so the destructiveHint tightener can never gate it; empty
344
+ // approval maps keep it approval-free by construction (see
345
+ // shared/datastore-attachment.ts).
346
+ if (datastoreUsages.length > 0) {
347
+ const scopedCredential =
348
+ (await client.acquireScopedRunnerToken({ agentExecutionId: executionId }))
349
+ ?? config.stigmerTokenRef?.current
350
+ ?? config.stigmerToken;
351
+ const attachment = synthesizeDatastoreAttachment(datastoreUsages, {
352
+ bridgeEndpoint: config.mcpBridgeEndpoint,
353
+ credential: scopedCredential,
354
+ backendEndpoint: config.stigmerBackendEndpoint,
355
+ });
356
+ if (attachment) {
357
+ backfilledServers = injectDatastoreAttachment(backfilledServers, attachment);
358
+ }
359
+ }
320
360
  resolvedMcpServers = { resolvedServers: backfilledServers };
321
361
 
322
362
  mcpConnection = await connectMcpServers(
@@ -383,6 +423,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
383
423
  provisionResults,
384
424
  containerRoot: workspaceBackend.rootDir,
385
425
  skillsPromptSection,
426
+ datastoresPromptSection: datastoreUsages.length > 0
427
+ ? formatDatastoresSection(datastoreUsages)
428
+ : undefined,
386
429
  workspaceFileRefs: execution.spec!.workspaceFileRefs || [],
387
430
  workspaceRoot: workspaceBackend.rootDir,
388
431
  injectedFiles,
@@ -440,6 +483,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
440
483
  leases,
441
484
  );
442
485
 
486
+ // Unattended approval mode (DD-014): approver-less surfaces (channels,
487
+ // guest shares) stamp APPROVAL_MODE_UNATTENDED, and the gate resolves
488
+ // gated tools as automatic skips instead of interrupting. The registry is
489
+ // the gate→reconciler channel for the skipped tool-call ids; one instance
490
+ // is shared with sub-agent gates via the inherited config.
491
+ const unattended = isUnattendedApprovalMode(execution);
492
+ const unattendedSkips = new Set<string>();
493
+
443
494
  // The approval gate config is the single source of truth for HITL gating,
444
495
  // built once and inherited verbatim by sub-agents (so a mutating tool inside
445
496
  // a sub-agent is gated identically to one in the parent). Null under the
@@ -476,6 +527,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
476
527
  isCapturablePath,
477
528
  captureIgnored: captureMode && !!artifactStorage,
478
529
  recordBlockedSecret: (rawPath: string) => casObserver.recordBlockedSecret(rawPath),
530
+ unattended,
531
+ unattendedSkips,
479
532
  }
480
533
  : null;
481
534
 
@@ -648,6 +701,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
648
701
  toolServerMap,
649
702
  leasedCategories: leases.categories,
650
703
  globalBypass,
704
+ unattended,
705
+ unattendedSkips,
651
706
  hasStructuredOutput: !!outputSchema,
652
707
  streamVersion,
653
708
  casObserver,
@@ -157,6 +157,14 @@ export interface ApprovalProvenanceInputs {
157
157
  readonly leasedCategories?: ReadonlySet<ToolApprovalCategory>;
158
158
  /** Pre-armed spec.auto_approve_all — the one whole-run global bypass. */
159
159
  readonly globalBypass: boolean;
160
+ /**
161
+ * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
162
+ * the gate auto-skips gated calls instead of interrupting, so the builders
163
+ * must NOT seed WAITING_APPROVAL rows or flip the phase to
164
+ * WAITING_FOR_APPROVAL — the row streams as RUNNING and the post-stream
165
+ * `reconcileUnattendedSkips` stamps the terminal SKIPPED + provenance.
166
+ */
167
+ readonly unattended?: boolean;
160
168
  }
161
169
 
162
170
  /** Shared empty set so a provider without leases allocates nothing per call. */
@@ -319,6 +319,13 @@ export class StatusBuilder {
319
319
  return { requiresApproval: false, message: "", serverSlug };
320
320
  }
321
321
 
322
+ // Unattended mode: the gate auto-skips instead of interrupting, so no row
323
+ // is ever WAITING_APPROVAL and the phase never flips — the streamed row
324
+ // runs to its skip result and reconcileUnattendedSkips terminalizes it.
325
+ if (this.approvalProvider.unattended) {
326
+ return { requiresApproval: false, message: "", serverSlug };
327
+ }
328
+
322
329
  if (serverSlug) {
323
330
  const key = `${serverSlug}/${toolName}`;
324
331
  const policy = this.approvalProvider.policies.get(key);
@@ -419,6 +419,13 @@ export class V3StatusBuilder implements ExecutionStatusWriter {
419
419
  return { requiresApproval: false, message: "", serverSlug };
420
420
  }
421
421
 
422
+ // Unattended mode: the gate auto-skips instead of interrupting, so no row
423
+ // is ever WAITING_APPROVAL and the phase never flips — the streamed row
424
+ // runs to its skip result and reconcileUnattendedSkips terminalizes it.
425
+ if (this.approvalProvider.unattended) {
426
+ return { requiresApproval: false, message: "", serverSlug };
427
+ }
428
+
422
429
  if (serverSlug) {
423
430
  const key = `${serverSlug}/${toolName}`;
424
431
  const policy = this.approvalProvider.policies.get(key);
@@ -34,6 +34,7 @@ import {
34
34
  ArtifactCreatedPayloadSchema,
35
35
  HumanInputOutcomeInfoSchema,
36
36
  } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
37
+ import { ApiResourceAuditActorSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
37
38
  import { WorkflowExecutionStatusSchema, WorkflowTaskSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
38
39
  import {
39
40
  WorkflowExecutionUpdateStatusInputSchema,
@@ -309,6 +310,14 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
309
310
  case: "approvalResolved",
310
311
  value: create(ApprovalResolvedPayloadSchema, {
311
312
  resolvedBy: desc.resolvedBy,
313
+ resolvedByActor: desc.resolvedByActor
314
+ ? create(ApiResourceAuditActorSchema, {
315
+ id: desc.resolvedByActor.id,
316
+ displayName: desc.resolvedByActor.display_name ?? "",
317
+ email: desc.resolvedByActor.email ?? "",
318
+ avatar: desc.resolvedByActor.avatar ?? "",
319
+ })
320
+ : undefined,
312
321
  comment: desc.comment,
313
322
  waitDurationMs: BigInt(desc.waitDurationMs),
314
323
  }),
package/src/config.ts CHANGED
@@ -60,6 +60,14 @@ export interface Config {
60
60
  readonly temporalNamespace: string;
61
61
  readonly stigmerBackendEndpoint: string;
62
62
  readonly stigmerToken: string | null;
63
+ /**
64
+ * The MCP bridge endpoint for the runner-synthesized datastore
65
+ * records attachment (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
66
+ * https://mcp.stigmer.ai). Null selects the OSS/local shape: a
67
+ * spawned `stigmer mcp-server` stdio child against the local backend.
68
+ * See shared/datastore-attachment.ts.
69
+ */
70
+ readonly mcpBridgeEndpoint: string | null;
63
71
  /**
64
72
  * Bearer credential for the Cursor SDK's Connect RPC transport.
65
73
  *
@@ -153,6 +161,8 @@ export function loadConfig(): Config {
153
161
  ? requireEnv("STIGMER_TOKEN")
154
162
  : (process.env.STIGMER_TOKEN ?? null);
155
163
 
164
+ const mcpBridgeEndpoint = process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null;
165
+
156
166
  // In proxy mode, pass STIGMER_TOKEN as the SDK's API key. The SDK exchanges
157
167
  // it (via REST proxy → Tomcat → Cursor) for an access token. The HTTP/2
158
168
  // interceptor injects x-stigmer-auth for BiDi proxy authentication while
@@ -202,6 +212,7 @@ export function loadConfig(): Config {
202
212
  temporalNamespace,
203
213
  stigmerBackendEndpoint,
204
214
  stigmerToken,
215
+ mcpBridgeEndpoint,
205
216
  cursorApiKey,
206
217
  workspaceRootDir,
207
218
  mode,
@@ -784,4 +784,99 @@ describe("ApprovalGateMiddleware", () => {
784
784
  expect(result).toBeInstanceOf(ToolMessage);
785
785
  expect((result as ToolMessage).content).toContain("unknown action");
786
786
  });
787
+
788
+ describe("unattended approval mode (DD-014)", () => {
789
+ const gatedMcpPolicies = new Map<string, MergedToolPolicy>([
790
+ ["srv/gated_tool", {
791
+ toolName: "gated_tool",
792
+ mcpServerSlug: "srv",
793
+ requiresApproval: true,
794
+ approvalMessage: "Run gated_tool",
795
+ source: "classifier_default",
796
+ }],
797
+ ]);
798
+
799
+ it("skips a gated MCP tool without interrupting and records it in the registry", async () => {
800
+ const unattendedSkips = new Set<string>();
801
+ const mw = createApprovalGateMiddleware(makeConfig({
802
+ policies: gatedMcpPolicies,
803
+ toolServerMap: new Map([["gated_tool", "srv"]]),
804
+ unattended: true,
805
+ unattendedSkips,
806
+ }));
807
+
808
+ const handler = vi.fn(passthrough);
809
+ const result = await mw.wrapToolCall!(
810
+ makeRequest({ name: "gated_tool", args: { target: "prod" } }),
811
+ handler,
812
+ );
813
+
814
+ // Gateway invariant: the side effect never ran, and no interrupt was
815
+ // raised — the turn continues instead of parking WAITING_FOR_APPROVAL.
816
+ expect(handler).not.toHaveBeenCalled();
817
+ expect(mockedInterrupt).not.toHaveBeenCalled();
818
+ expect((result as ToolMessage).content).toContain("skipped automatically");
819
+ expect(unattendedSkips.has("call_abc123")).toBe(true);
820
+ });
821
+
822
+ it("skips a gated built-in (shell) the same way", async () => {
823
+ const unattendedSkips = new Set<string>();
824
+ const mw = createApprovalGateMiddleware(makeConfig({
825
+ unattended: true,
826
+ unattendedSkips,
827
+ }));
828
+
829
+ const handler = vi.fn(passthrough);
830
+ const result = await mw.wrapToolCall!(
831
+ makeRequest({ name: "shell", args: { command: "rm -rf /" } }),
832
+ handler,
833
+ );
834
+
835
+ expect(handler).not.toHaveBeenCalled();
836
+ expect(mockedInterrupt).not.toHaveBeenCalled();
837
+ expect((result as ToolMessage).content).toContain("skipped automatically");
838
+ expect(unattendedSkips.has("call_abc123")).toBe(true);
839
+ });
840
+
841
+ it("still runs tools the policy chain auto-approved (un-gating stays the operator lever)", async () => {
842
+ // The operator's tool_approval_overrides path: an un-gated tool is
843
+ // absent from the policy map, so unattended mode never touches it.
844
+ const unattendedSkips = new Set<string>();
845
+ const mw = createApprovalGateMiddleware(makeConfig({
846
+ toolServerMap: new Map([["book_appointment", "clinic"]]),
847
+ unattended: true,
848
+ unattendedSkips,
849
+ }));
850
+
851
+ const result = await mw.wrapToolCall!(
852
+ makeRequest({ name: "book_appointment", args: { slot: "mon-10" } }),
853
+ passthrough,
854
+ );
855
+
856
+ expect((result as ToolMessage).content).toBe("tool result");
857
+ expect(unattendedSkips.size).toBe(0);
858
+ expect(mockedInterrupt).not.toHaveBeenCalled();
859
+ });
860
+
861
+ it("never emits an execution receipt for an unattended skip (no side effect)", async () => {
862
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
863
+ try {
864
+ const mw = createApprovalGateMiddleware(makeConfig({
865
+ policies: gatedMcpPolicies,
866
+ toolServerMap: new Map([["gated_tool", "srv"]]),
867
+ unattended: true,
868
+ unattendedSkips: new Set<string>(),
869
+ }));
870
+
871
+ await mw.wrapToolCall!(makeRequest({ name: "gated_tool" }), passthrough);
872
+
873
+ const receipts = logSpy.mock.calls
874
+ .map((c) => String(c[0]))
875
+ .filter((line) => line.includes("[hitl-gateway] receipt"));
876
+ expect(receipts).toHaveLength(0);
877
+ } finally {
878
+ logSpy.mockRestore();
879
+ }
880
+ });
881
+ });
787
882
  });
@@ -51,6 +51,7 @@ import {
51
51
  type PolicySource,
52
52
  POLICY_ENGINE_VERSION,
53
53
  resolveApprovalMessage,
54
+ unattendedSkipMessage,
54
55
  } from "../shared/approval-policy.js";
55
56
  import { toolApprovalCategory, type ToolApprovalCategory } from "../shared/tool-kind.js";
56
57
  import { extractFilePath } from "../shared/file-tools.js";
@@ -121,6 +122,26 @@ export interface ApprovalGateConfig {
121
122
  * secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
122
123
  */
123
124
  readonly recordBlockedSecret?: (rawPath: string) => void;
125
+ /**
126
+ * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
127
+ * the creating surface — a messaging channel, a guest share — has no
128
+ * approver, so a gated tool is resolved as an automatic SKIP (the model is
129
+ * told to adapt) instead of `interrupt()`. The execution never enters
130
+ * WAITING_FOR_APPROVAL. What is gated is unchanged — only the resolution
131
+ * differs; an operator un-gates a specific tool for the agent via
132
+ * tool_approval_overrides, not by weakening this mode.
133
+ */
134
+ readonly unattended?: boolean;
135
+ /**
136
+ * Registry of tool-call ids this gate auto-skipped under {@link unattended}
137
+ * — the gate is the single WRITER; `reconcileUnattendedSkips` (hitl.ts) is
138
+ * the reader that folds each id into a terminal TOOL_CALL_SKIPPED row with
139
+ * UNATTENDED_SKIP provenance after the stream. In-process, per-execution
140
+ * state keyed by the framework's own tool-call id (direct identity, no
141
+ * matching); inherited verbatim by sub-agent gates so their skips land in
142
+ * the same registry.
143
+ */
144
+ readonly unattendedSkips?: Set<string>;
124
145
  }
125
146
 
126
147
  interface ApprovalDecision {
@@ -249,6 +270,22 @@ export function createApprovalGateMiddleware(
249
270
  return await handler(request);
250
271
  }
251
272
 
273
+ // Unattended surfaces (channels, guest shares) have no approver, so a
274
+ // gate that would interrupt() here resolves as an automatic SKIP: the
275
+ // tool does NOT run (the gateway invariant holds — no side effect
276
+ // without a backing authorization), the model is told to adapt in plain
277
+ // language, and the turn continues to normal completion instead of
278
+ // parking in WAITING_FOR_APPROVAL forever. The registry entry lets the
279
+ // post-stream reconciler stamp the terminal SKIPPED row + provenance.
280
+ if (config.unattended) {
281
+ config.unattendedSkips?.add(toolCall.id);
282
+ return new ToolMessage({
283
+ content: unattendedSkipMessage(toolName),
284
+ tool_call_id: toolCall.id,
285
+ name: toolName,
286
+ });
287
+ }
288
+
252
289
  const approvalRequest = {
253
290
  tool_call_id: toolCall.id,
254
291
  tool_name: toolName,
@@ -583,6 +583,9 @@ export function mapManagerOptionsToConfig(
583
583
  temporalNamespace: options.temporalNamespace ?? "default",
584
584
  stigmerBackendEndpoint: normalizeEndpoint(options.stigmerEndpoint),
585
585
  stigmerToken: options.stigmerToken ?? null,
586
+ // Env-only like the env-loaded path (loadConfig): the bridge endpoint
587
+ // is deployment topology, not per-session state.
588
+ mcpBridgeEndpoint: process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null,
586
589
  stigmerTokenRef: tokenRef,
587
590
  stigmerRunnerTokenRef: runnerTokenRef,
588
591
  cursorApiKey: proxyActive
package/src/runner.ts CHANGED
@@ -268,6 +268,9 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
268
268
  temporalNamespace: options.temporalNamespace ?? "default",
269
269
  stigmerBackendEndpoint: normalizeEndpoint(options.stigmerEndpoint),
270
270
  stigmerToken: options.stigmerToken ?? null,
271
+ // Env-only like the env-loaded path (loadConfig): the bridge endpoint
272
+ // is deployment topology, not per-session state.
273
+ mcpBridgeEndpoint: process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null,
271
274
  cursorApiKey: proxyActive
272
275
  ? (options.cursorApiKey ?? "proxy-managed")
273
276
  : (options.cursorApiKey ?? ""),
@@ -420,6 +420,7 @@ describe("loadArtifactStorageConfig", () => {
420
420
  temporalAddress: "localhost:7233",
421
421
  temporalNamespace: "default",
422
422
  stigmerBackendEndpoint: "http://localhost:7234",
423
+ mcpBridgeEndpoint: null,
423
424
  cursorApiKey: "",
424
425
  workspaceRootDir: "/tmp",
425
426
  maxConcurrentActivities: 5,
@@ -0,0 +1,165 @@
1
+ // The synthesized datastore records attachment (T05): connection
2
+ // shapes, the approval-free-by-construction property, and the
3
+ // structural immunity to the connect backfill's destructiveHint
4
+ // tightener — the two hazards that would otherwise silently skip
5
+ // record writes on channels (UNATTENDED mode).
6
+
7
+ import { create } from "@bufbuild/protobuf";
8
+ import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
9
+ import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ import { mergeApprovalPolicies, type ActiveLeases } from "../approval-policy.js";
13
+ import { needsBackfill } from "../connect-backfill.js";
14
+ import {
15
+ DATASTORE_ATTACHMENT_SLUG,
16
+ formatDatastoresSection,
17
+ injectDatastoreAttachment,
18
+ synthesizeDatastoreAttachment,
19
+ } from "../datastore-attachment.js";
20
+ import type { ResolvedMcpServer } from "../mcp-resolver.js";
21
+
22
+ function usage(slug: string) {
23
+ return create(DatastoreUsageSchema, {
24
+ datastoreRef: create(ApiResourceReferenceSchema, { slug }),
25
+ });
26
+ }
27
+
28
+ const NO_LEASES: ActiveLeases = {
29
+ global: false,
30
+ categories: new Set(),
31
+ servers: new Set(),
32
+ };
33
+
34
+ describe("synthesizeDatastoreAttachment", () => {
35
+ it("returns undefined when the agent uses no datastores", () => {
36
+ expect(
37
+ synthesizeDatastoreAttachment([], {
38
+ bridgeEndpoint: "https://mcp.stigmer.ai",
39
+ credential: "tok",
40
+ backendEndpoint: "http://localhost:7234",
41
+ }),
42
+ ).toBeUndefined();
43
+ });
44
+
45
+ it("builds the HTTP shape against the bridge /records route with the injected credential", () => {
46
+ const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
47
+ bridgeEndpoint: "https://mcp.stigmer.ai/",
48
+ credential: "sandbox-token",
49
+ backendEndpoint: "http://localhost:7234",
50
+ });
51
+
52
+ expect(attachment).toMatchObject({
53
+ slug: DATASTORE_ATTACHMENT_SLUG,
54
+ connectionType: "http",
55
+ url: "https://mcp.stigmer.ai/records",
56
+ headers: { Authorization: "Bearer sandbox-token" },
57
+ });
58
+ });
59
+
60
+ it("omits the Authorization header without a credential", () => {
61
+ const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
62
+ bridgeEndpoint: "https://mcp.stigmer.ai",
63
+ credential: null,
64
+ backendEndpoint: "http://localhost:7234",
65
+ });
66
+ expect(attachment?.headers).toBeUndefined();
67
+ });
68
+
69
+ it("builds the OSS stdio shape: the CLI-embedded bridge with the records roster", () => {
70
+ const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
71
+ bridgeEndpoint: null,
72
+ credential: null,
73
+ backendEndpoint: "http://localhost:7234",
74
+ });
75
+
76
+ expect(attachment).toMatchObject({
77
+ slug: DATASTORE_ATTACHMENT_SLUG,
78
+ connectionType: "stdio",
79
+ command: "stigmer",
80
+ args: ["mcp-server"],
81
+ env: {
82
+ STIGMER_MCP_ROSTER: "records",
83
+ STIGMER_SERVER_ADDRESS: "localhost:7234",
84
+ },
85
+ });
86
+ });
87
+
88
+ it("is approval-free by construction: zero entries in the merged approval map", () => {
89
+ const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
90
+ bridgeEndpoint: "https://mcp.stigmer.ai",
91
+ credential: "tok",
92
+ backendEndpoint: "http://localhost:7234",
93
+ })!;
94
+
95
+ // Absence from the map means auto-approved for EVERY consumer (the
96
+ // Cursor hook, the deep-agent gate) — DD-001 SD-3's structural bypass.
97
+ const merged = mergeApprovalPolicies([attachment], [], NO_LEASES);
98
+ expect(merged.size).toBe(0);
99
+ });
100
+
101
+ it("is structurally immune to the connect backfill (destructiveHint tightener)", () => {
102
+ const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
103
+ bridgeEndpoint: "https://mcp.stigmer.ai",
104
+ credential: "tok",
105
+ backendEndpoint: "http://localhost:7234",
106
+ })!;
107
+
108
+ // If this ever became true, the backfill's connect + classification
109
+ // would force-gate delete_record (it carries destructiveHint), and on
110
+ // channels a gated tool is silently auto-skipped.
111
+ expect(attachment.discoveredCapabilitiesEmpty).toBe(false);
112
+ expect(needsBackfill(attachment)).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe("injectDatastoreAttachment", () => {
117
+ const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
118
+ bridgeEndpoint: "https://mcp.stigmer.ai",
119
+ credential: "tok",
120
+ backendEndpoint: "http://localhost:7234",
121
+ })!;
122
+
123
+ it("appends to the resolved servers", () => {
124
+ const other: ResolvedMcpServer = {
125
+ slug: "github",
126
+ connectionType: "http",
127
+ url: "https://example.com",
128
+ toolApprovals: [],
129
+ pinnedToolApprovals: [],
130
+ discoveredCapabilitiesEmpty: false,
131
+ };
132
+ const result = injectDatastoreAttachment([other], attachment);
133
+ expect(result.map((s) => s.slug)).toEqual(["github", DATASTORE_ATTACHMENT_SLUG]);
134
+ });
135
+
136
+ it("replaces a user server shadowing the reserved slug, loudly", () => {
137
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
138
+ const impostor: ResolvedMcpServer = {
139
+ slug: DATASTORE_ATTACHMENT_SLUG,
140
+ connectionType: "http",
141
+ url: "https://evil.example.com",
142
+ toolApprovals: [],
143
+ pinnedToolApprovals: [],
144
+ discoveredCapabilitiesEmpty: false,
145
+ };
146
+
147
+ const result = injectDatastoreAttachment([impostor], attachment);
148
+
149
+ expect(result).toHaveLength(1);
150
+ expect(result[0].url).toBe("https://mcp.stigmer.ai/records");
151
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("reserved"));
152
+ warnSpy.mockRestore();
153
+ });
154
+ });
155
+
156
+ describe("formatDatastoresSection", () => {
157
+ it("names the attached datastores and points at describe_datastore first", () => {
158
+ const section = formatDatastoresSection([usage("clinic"), usage("inventory")]);
159
+ expect(section).toContain("<available_datastores>");
160
+ expect(section).toContain("- clinic");
161
+ expect(section).toContain("- inventory");
162
+ expect(section).toContain("describe_datastore");
163
+ expect(section).toContain("</available_datastores>");
164
+ });
165
+ });