@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-transform.js +12 -2
- package/dist/activities/call-transform.js.map +1 -1
- package/dist/activities/call-validate.js +20 -2
- package/dist/activities/call-validate.js.map +1 -1
- package/dist/activities/discover-mcp-server.js +6 -20
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
- package/dist/activities/execute-cursor/approval-state.js +13 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +38 -0
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +12 -0
- package/dist/activities/execute-cursor/index.js +72 -15
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
- package/dist/activities/execute-cursor/message-translator.js +78 -2
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
- package/dist/activities/execute-cursor/prompt-builder.js +21 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.js +15 -2
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
- package/dist/activities/execute-deep-agent/hitl.js +48 -1
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +10 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
- package/dist/activities/execute-deep-agent/setup.js +16 -1
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +11 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +20 -0
- package/dist/middleware/approval-gate.js +16 -1
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +24 -1
- package/dist/shared/approval-policy.js +35 -1
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/context-bridge.d.ts +30 -0
- package/dist/shared/context-bridge.js +45 -0
- package/dist/shared/context-bridge.js.map +1 -0
- package/dist/shared/sender-identity.d.ts +50 -0
- package/dist/shared/sender-identity.js +69 -0
- package/dist/shared/sender-identity.js.map +1 -0
- package/dist/shared/with-timeout.d.ts +27 -0
- package/dist/shared/with-timeout.js +47 -0
- package/dist/shared/with-timeout.js.map +1 -0
- package/dist/workflow-engine/do-executor.d.ts +7 -0
- package/dist/workflow-engine/do-executor.js +59 -1
- package/dist/workflow-engine/do-executor.js.map +1 -1
- package/dist/workflow-engine/tasks/call-function.js +68 -1
- package/dist/workflow-engine/tasks/call-function.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.js +1 -0
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +48 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +5 -1
- package/dist/workflows/engine-core.js.map +1 -1
- package/dist/workflows/human-input-orchestrator.d.ts +2 -1
- package/dist/workflows/human-input-orchestrator.js +2 -1
- package/dist/workflows/human-input-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/call-validate.test.ts +137 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +89 -0
- package/src/activities/call-transform.ts +20 -2
- package/src/activities/call-validate.ts +27 -2
- package/src/activities/discover-mcp-server.ts +7 -28
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
- package/src/activities/execute-cursor/approval-state.ts +32 -1
- package/src/activities/execute-cursor/hook-script.ts +40 -0
- package/src/activities/execute-cursor/index.ts +86 -17
- package/src/activities/execute-cursor/message-translator.ts +82 -1
- package/src/activities/execute-cursor/prompt-builder.ts +44 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
- package/src/activities/execute-cursor/turn-boundary.ts +23 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
- package/src/activities/execute-deep-agent/hitl.ts +51 -0
- package/src/activities/execute-deep-agent/index.ts +11 -1
- package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
- package/src/activities/execute-deep-agent/setup.ts +31 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
- package/src/activities/execute-deep-agent/status-builder.ts +7 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
- package/src/activities/workflow-event-activities.ts +11 -0
- package/src/config.ts +23 -0
- package/src/middleware/__tests__/approval-gate.test.ts +95 -0
- package/src/middleware/approval-gate.ts +37 -0
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/context-bridge.test.ts +51 -0
- package/src/shared/__tests__/sender-identity.test.ts +92 -0
- package/src/shared/__tests__/with-timeout.test.ts +60 -0
- package/src/shared/approval-policy.ts +41 -2
- package/src/shared/context-bridge.ts +51 -0
- package/src/shared/sender-identity.ts +85 -0
- package/src/shared/with-timeout.ts +53 -0
- package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
- package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
- package/src/workflow-engine/do-executor.ts +65 -1
- package/src/workflow-engine/tasks/call-function.ts +84 -3
- package/src/workflow-engine/tasks/human-input.ts +1 -0
- package/src/workflow-engine/types.ts +50 -0
- package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
- package/src/workflows/engine-core.ts +5 -1
- 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 {
|
|
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
|