@stigmer/runner 3.2.1 → 3.2.3

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 (145) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-transform.js +12 -2
  3. package/dist/activities/call-transform.js.map +1 -1
  4. package/dist/activities/call-validate.js +20 -2
  5. package/dist/activities/call-validate.js.map +1 -1
  6. package/dist/activities/discover-mcp-server.js +6 -20
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
  9. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  10. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  11. package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
  12. package/dist/activities/execute-cursor/approval-state.js +13 -1
  13. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  14. package/dist/activities/execute-cursor/hook-script.js +38 -0
  15. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  16. package/dist/activities/execute-cursor/index.d.ts +12 -0
  17. package/dist/activities/execute-cursor/index.js +72 -15
  18. package/dist/activities/execute-cursor/index.js.map +1 -1
  19. package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
  20. package/dist/activities/execute-cursor/message-translator.js +78 -2
  21. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  22. package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
  23. package/dist/activities/execute-cursor/prompt-builder.js +21 -0
  24. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  25. package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
  26. package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
  27. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  28. package/dist/activities/execute-cursor/turn-boundary.js +15 -2
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
  31. package/dist/activities/execute-deep-agent/hitl.js +48 -1
  32. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +10 -1
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
  39. package/dist/activities/execute-deep-agent/setup.js +16 -1
  40. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
  42. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  43. package/dist/activities/execute-deep-agent/status-builder.js +6 -0
  44. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
  46. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  47. package/dist/activities/workflow-event-activities.js +11 -0
  48. package/dist/activities/workflow-event-activities.js.map +1 -1
  49. package/dist/config.d.ts +17 -0
  50. package/dist/config.js +14 -0
  51. package/dist/config.js.map +1 -1
  52. package/dist/middleware/approval-gate.d.ts +20 -0
  53. package/dist/middleware/approval-gate.js +16 -1
  54. package/dist/middleware/approval-gate.js.map +1 -1
  55. package/dist/runner-manager.d.ts +2 -0
  56. package/dist/runner-manager.js +2 -1
  57. package/dist/runner-manager.js.map +1 -1
  58. package/dist/runner.d.ts +2 -0
  59. package/dist/runner.js +2 -1
  60. package/dist/runner.js.map +1 -1
  61. package/dist/shared/approval-policy.d.ts +24 -1
  62. package/dist/shared/approval-policy.js +35 -1
  63. package/dist/shared/approval-policy.js.map +1 -1
  64. package/dist/shared/context-bridge.d.ts +30 -0
  65. package/dist/shared/context-bridge.js +45 -0
  66. package/dist/shared/context-bridge.js.map +1 -0
  67. package/dist/shared/sender-identity.d.ts +50 -0
  68. package/dist/shared/sender-identity.js +69 -0
  69. package/dist/shared/sender-identity.js.map +1 -0
  70. package/dist/shared/with-timeout.d.ts +27 -0
  71. package/dist/shared/with-timeout.js +47 -0
  72. package/dist/shared/with-timeout.js.map +1 -0
  73. package/dist/workflow-engine/do-executor.d.ts +7 -0
  74. package/dist/workflow-engine/do-executor.js +59 -1
  75. package/dist/workflow-engine/do-executor.js.map +1 -1
  76. package/dist/workflow-engine/tasks/call-function.js +68 -1
  77. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  78. package/dist/workflow-engine/tasks/human-input.js +1 -0
  79. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  80. package/dist/workflow-engine/types.d.ts +48 -0
  81. package/dist/workflow-engine/types.js.map +1 -1
  82. package/dist/workflows/engine-core.js +5 -1
  83. package/dist/workflows/engine-core.js.map +1 -1
  84. package/dist/workflows/human-input-orchestrator.d.ts +2 -1
  85. package/dist/workflows/human-input-orchestrator.js +2 -1
  86. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/__tests__/config.test.ts +8 -0
  89. package/src/activities/__tests__/call-validate.test.ts +137 -0
  90. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  91. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  92. package/src/activities/__tests__/workflow-event-activities.test.ts +89 -0
  93. package/src/activities/call-transform.ts +20 -2
  94. package/src/activities/call-validate.ts +27 -2
  95. package/src/activities/discover-mcp-server.ts +7 -28
  96. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
  97. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
  98. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
  99. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
  100. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
  101. package/src/activities/execute-cursor/approval-state.ts +32 -1
  102. package/src/activities/execute-cursor/hook-script.ts +40 -0
  103. package/src/activities/execute-cursor/index.ts +86 -17
  104. package/src/activities/execute-cursor/message-translator.ts +82 -1
  105. package/src/activities/execute-cursor/prompt-builder.ts +44 -0
  106. package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
  107. package/src/activities/execute-cursor/turn-boundary.ts +23 -0
  108. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  109. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  110. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  111. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
  112. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  113. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
  114. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  115. package/src/activities/execute-deep-agent/hitl.ts +51 -0
  116. package/src/activities/execute-deep-agent/index.ts +11 -1
  117. package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
  118. package/src/activities/execute-deep-agent/setup.ts +31 -0
  119. package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
  120. package/src/activities/execute-deep-agent/status-builder.ts +7 -0
  121. package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
  122. package/src/activities/workflow-event-activities.ts +11 -0
  123. package/src/config.ts +23 -0
  124. package/src/middleware/__tests__/approval-gate.test.ts +95 -0
  125. package/src/middleware/approval-gate.ts +37 -0
  126. package/src/runner-manager.ts +6 -1
  127. package/src/runner.ts +6 -1
  128. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  129. package/src/shared/__tests__/context-bridge.test.ts +51 -0
  130. package/src/shared/__tests__/sender-identity.test.ts +92 -0
  131. package/src/shared/__tests__/with-timeout.test.ts +60 -0
  132. package/src/shared/approval-policy.ts +41 -2
  133. package/src/shared/context-bridge.ts +51 -0
  134. package/src/shared/sender-identity.ts +85 -0
  135. package/src/shared/with-timeout.ts +53 -0
  136. package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
  137. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
  138. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
  139. package/src/workflow-engine/do-executor.ts +65 -1
  140. package/src/workflow-engine/tasks/call-function.ts +84 -3
  141. package/src/workflow-engine/tasks/human-input.ts +1 -0
  142. package/src/workflow-engine/types.ts +50 -0
  143. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
  144. package/src/workflows/engine-core.ts +5 -1
  145. package/src/workflows/human-input-orchestrator.ts +2 -1
@@ -87,6 +87,71 @@ describe("buildPrompt", () => {
87
87
  expect(prompt).toContain(USER_MESSAGE);
88
88
  });
89
89
 
90
+ it("carries the rollover context bridge on the first execution (DD-013)", () => {
91
+ const prompt = buildPrompt(
92
+ input({
93
+ resolution: resolution("local", "created_first_execution"),
94
+ contextBridge: "Subject: Orders\nUser: where is my order?\nAssistant: Shipped.",
95
+ }),
96
+ );
97
+ expect(prompt).toContain("<previous_conversation_context>");
98
+ expect(prompt).toContain("User: where is my order?");
99
+ // The bridge is CONTEXT; the approval protocol keeps its pinned
100
+ // last-before-task slot so instructions outweigh it.
101
+ expect(prompt.indexOf("<previous_conversation_context>"))
102
+ .toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
103
+ });
104
+
105
+ it("never bridges a successfully resumed agent — its native context IS the conversation", () => {
106
+ const prompt = buildPrompt(
107
+ input({
108
+ resolution: resolution("local", "resumed_successfully"),
109
+ contextBridge: "Subject: Orders\nUser: hi\nAssistant: hello",
110
+ }),
111
+ );
112
+ expect(prompt).toBe(USER_MESSAGE);
113
+ });
114
+
115
+ it("omits the bridge section when the session carries none", () => {
116
+ const prompt = buildPrompt(
117
+ input({ resolution: resolution("local", "created_first_execution") }),
118
+ );
119
+ expect(prompt).not.toContain("<previous_conversation_context>");
120
+ });
121
+
122
+ it("carries the channel sender identity on the first execution", () => {
123
+ const prompt = buildPrompt(
124
+ input({
125
+ resolution: resolution("local", "created_first_execution"),
126
+ senderIdentity: { value: "15550001111", kind: "whatsapp_phone" },
127
+ }),
128
+ );
129
+ expect(prompt).toContain("<conversation_sender>");
130
+ expect(prompt).toContain("WhatsApp phone number");
131
+ expect(prompt).toContain("15550001111");
132
+ // Identity is CONTEXT like the bridge; the approval protocol keeps its
133
+ // pinned last-before-task slot.
134
+ expect(prompt.indexOf("<conversation_sender>"))
135
+ .toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
136
+ });
137
+
138
+ it("never re-sends the identity to a successfully resumed agent — its native context carries it", () => {
139
+ const prompt = buildPrompt(
140
+ input({
141
+ resolution: resolution("local", "resumed_successfully"),
142
+ senderIdentity: { value: "U0USER", kind: "slack_user_id" },
143
+ }),
144
+ );
145
+ expect(prompt).toBe(USER_MESSAGE);
146
+ });
147
+
148
+ it("omits the sender section when the session carries no identity (console sessions)", () => {
149
+ const prompt = buildPrompt(
150
+ input({ resolution: resolution("local", "created_first_execution") }),
151
+ );
152
+ expect(prompt).not.toContain("<conversation_sender>");
153
+ });
154
+
90
155
  it("uses the reinvocation prompt for a HITL reinvocation (human-meaningful, no opaque ids)", () => {
91
156
  const approvalDecisions = new Map<string, ApprovalAction>([
92
157
  ["tool-call-1", ApprovalAction.APPROVE],
@@ -42,7 +42,9 @@ import {
42
42
  MessageType,
43
43
  ToolCallStatus,
44
44
  ApprovalAction,
45
+ ApprovalPolicySource,
45
46
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
47
+ import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
46
48
 
47
49
  import {
48
50
  resetDenialLedger,
@@ -61,6 +63,7 @@ import {
61
63
  collapseRedundantToolCallTwins,
62
64
  toolCallIdentityToken,
63
65
  detectUnattributedHookBlocks,
66
+ stampUnattendedSkippedToolCalls,
64
67
  HOOK_BLOCK_ERROR_MARKERS,
65
68
  } from "../message-translator.js";
66
69
  import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
@@ -1843,6 +1846,105 @@ describe("reconcileDeniedToolCalls — non-approval kinds never gate", () => {
1843
1846
  });
1844
1847
  });
1845
1848
 
1849
+ describe("stampUnattendedSkippedToolCalls (DD-014)", () => {
1850
+ it("settles a hook-blocked FAILED row to SKIPPED with UNATTENDED_SKIP provenance", () => {
1851
+ const denied = toolCall({
1852
+ id: "u1", name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED,
1853
+ error: "blocked by a hook", args: { command: "rm -rf build" },
1854
+ });
1855
+ const messages = [aiMessageWith([denied])];
1856
+
1857
+ const stamped = stampUnattendedSkippedToolCalls(messages, [], [
1858
+ { toolName: "Shell", token: toolCallIdentityToken(denied), kind: "unattended" },
1859
+ ]);
1860
+
1861
+ expect(stamped).toBe(1);
1862
+ expect(denied.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
1863
+ expect(denied.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
1864
+ expect(denied.error).toBe("");
1865
+ expect(denied.result).toContain("skipped automatically");
1866
+ // Server-owned human-decision fields stay untouched (DD-014 D-e).
1867
+ expect(denied.approvalAction).toBe(ApprovalAction.UNSPECIFIED);
1868
+ });
1869
+
1870
+ it("settles an interrupted non-terminal row and covers sub-agent transcripts", () => {
1871
+ const parentRow = toolCall({
1872
+ id: "u2", name: "edit", status: ToolCallStatus.TOOL_CALL_RUNNING,
1873
+ args: { path: "notes.md", old_string: "a", new_string: "b" },
1874
+ });
1875
+ const subRow = toolCall({
1876
+ id: "u3", name: "shell", status: ToolCallStatus.TOOL_CALL_PENDING,
1877
+ args: { command: "make" },
1878
+ });
1879
+ const subAgents = [create(SubAgentExecutionSchema, { messages: [aiMessageWith([subRow])] })];
1880
+
1881
+ const stamped = stampUnattendedSkippedToolCalls(
1882
+ [aiMessageWith([parentRow])],
1883
+ subAgents,
1884
+ [
1885
+ { toolName: "Write", token: toolCallIdentityToken(parentRow), kind: "unattended" },
1886
+ { toolName: "Shell", token: toolCallIdentityToken(subRow), kind: "unattended" },
1887
+ ],
1888
+ );
1889
+
1890
+ expect(stamped).toBe(2);
1891
+ expect(parentRow.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
1892
+ expect(subAgents[0].messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
1893
+ });
1894
+
1895
+ it("matches a FILE denial across abs-vs-rel path drift via the workspace root", () => {
1896
+ // Hook recorded the ABSOLUTE path; the stream carried the relative one.
1897
+ const denied = toolCall({
1898
+ id: "u4", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
1899
+ error: "blocked by a hook", args: { path: "notes.md" },
1900
+ });
1901
+
1902
+ const stamped = stampUnattendedSkippedToolCalls(
1903
+ [aiMessageWith([denied])],
1904
+ [],
1905
+ [{ toolName: "Write", token: grantToken("write", `${ROOT}/notes.md`), kind: "unattended" }],
1906
+ ROOT,
1907
+ );
1908
+
1909
+ expect(stamped).toBe(1);
1910
+ expect(denied.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
1911
+ });
1912
+
1913
+ it("never rewrites a COMPLETED row or an unrelated failure", () => {
1914
+ const completed = toolCall({
1915
+ id: "u5", name: "shell", status: ToolCallStatus.TOOL_CALL_COMPLETED,
1916
+ result: "ok", args: { command: "ls" },
1917
+ });
1918
+ const ordinaryFailure = toolCall({
1919
+ id: "u6", name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED,
1920
+ error: "command not found", args: { command: "make" },
1921
+ });
1922
+ const messages = [aiMessageWith([completed, ordinaryFailure])];
1923
+
1924
+ const stamped = stampUnattendedSkippedToolCalls(messages, [], [
1925
+ { toolName: "Shell", token: toolCallIdentityToken(completed), kind: "unattended" },
1926
+ { toolName: "Shell", token: toolCallIdentityToken(ordinaryFailure), kind: "unattended" },
1927
+ ]);
1928
+
1929
+ // The completed row is a real execution; the ordinary failure is not a
1930
+ // hook block — only interrupted/hook-blocked shapes are settled. The
1931
+ // interrupted-shape sibling (PENDING/RUNNING) is covered above.
1932
+ expect(stamped).toBe(0);
1933
+ expect(completed.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
1934
+ expect(ordinaryFailure.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
1935
+ expect(ordinaryFailure.error).toBe("command not found");
1936
+ });
1937
+
1938
+ it("is a no-op for an empty unattended ledger", () => {
1939
+ const row = toolCall({
1940
+ id: "u7", name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED,
1941
+ error: "blocked by a hook", args: { command: "make" },
1942
+ });
1943
+ expect(stampUnattendedSkippedToolCalls([aiMessageWith([row])], [], [])).toBe(0);
1944
+ expect(row.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
1945
+ });
1946
+ });
1947
+
1846
1948
  describe("generateHookScript ledger wiring", () => {
1847
1949
  it("bakes the active-turn pointer, derives the ledger from it, and records on EVERY deny arm", () => {
1848
1950
  const script = generateHookScript("/gate/active.json");
@@ -1858,6 +1960,9 @@ describe("generateHookScript ledger wiring", () => {
1858
1960
  // CAS staging-error fail-closed deny, and the missing-state-file failsafe.
1859
1961
  expect(script).toContain('record_denial "$PRIMARY_TOKEN" "approval"');
1860
1962
  expect(script).toContain('record_denial "$MCP_TOKEN" "approval"');
1963
+ // DD-014: both approval arms have an unattended-resolution sibling.
1964
+ expect(script).toContain('record_denial "$PRIMARY_TOKEN" "unattended"');
1965
+ expect(script).toContain('record_denial "$MCP_TOKEN" "unattended"');
1861
1966
  expect(script.split('"secret"').length - 1).toBeGreaterThanOrEqual(2);
1862
1967
  expect(script).toContain('record_denial "$PRIMARY_TOKEN" "capture-error"');
1863
1968
  expect(script).toContain('record_denial "$PRIMARY_TOKEN" "fail-closed"');
@@ -103,6 +103,60 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
103
103
  expect(ledger[0]).not.toHaveProperty("input");
104
104
  });
105
105
 
106
+ // Unattended approval mode (DD-014): same gate, different RESOLUTION — the
107
+ // approval-deny arms record the non-pausing "unattended" kind with an
108
+ // adapt-and-explain message. What is gated must be byte-identical to
109
+ // interactive mode; only the kind and the agent message differ.
110
+ describe("unattended approval mode (DD-014)", () => {
111
+ it("denies a gated built-in with kind 'unattended' and the adapt message (no resume promise)", () => {
112
+ const h = setup({ unattendedSkip: true });
113
+ const res = h.decide(hookShell("rm -rf build"));
114
+ expect(res.permission).toBe("deny");
115
+ expect(res.raw).toContain("skipped automatically");
116
+ expect(res.raw).not.toContain("resume you");
117
+ const ledger = h.ledger();
118
+ expect(ledger).toHaveLength(1);
119
+ expect(ledger[0].kind).toBe("unattended");
120
+ // Same identity space as interactive mode — the stamping correlates on it.
121
+ expect(ledger[0].token).toBe(primaryToken("shell", "rm -rf build", ""));
122
+ // Non-approval kinds are content-free (no approval card needs a preview).
123
+ expect(ledger[0]).not.toHaveProperty("input");
124
+ });
125
+
126
+ it("denies a require-approval MCP tool with kind 'unattended'", () => {
127
+ const h = setup({
128
+ unattendedSkip: true,
129
+ mcpPolicies: { drop_table: { requiresApproval: true, message: "Drop table?" } },
130
+ });
131
+ const res = h.decide(hookMcp("drop_table", { table: "users" }));
132
+ expect(res.permission).toBe("deny");
133
+ expect(res.raw).toContain("skipped automatically");
134
+ const ledger = h.ledger();
135
+ expect(ledger).toHaveLength(1);
136
+ expect(ledger[0].kind).toBe("unattended");
137
+ expect(ledger[0].token).toBe(grantToken("drop_table", ""));
138
+ });
139
+
140
+ it("still allows auto-approved MCP tools and read-only built-ins (gating is unchanged)", () => {
141
+ const h = setup({
142
+ unattendedSkip: true,
143
+ mcpPolicies: { drop_table: { requiresApproval: true } },
144
+ });
145
+ expect(h.decide(hookMcp("list_tables")).permission).toBe("allow");
146
+ expect(h.decide(hookRead("/x/a.txt")).permission).toBe("allow");
147
+ expect(h.ledger()).toEqual([]);
148
+ });
149
+
150
+ it("the secret hard-block stays kind 'secret' (mode-independent)", () => {
151
+ const h = setup({ unattendedSkip: true });
152
+ const res = h.decide(hookWrite("/x/.env", "API_KEY=abc"));
153
+ expect(res.permission).toBe("deny");
154
+ const ledger = h.ledger();
155
+ expect(ledger).toHaveLength(1);
156
+ expect(ledger[0].kind).toBe("secret");
157
+ });
158
+ });
159
+
106
160
  // MCP gating runs ONLY on the beforeMCPExecution event (preToolUse does not
107
161
  // enforce MCP), so a denial is recorded in exactly one place. The identity is
108
162
  // name-only (base64("<tool>\n")) because the bare tool name is identical on the
@@ -20,7 +20,13 @@ vi.mock("@cursor/sdk", () => ({
20
20
  }));
21
21
 
22
22
  import { Agent } from "@cursor/sdk";
23
- import { resolvePlatformOptions, createAgent, resumeAgent } from "../session-lifecycle.js";
23
+ import {
24
+ resolvePlatformOptions,
25
+ createAgent,
26
+ resumeAgent,
27
+ resolveAgentWithTransportRecovery,
28
+ } from "../session-lifecycle.js";
29
+ import type { CreateAgentOptions } from "../session-lifecycle.js";
24
30
 
25
31
  const tempRoots: string[] = [];
26
32
 
@@ -134,3 +140,118 @@ describe("workspace binding on create/resume", () => {
134
140
  expect(callOptions.local.cwd).toEqual(["/work/repo-a", "/work/repo-b"]);
135
141
  });
136
142
  });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Transport recovery on agent-resolution timeout
146
+ //
147
+ // Regression: a stale HTTP/2 session to the proxy hangs Agent.create/resume
148
+ // forever (prod incident, Jul 2026). The wrapper bounds each attempt, resets
149
+ // the transport on the first expiry, and retries once. Only TimeoutError
150
+ // triggers recovery — deterministic failures must propagate untouched.
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe("resolveAgentWithTransportRecovery", () => {
154
+ const TIMEOUT_MS = 1_000;
155
+ const hang = () => new Promise<never>(() => {});
156
+
157
+ function recoveryOptions(overrides?: {
158
+ harnessStateId?: string;
159
+ resetTransport?: () => void;
160
+ }) {
161
+ const createOptions: CreateAgentOptions = {
162
+ apiKey: "key",
163
+ model: "gpt-test",
164
+ workspaceDirs: ["/work/repo-a"],
165
+ sessionId: "ses-recovery-test",
166
+ workspaceRootDir: freshWorkspaceRoot(),
167
+ };
168
+ return {
169
+ harnessStateId: overrides?.harnessStateId ?? "",
170
+ createOptions,
171
+ mode: "local" as const,
172
+ timeoutMs: TIMEOUT_MS,
173
+ buildTimeoutMessage: (finalAttempt: boolean) =>
174
+ finalAttempt ? "final attempt timed out" : "first attempt timed out",
175
+ resetTransport: overrides?.resetTransport ?? vi.fn(),
176
+ };
177
+ }
178
+
179
+ afterEach(() => {
180
+ vi.useRealTimers();
181
+ });
182
+
183
+ it("passes the resolution through untouched when the first attempt succeeds", async () => {
184
+ const resetTransport = vi.fn();
185
+
186
+ const resolution = await resolveAgentWithTransportRecovery(
187
+ recoveryOptions({ resetTransport }),
188
+ );
189
+
190
+ expect(resolution.agentId).toBe("agent-created");
191
+ expect(resolution.reason).toBe("created_first_execution");
192
+ expect(resetTransport).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("resets the transport exactly once and recovers when the first attempt hangs", async () => {
196
+ vi.useFakeTimers();
197
+ const resetTransport = vi.fn();
198
+ vi.mocked(Agent.create).mockImplementationOnce(hang as any);
199
+
200
+ const promise = resolveAgentWithTransportRecovery(recoveryOptions({ resetTransport }));
201
+
202
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1);
203
+ const resolution = await promise;
204
+
205
+ expect(resolution.agentId).toBe("agent-created");
206
+ expect(resetTransport).toHaveBeenCalledTimes(1);
207
+ });
208
+
209
+ it("retries resume-first so a transport hiccup does not discard conversation context", async () => {
210
+ vi.useFakeTimers();
211
+ const resetTransport = vi.fn();
212
+ vi.mocked(Agent.resume).mockImplementationOnce(hang as any);
213
+
214
+ const promise = resolveAgentWithTransportRecovery(
215
+ recoveryOptions({ harnessStateId: "agent-prior-turn", resetTransport }),
216
+ );
217
+
218
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1);
219
+ const resolution = await promise;
220
+
221
+ // The load-bearing assertion: the retry went through Agent.resume again
222
+ // (only the transport was suspect, not the agent handle), so the native
223
+ // conversation context survives the recovery.
224
+ expect(resolution.reason).toBe("resumed_successfully");
225
+ expect(resolution.agentId).toBe("agent-resumed");
226
+ expect(resetTransport).toHaveBeenCalledTimes(1);
227
+ });
228
+
229
+ it("rejects with the final-attempt message when both attempts hang; transport reset only once", async () => {
230
+ vi.useFakeTimers();
231
+ const resetTransport = vi.fn();
232
+ vi.mocked(Agent.create)
233
+ .mockImplementationOnce(hang as any)
234
+ .mockImplementationOnce(hang as any);
235
+
236
+ const promise = resolveAgentWithTransportRecovery(recoveryOptions({ resetTransport }));
237
+ const rejection = expect(promise).rejects.toThrow("final attempt timed out");
238
+
239
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1); // first attempt expires
240
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1); // retry expires
241
+ await rejection;
242
+
243
+ expect(resetTransport).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ it("propagates a non-timeout error immediately without touching the transport", async () => {
247
+ const resetTransport = vi.fn();
248
+ const authFailure = new Error("401 unauthorized");
249
+ vi.mocked(Agent.create).mockRejectedValueOnce(authFailure);
250
+
251
+ await expect(
252
+ resolveAgentWithTransportRecovery(recoveryOptions({ resetTransport })),
253
+ ).rejects.toBe(authFailure);
254
+
255
+ expect(resetTransport).not.toHaveBeenCalled();
256
+ });
257
+ });
@@ -218,6 +218,17 @@ export interface ApprovalStateFile {
218
218
  * deep-agent), and shell/MCP gate as always.
219
219
  */
220
220
  gitWorkspace: boolean;
221
+ /**
222
+ * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED,
223
+ * DD-014): the creating surface — a messaging channel, a guest share — has
224
+ * no approver. What is gated is UNCHANGED; only the resolution differs: a
225
+ * deny that would be recorded kind "approval" (pausing) is recorded kind
226
+ * "unattended" (non-pausing) with an adapt-and-explain agent message, so
227
+ * the first-denial stop never fires, no WAITING_APPROVAL gate is
228
+ * reconciled, and the run continues to normal completion. The secret
229
+ * hard-block and fail-closed arms are mode-independent and unchanged.
230
+ */
231
+ unattendedSkip: boolean;
221
232
  }
222
233
 
223
234
  /**
@@ -384,6 +395,7 @@ export function buildApprovalState(
384
395
  captureMode = false,
385
396
  captureIgnored = false,
386
397
  gitWorkspace = true,
398
+ unattendedSkip = false,
387
399
  ): ApprovalStateFile {
388
400
  const approvedGrants = grants ?? [];
389
401
 
@@ -407,6 +419,7 @@ export function buildApprovalState(
407
419
  captureMode,
408
420
  captureIgnored,
409
421
  gitWorkspace,
422
+ unattendedSkip,
410
423
  };
411
424
  }
412
425
 
@@ -449,6 +462,11 @@ const DENIAL_LEDGER_FILE = "denials.jsonl";
449
462
  *
450
463
  * - `approval` — the normal gate: the runner surfaces it as a
451
464
  * WAITING_APPROVAL pause. The ONLY kind that pauses.
465
+ * - `unattended` — the same gate resolved under UNATTENDED approval mode
466
+ * (DD-014): the surface has no approver, so the deny is
467
+ * final for this turn — non-pausing, the agent was told
468
+ * to adapt, and the turn boundary stamps the call
469
+ * TOOL_CALL_SKIPPED with UNATTENDED_SKIP provenance.
452
470
  * - `secret` — DD-26 secret hard-block: intentional, non-pausing (the
453
471
  * agent was told to move on), recorded content-free.
454
472
  * - `capture-error` — CAS staging failed; the write stayed on the deny-gate.
@@ -461,11 +479,14 @@ const DENIAL_LEDGER_FILE = "denials.jsonl";
461
479
  * unknown deny must never manufacture an approval) but still attributes the
462
480
  * blocked call to our own hook.
463
481
  */
464
- export type DenialKind = "approval" | "secret" | "capture-error" | "fail-closed";
482
+ export type DenialKind = "approval" | "unattended" | "secret" | "capture-error" | "fail-closed";
465
483
 
466
484
  /** The one kind that pauses the run for user approval. */
467
485
  export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
468
486
 
487
+ /** The unattended-mode resolution kind (non-pausing; stamped SKIPPED). */
488
+ export const UNATTENDED_DENIAL_KIND: DenialKind = "unattended";
489
+
469
490
  /**
470
491
  * One denial recorded by the preToolUse hook. `token` is the call's identity in
471
492
  * the same space as grantToken() (base64 of `toolName \n salientArg`), used to
@@ -511,6 +532,16 @@ export function approvalDenials(entries: readonly DeniedLedgerEntry[]): DeniedLe
511
532
  return entries.filter((e) => denialKindOf(e) === APPROVAL_DENIAL_KIND);
512
533
  }
513
534
 
535
+ /**
536
+ * The entries the UNATTENDED approval mode resolved (DD-014) — never pausing,
537
+ * consumed by the turn boundary's `stampUnattendedSkippedToolCalls` to
538
+ * terminalize the corresponding streamed tool calls as TOOL_CALL_SKIPPED with
539
+ * UNATTENDED_SKIP provenance, so both harnesses persist the same honest shape.
540
+ */
541
+ export function unattendedDenials(entries: readonly DeniedLedgerEntry[]): DeniedLedgerEntry[] {
542
+ return entries.filter((e) => denialKindOf(e) === UNATTENDED_DENIAL_KIND);
543
+ }
544
+
514
545
  /**
515
546
  * Absolute path of the per-turn denial ledger the hook appends to, inside the
516
547
  * session's runner-owned HITL directory (never the user's workspace).
@@ -124,6 +124,24 @@ const APPROVAL_REQUIRED_AGENT_MESSAGE =
124
124
  "attempt a workaround for this action. The platform will resume you " +
125
125
  "automatically after the user responds — continue with the rest of the task.";
126
126
 
127
+ // Shown to the model when the gate denies a tool call under UNATTENDED
128
+ // approval mode (DD-014): the creating surface (a messaging channel, a guest
129
+ // share) has no approver, so — unlike APPROVAL_REQUIRED_AGENT_MESSAGE — this
130
+ // must NOT promise a resume: the deny is final for this turn and the model
131
+ // must adapt. It also enforces the anti-leak posture: the end user hears a
132
+ // plain-language explanation, never tool/approval vocabulary. Same embedding
133
+ // constraint (single-quoted bash echo of a JSON object): no double quotes,
134
+ // apostrophes, or backslashes. Mirrors the native gate skip message
135
+ // (middleware/approval-gate.ts unattendedSkipMessage) in intent.
136
+ const UNATTENDED_SKIP_AGENT_MESSAGE =
137
+ "This action was skipped automatically because it requires an approval that " +
138
+ "is not available in this conversation. This is the platform approval gate " +
139
+ "working as intended — it is not an error and not a Cursor misconfiguration. " +
140
+ "Do not retry it or attempt a workaround; it will not be resumed. Adapt your " +
141
+ "plan, and explain to the user in plain language what you could not do and " +
142
+ "what they can do instead — never mention tools, approvals, or platform " +
143
+ "mechanics.";
144
+
127
145
  // Shown to the model when a secret-like gitignored write is hard-blocked (DD-E /
128
146
  // DD-18). Unlike APPROVAL_REQUIRED_AGENT_MESSAGE, this must NOT promise a resume:
129
147
  // the write is discarded and never captured for review, so the model must move on
@@ -522,6 +540,14 @@ GIT_WORKSPACE=true
522
540
  if echo "$STATE" | grep -q '"gitWorkspace":false'; then
523
541
  GIT_WORKSPACE=false
524
542
  fi
543
+ # Unattended approval mode (DD-014): the surface has no approver. Same gate,
544
+ # different RESOLUTION — the approval-deny arms below record kind "unattended"
545
+ # (non-pausing) with an adapt-and-explain message instead of kind "approval"
546
+ # (pausing). Secret/fail-closed/capture-error arms are mode-independent.
547
+ UNATTENDED_SKIP=false
548
+ if echo "$STATE" | grep -q '"unattendedSkip":true'; then
549
+ UNATTENDED_SKIP=true
550
+ fi
525
551
 
526
552
  # --- Capture mode: observe CAS-owned writes for review ----------------------
527
553
  # Runs BEFORE the auto-approve-all shortcut and the grant/lease checks because
@@ -588,6 +614,13 @@ if [ "$HOOK_EVENT" = "beforeMCPExecution" ]; then
588
614
  if [ -z "$MSG" ]; then
589
615
  MSG="Tool requires approval: $TOOL_NAME"
590
616
  fi
617
+ if [ "$UNATTENDED_SKIP" = "true" ]; then
618
+ # Unattended resolution (DD-014): non-pausing kind, final for this
619
+ # turn — the model adapts and the turn boundary stamps SKIPPED.
620
+ record_denial "$MCP_TOKEN" "unattended"
621
+ echo '{"permission":"deny","agent_message":"${UNATTENDED_SKIP_AGENT_MESSAGE}","user_message":"Skipped (approval not available on this surface): '"$TOOL_NAME"'"}'
622
+ exit 0
623
+ fi
591
624
  record_denial "$MCP_TOKEN" "approval"
592
625
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
593
626
  exit 0
@@ -655,6 +688,13 @@ if [ -n "$CATEGORY" ]; then
655
688
  fi
656
689
  # Record the PRIMARY token (content-exact when available, else coarse) so the
657
690
  # runner's denial correlation keys on the SAME identity it grants on approval.
691
+ if [ "$UNATTENDED_SKIP" = "true" ]; then
692
+ # Unattended resolution (DD-014): non-pausing kind, final for this turn —
693
+ # the model adapts and the turn boundary stamps SKIPPED.
694
+ record_denial "$PRIMARY_TOKEN" "unattended"
695
+ echo '{"permission":"deny","agent_message":"${UNATTENDED_SKIP_AGENT_MESSAGE}","user_message":"Skipped (approval not available on this surface): '"$TOOL_NAME"'"}'
696
+ exit 0
697
+ fi
658
698
  record_denial "$PRIMARY_TOKEN" "approval"
659
699
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
660
700
  exit 0