@stigmer/runner 3.0.3 → 3.0.4
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/execute-cursor/hook-script.d.ts +14 -6
- package/dist/activities/execute-cursor/hook-script.js +83 -32
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +9 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +11 -3
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +55 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +73 -2
- package/src/activities/execute-cursor/hook-script.ts +84 -32
- package/src/activities/execute-cursor/session-lifecycle.ts +21 -3
package/dist/.build-fingerprint
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"hash":"
|
|
1
|
+
{"hash":"af9f8d2d84cef6db","builtAt":"2026-06-10T08:56:06.851Z","fileCount":192}
|
|
@@ -16,12 +16,20 @@
|
|
|
16
16
|
* so its ledger is the authoritative record of what was gated this turn
|
|
17
17
|
* 5. Returns { "permission": "allow" } or { "permission": "deny" } on stdout
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* Identity extraction runs on the SAME Node.js binary as the runner (its
|
|
20
|
+
* absolute path — process.execPath — is baked into the script at generation
|
|
21
|
+
* time), because the identity token must be byte-identical to the one the
|
|
22
|
+
* runner computes from the parsed stream event. The original grep/cut
|
|
23
|
+
* extraction is kept only as a best-effort fallback if that binary cannot run:
|
|
24
|
+
* grep's `"command":"[^"]*"` truncates at the first JSON-escaped quote, so for
|
|
25
|
+
* a shell command like `printf '%s' "x" > file` the fallback token will NOT
|
|
26
|
+
* match the runner's — the call is still denied (the gate holds) but the
|
|
27
|
+
* denial cannot be overlaid onto the real streamed tool call and a grant for
|
|
28
|
+
* it will not match on reinvocation. All policy decisions are pre-computed by
|
|
29
|
+
* the runner into the state file (and into this generated script); the hook
|
|
30
|
+
* only performs mechanical field extraction and string lookups — the policy
|
|
31
|
+
* itself is authored once in TypeScript (approval-policy.ts /
|
|
32
|
+
* approval-state.ts).
|
|
25
33
|
*
|
|
26
34
|
* Cross-taxonomy identity (the crux):
|
|
27
35
|
* The preToolUse hook and the SDK event stream name the same operation
|
|
@@ -16,12 +16,20 @@
|
|
|
16
16
|
* so its ledger is the authoritative record of what was gated this turn
|
|
17
17
|
* 5. Returns { "permission": "allow" } or { "permission": "deny" } on stdout
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* Identity extraction runs on the SAME Node.js binary as the runner (its
|
|
20
|
+
* absolute path — process.execPath — is baked into the script at generation
|
|
21
|
+
* time), because the identity token must be byte-identical to the one the
|
|
22
|
+
* runner computes from the parsed stream event. The original grep/cut
|
|
23
|
+
* extraction is kept only as a best-effort fallback if that binary cannot run:
|
|
24
|
+
* grep's `"command":"[^"]*"` truncates at the first JSON-escaped quote, so for
|
|
25
|
+
* a shell command like `printf '%s' "x" > file` the fallback token will NOT
|
|
26
|
+
* match the runner's — the call is still denied (the gate holds) but the
|
|
27
|
+
* denial cannot be overlaid onto the real streamed tool call and a grant for
|
|
28
|
+
* it will not match on reinvocation. All policy decisions are pre-computed by
|
|
29
|
+
* the runner into the state file (and into this generated script); the hook
|
|
30
|
+
* only performs mechanical field extraction and string lookups — the policy
|
|
31
|
+
* itself is authored once in TypeScript (approval-policy.ts /
|
|
32
|
+
* approval-state.ts).
|
|
25
33
|
*
|
|
26
34
|
* Cross-taxonomy identity (the crux):
|
|
27
35
|
* The preToolUse hook and the SDK event stream name the same operation
|
|
@@ -70,10 +78,41 @@ function buildCategoryCaseArms() {
|
|
|
70
78
|
const arms = [];
|
|
71
79
|
for (const [category, names] of byCategory) {
|
|
72
80
|
const pattern = names.map((n) => `"${n}"`).join("|");
|
|
73
|
-
arms.push(`
|
|
81
|
+
arms.push(` ${pattern}) CATEGORY="${category}" ;;`);
|
|
74
82
|
}
|
|
75
83
|
return arms.join("\n");
|
|
76
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the inline Node.js identity extractor embedded in the hook script.
|
|
87
|
+
*
|
|
88
|
+
* Parses the hook's stdin JSON properly (the bash fallback's grep truncates
|
|
89
|
+
* string values at the first escaped quote) and emits four lines:
|
|
90
|
+
* tool_name, canonical category, identity token, and MCP name-token. The token
|
|
91
|
+
* encodings must stay byte-identical to grantToken() in approval-state.ts.
|
|
92
|
+
*
|
|
93
|
+
* Authored as a single-quoted bash string, so the JS must not contain single
|
|
94
|
+
* quotes. The category map and salient field list are baked from
|
|
95
|
+
* approval-policy.ts — the same source the runner uses — so the two sides can
|
|
96
|
+
* never disagree.
|
|
97
|
+
*/
|
|
98
|
+
function buildNodeIdentityScript() {
|
|
99
|
+
const categoryMap = {};
|
|
100
|
+
for (const [name, category] of getBuiltInGatedCategories()) {
|
|
101
|
+
categoryMap[name] = category;
|
|
102
|
+
}
|
|
103
|
+
const categories = JSON.stringify(categoryMap);
|
|
104
|
+
const fields = JSON.stringify(SALIENT_ARG_FIELDS);
|
|
105
|
+
return [
|
|
106
|
+
`const t=JSON.parse(require("fs").readFileSync(0,"utf8"));`,
|
|
107
|
+
`const name=typeof t.tool_name==="string"?t.tool_name:"";`,
|
|
108
|
+
`const cat=(${categories})[name]||"";`,
|
|
109
|
+
`const a=(t.tool_input&&typeof t.tool_input==="object")?t.tool_input:{};`,
|
|
110
|
+
`let s="";`,
|
|
111
|
+
`for(const f of ${fields}){const v=a[f];if(typeof v==="string"&&v){s=v;break;}}`,
|
|
112
|
+
`const b=(x)=>Buffer.from(x,"utf8").toString("base64");`,
|
|
113
|
+
`process.stdout.write(name+"\\n"+cat+"\\n"+b(cat+"\\n"+s)+"\\n"+b(name+"\\n"));`,
|
|
114
|
+
].join("");
|
|
115
|
+
}
|
|
77
116
|
/**
|
|
78
117
|
* Generates the bash hook script content.
|
|
79
118
|
*
|
|
@@ -90,6 +129,7 @@ function buildCategoryCaseArms() {
|
|
|
90
129
|
export function generateHookScript(stateFilePath, ledgerFilePath) {
|
|
91
130
|
const salientFields = SALIENT_ARG_FIELDS.join(" ");
|
|
92
131
|
const categoryCaseArms = buildCategoryCaseArms();
|
|
132
|
+
const nodeIdentityScript = buildNodeIdentityScript();
|
|
93
133
|
return `#!/bin/bash
|
|
94
134
|
# Stigmer HITL approval hook for Cursor preToolUse
|
|
95
135
|
# Generated by cursor-runner — do not edit manually.
|
|
@@ -103,15 +143,45 @@ set -euo pipefail
|
|
|
103
143
|
|
|
104
144
|
INPUT=$(cat)
|
|
105
145
|
|
|
106
|
-
# Extract tool_name from the hook input JSON. The hook receives PascalCase names
|
|
107
|
-
# (Write/Shell/Delete/Read/...). Every extraction ends with '|| true': under
|
|
108
|
-
# 'set -e' a non-matching grep would otherwise abort the script and emit no
|
|
109
|
-
# decision.
|
|
110
|
-
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
111
|
-
|
|
112
146
|
STATE_FILE="${stateFilePath}"
|
|
113
147
|
LEDGER_FILE="${ledgerFilePath}"
|
|
114
148
|
|
|
149
|
+
# --- Canonical identity: tool_name / category / identity token / MCP token ---
|
|
150
|
+
# Computed by the same Node.js binary that runs the cursor-runner (absolute path
|
|
151
|
+
# baked at generation time) so JSON string values — file paths and especially
|
|
152
|
+
# shell commands containing quotes, newlines, or unicode escapes — decode to the
|
|
153
|
+
# exact bytes the runner sees in the stream event. ELECTRON_RUN_AS_NODE makes
|
|
154
|
+
# the invocation safe when the runner is embedded in an Electron app (where
|
|
155
|
+
# process.execPath is the Electron binary).
|
|
156
|
+
NODE_BIN="${process.execPath}"
|
|
157
|
+
IDENTITY=$(printf '%s' "$INPUT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${nodeIdentityScript}' 2>/dev/null || true)
|
|
158
|
+
if [ -n "$IDENTITY" ]; then
|
|
159
|
+
TOOL_NAME=$(printf '%s\\n' "$IDENTITY" | sed -n 1p)
|
|
160
|
+
CATEGORY=$(printf '%s\\n' "$IDENTITY" | sed -n 2p)
|
|
161
|
+
TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 3p)
|
|
162
|
+
MCP_TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 4p)
|
|
163
|
+
else
|
|
164
|
+
# Fallback when the Node binary cannot run: grep/cut extraction. Best-effort
|
|
165
|
+
# only — '"field":"[^"]*"' truncates at the first JSON-escaped quote, so the
|
|
166
|
+
# token may not match the runner's for values containing escapes. Gating still
|
|
167
|
+
# holds (deny goes out); only denial correlation and grant precision degrade.
|
|
168
|
+
# Every extraction ends with '|| true': under 'set -e' a non-matching grep
|
|
169
|
+
# would otherwise abort the script and emit no decision.
|
|
170
|
+
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
171
|
+
SALIENT=""
|
|
172
|
+
for field in ${salientFields}; do
|
|
173
|
+
v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
|
|
174
|
+
if [ -n "$v" ]; then SALIENT="$v"; break; fi
|
|
175
|
+
done
|
|
176
|
+
CATEGORY=""
|
|
177
|
+
case "$TOOL_NAME" in
|
|
178
|
+
${categoryCaseArms}
|
|
179
|
+
*) CATEGORY="" ;;
|
|
180
|
+
esac
|
|
181
|
+
TOKEN=$(printf '%s\\n%s' "$CATEGORY" "$SALIENT" | base64 | tr -d '\\n')
|
|
182
|
+
MCP_TOKEN=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
|
|
183
|
+
fi
|
|
184
|
+
|
|
115
185
|
# --- Failsafe: missing state file → deny (fail-closed) ---
|
|
116
186
|
if [ ! -f "$STATE_FILE" ]; then
|
|
117
187
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
|
|
@@ -126,22 +196,6 @@ if echo "$STATE" | grep -q '"autoApproveAll":true'; then
|
|
|
126
196
|
exit 0
|
|
127
197
|
fi
|
|
128
198
|
|
|
129
|
-
# --- Salient resource value (file path / command), spanning both taxonomies'
|
|
130
|
-
# arg field names (file_path here, path on the stream side). First match wins. ---
|
|
131
|
-
SALIENT=""
|
|
132
|
-
for field in ${salientFields}; do
|
|
133
|
-
v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
|
|
134
|
-
if [ -n "$v" ]; then SALIENT="$v"; break; fi
|
|
135
|
-
done
|
|
136
|
-
|
|
137
|
-
# --- Canonical approval category for this hook tool_name (baked from
|
|
138
|
-
# approval-policy.ts). Empty for non-gated tools. ---
|
|
139
|
-
CATEGORY=""
|
|
140
|
-
case "$TOOL_NAME" in
|
|
141
|
-
${categoryCaseArms}
|
|
142
|
-
*) CATEGORY="" ;;
|
|
143
|
-
esac
|
|
144
|
-
|
|
145
199
|
# Append a denial record to the ledger. Best-effort: a ledger write failure must
|
|
146
200
|
# never abort the decision (the deny still goes out on stdout). toolName is raw
|
|
147
201
|
# for human-readable debugging; token drives correlation in the runner.
|
|
@@ -151,8 +205,6 @@ record_denial() {
|
|
|
151
205
|
|
|
152
206
|
# --- 2. Gated built-in tools (category non-empty) ---
|
|
153
207
|
if [ -n "$CATEGORY" ]; then
|
|
154
|
-
# Canonical identity token: base64("$CATEGORY\\n$SALIENT").
|
|
155
|
-
TOKEN=$(printf '%s\\n%s' "$CATEGORY" "$SALIENT" | base64 | tr -d '\\n')
|
|
156
208
|
# Reinvocation grant: this exact resource was approved earlier → allow.
|
|
157
209
|
if echo "$STATE" | grep -qF "\\"$TOKEN\\""; then
|
|
158
210
|
echo '{"permission":"allow"}'
|
|
@@ -171,7 +223,6 @@ fi
|
|
|
171
223
|
if echo "$STATE" | grep -q "\\"mcpToolPolicies\\"" && [ -n "$TOOL_NAME" ]; then
|
|
172
224
|
TOOL_POLICY=$(echo "$STATE" | grep -o "\\"$TOOL_NAME\\":{[^}]*}" | head -1 || true)
|
|
173
225
|
if [ -n "$TOOL_POLICY" ] && ! echo "$TOOL_POLICY" | grep -q '"requiresApproval":false'; then
|
|
174
|
-
MCP_TOKEN=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
|
|
175
226
|
if echo "$STATE" | grep -qF "\\"$MCP_TOKEN\\""; then
|
|
176
227
|
echo '{"permission":"allow"}'
|
|
177
228
|
exit 0
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-script.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/hook-script.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"hook-script.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/hook-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAEH,OAAO,EAAE,kBAAkB,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAErF,MAAM,+BAA+B,GACnC,0EAA0E;IAC1E,6EAA6E;IAC7E,4EAA4E;IAC5E,sCAAsC,CAAC;AAEzC;;;;GAIG;AACH,SAAS,qBAAqB;IAC5B,MAAM,UAAU,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC/C,KAAK,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,yBAAyB,EAAE,EAAE,CAAC;QAC3D,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;QAC7C,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjB,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAClC,CAAC;IACD,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,KAAK,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,UAAU,EAAE,CAAC;QAC3C,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACrD,IAAI,CAAC,IAAI,CAAC,SAAS,OAAO,eAAe,QAAQ,MAAM,CAAC,CAAC;IAC3D,CAAC;IACD,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACzB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,uBAAuB;IAC9B,MAAM,WAAW,GAA2B,EAAE,CAAC;IAC/C,KAAK,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,yBAAyB,EAAE,EAAE,CAAC;QAC3D,WAAW,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC;IAC/B,CAAC;IACD,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC;IAC/C,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,kBAAkB,CAAC,CAAC;IAClD,OAAO;QACL,2DAA2D;QAC3D,0DAA0D;QAC1D,cAAc,UAAU,cAAc;QACtC,yEAAyE;QACzE,WAAW;QACX,kBAAkB,MAAM,wDAAwD;QAChF,wDAAwD;QACxD,gFAAgF;KACjF,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACb,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,kBAAkB,CAAC,aAAqB,EAAE,cAAsB;IAC9E,MAAM,aAAa,GAAG,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACnD,MAAM,gBAAgB,GAAG,qBAAqB,EAAE,CAAC;IACjD,MAAM,kBAAkB,GAAG,uBAAuB,EAAE,CAAC;IACrD,OAAO;;;;;;;;;;;;;cAaK,aAAa;eACZ,cAAc;;;;;;;;;YASjB,OAAO,CAAC,QAAQ;2EAC+C,kBAAkB;;;;;;;;;;;;;;;iBAe5E,aAAa;;;;;;EAM5B,gBAAgB;;;;;;;;;gDAS8B,+BAA+B;;;;;;;;;;;;;;;;;;;;;;;;;;;gDA2B/B,+BAA+B;;;;;;;;;;;;;;;;;;;;;kDAqB7B,+BAA+B;;;;;;;;;;;CAWhF,CAAC;AACF,CAAC"}
|
|
@@ -56,6 +56,15 @@ export interface ResumeAgentOptions {
|
|
|
56
56
|
apiKey: string;
|
|
57
57
|
agentId: string;
|
|
58
58
|
sessionId: string;
|
|
59
|
+
/**
|
|
60
|
+
* Workspace directories — same as {@link CreateAgentOptions.workspaceDirs}.
|
|
61
|
+
* NOT persisted across Agent.resume(): without an explicit cwd the SDK falls
|
|
62
|
+
* back to process.cwd(), which both mis-roots the resumed agent and loads
|
|
63
|
+
* the "project" setting source (and therefore the HITL approval hook in
|
|
64
|
+
* .cursor/hooks.json) from the wrong directory — silently disabling the
|
|
65
|
+
* approval gate on every resumed turn.
|
|
66
|
+
*/
|
|
67
|
+
workspaceDirs: string[];
|
|
59
68
|
/** Durable workspace volume root; the SDK state store lives under it. */
|
|
60
69
|
workspaceRootDir: string;
|
|
61
70
|
model?: string;
|
|
@@ -129,6 +129,9 @@ export async function createAgent(options) {
|
|
|
129
129
|
* propagate or fall back to a fresh agent with continuation context.
|
|
130
130
|
*/
|
|
131
131
|
export async function resumeAgent(options) {
|
|
132
|
+
const cwd = options.workspaceDirs.length === 1
|
|
133
|
+
? options.workspaceDirs[0]
|
|
134
|
+
: options.workspaceDirs;
|
|
132
135
|
const platform = resolvePlatformOptions(options.sessionId, options.workspaceRootDir);
|
|
133
136
|
console.log(`resumeAgent: agentId=${options.agentId}, sessionId=${options.sessionId}, ` +
|
|
134
137
|
`workspaceRef=${platform.workspaceRef}, stateRoot=${platform.stateRoot}, ` +
|
|
@@ -136,9 +139,13 @@ export async function resumeAgent(options) {
|
|
|
136
139
|
return Agent.resume(options.agentId, {
|
|
137
140
|
apiKey: options.apiKey,
|
|
138
141
|
model: options.model ? { id: options.model } : undefined,
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
+
// Neither cwd nor settingSources survive Agent.resume(); both must be
|
|
143
|
+
// re-supplied every turn. Omitting cwd makes the SDK fall back to
|
|
144
|
+
// process.cwd(), which re-roots the agent in the runner's own working
|
|
145
|
+
// directory and loads the "project" setting source — the .cursor/hooks.json
|
|
146
|
+
// carrying the HITL approval hook — from that wrong directory, silently
|
|
147
|
+
// disabling the approval gate on every resumed turn.
|
|
148
|
+
local: { cwd, settingSources: [...LOCAL_SETTING_SOURCES] },
|
|
142
149
|
mcpServers: options.mcpServers,
|
|
143
150
|
agents: options.agents,
|
|
144
151
|
platform,
|
|
@@ -222,6 +229,7 @@ export async function resolveAgent(harnessStateId, options, mode = "local") {
|
|
|
222
229
|
apiKey: options.apiKey,
|
|
223
230
|
agentId: harnessStateId,
|
|
224
231
|
sessionId: options.sessionId,
|
|
232
|
+
workspaceDirs: options.workspaceDirs,
|
|
225
233
|
workspaceRootDir: options.workspaceRootDir,
|
|
226
234
|
model: options.model,
|
|
227
235
|
mcpServers: options.mcpServers,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-lifecycle.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAIpC,8EAA8E;AAC9E,YAAY;AACZ,8EAA8E;AAE9E,MAAM,oBAAoB,GAAG,2BAA2B,CAAC;AAEzD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,qBAAqB,GAAG,CAAC,SAAS,CAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"session-lifecycle.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/session-lifecycle.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@opentelemetry/resources": "^2.0.0",
|
|
85
85
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
86
86
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
87
|
-
"@stigmer/protos": "3.0.
|
|
87
|
+
"@stigmer/protos": "3.0.4",
|
|
88
88
|
"@temporalio/activity": "^1.11.0",
|
|
89
89
|
"@temporalio/client": "^1.11.0",
|
|
90
90
|
"@temporalio/common": "^1.11.0",
|
|
@@ -146,4 +146,59 @@ d("generated preToolUse hook", () => {
|
|
|
146
146
|
const h = setup({ noStateFile: true });
|
|
147
147
|
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("deny");
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
// Regression: the original grep-based extraction truncated string values at
|
|
151
|
+
// the first JSON-escaped character, so a shell command containing double
|
|
152
|
+
// quotes (e.g. `printf '%s' 'x' > "file"`) produced a ledger token that never
|
|
153
|
+
// matched the runner's grantToken — the denied call stayed COMPLETED in the
|
|
154
|
+
// persisted messages and a grant for it was re-denied on reinvocation.
|
|
155
|
+
// (Observed live in TestCursorHarness_HITL_ResumedTurn_StillGated.)
|
|
156
|
+
it("records a byte-identical token for commands with quotes, escapes, and newlines", () => {
|
|
157
|
+
const commands = [
|
|
158
|
+
'printf \'%s\' \'hello\' > "/tmp/a dir/resumed-gate.txt"',
|
|
159
|
+
'echo "double \\"nested\\" quotes" && echo done',
|
|
160
|
+
"line1\nline2\twith\ttabs",
|
|
161
|
+
'unicode: caf\u00e9 \u2014 emoji \u{1F600}',
|
|
162
|
+
];
|
|
163
|
+
for (const command of commands) {
|
|
164
|
+
const h = setup({});
|
|
165
|
+
expect(h.decide(hookShell(command)).permission).toBe("deny");
|
|
166
|
+
const ledger = h.ledger();
|
|
167
|
+
expect(ledger).toHaveLength(1);
|
|
168
|
+
expect(ledger[0].token).toBe(grantToken("shell", command));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("allows the exact granted shell command even when it contains quotes", () => {
|
|
173
|
+
const command = 'printf \'%s\' \'hello-resume\' > "/x/resumed-gate.txt"';
|
|
174
|
+
const id = toolIdentity("shell", "", { command });
|
|
175
|
+
const h = setup({ grants: [{ toolName: "shell", mcpServerSlug: "", key: id.key, salient: id.salient }] });
|
|
176
|
+
|
|
177
|
+
expect(h.decide(hookShell(command)).permission).toBe("allow");
|
|
178
|
+
// A different command is NOT covered by the grant -> still gated.
|
|
179
|
+
expect(h.decide(hookShell('rm -rf "/x"')).permission).toBe("deny");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("still denies gated tools via the bash fallback when the Node binary is unavailable", () => {
|
|
183
|
+
const ws = mkdtempSync(join(tmpdir(), "hook-script-fallback-"));
|
|
184
|
+
tempDirs.push(ws);
|
|
185
|
+
const dir = join(ws, ".cursor", "hooks");
|
|
186
|
+
mkdirSync(dir, { recursive: true });
|
|
187
|
+
const statePath = join(dir, "state.json");
|
|
188
|
+
const ledgerPath = join(dir, "denials.jsonl");
|
|
189
|
+
const scriptPath = join(dir, "hook.sh");
|
|
190
|
+
// Break the baked Node path to force the grep/cut fallback.
|
|
191
|
+
const script = generateHookScript(statePath, ledgerPath)
|
|
192
|
+
.replace(`NODE_BIN="${process.execPath}"`, 'NODE_BIN="/nonexistent/node"');
|
|
193
|
+
writeFileSync(scriptPath, script, "utf-8");
|
|
194
|
+
writeFileSync(statePath, JSON.stringify(buildApprovalState(new Map(), false)), "utf-8");
|
|
195
|
+
|
|
196
|
+
const raw = execFileSync("bash", [scriptPath], {
|
|
197
|
+
input: JSON.stringify(hookWrite("/x/a.txt")),
|
|
198
|
+
}).toString();
|
|
199
|
+
expect(raw).toContain('"permission":"deny"');
|
|
200
|
+
const ledger = readFileSync(ledgerPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
201
|
+
expect(ledger).toHaveLength(1);
|
|
202
|
+
expect(ledger[0].token).toBe(grantToken("write", "/x/a.txt"));
|
|
203
|
+
});
|
|
149
204
|
});
|
|
@@ -7,12 +7,20 @@
|
|
|
7
7
|
* collide. These invariants are correctness-critical, hence the explicit tests.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
10
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
11
11
|
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
vi.mock("@cursor/sdk", () => ({
|
|
16
|
+
Agent: {
|
|
17
|
+
create: vi.fn(async () => ({ agentId: "agent-created" })),
|
|
18
|
+
resume: vi.fn(async () => ({ agentId: "agent-resumed" })),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { Agent } from "@cursor/sdk";
|
|
23
|
+
import { resolvePlatformOptions, createAgent, resumeAgent } from "../session-lifecycle.js";
|
|
16
24
|
|
|
17
25
|
const tempRoots: string[] = [];
|
|
18
26
|
|
|
@@ -63,3 +71,66 @@ describe("resolvePlatformOptions", () => {
|
|
|
63
71
|
expect(() => resolvePlatformOptions("ses-123", "")).toThrow(/workspaceRootDir is required/);
|
|
64
72
|
});
|
|
65
73
|
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Workspace binding across create/resume
|
|
77
|
+
//
|
|
78
|
+
// Regression: Agent.resume() does not persist local.cwd. When resumeAgent()
|
|
79
|
+
// omitted it, the SDK fell back to process.cwd() — re-rooting the resumed
|
|
80
|
+
// agent in the runner's own working directory and loading the "project"
|
|
81
|
+
// setting source (the .cursor/hooks.json carrying the HITL approval hook)
|
|
82
|
+
// from that wrong directory. Result: on every resumed turn, file edits and
|
|
83
|
+
// shell commands ran unguarded with no approval card (observed in production
|
|
84
|
+
// execution aex_01ktr5na07f5xtmn0dz3mfjtdp).
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe("workspace binding on create/resume", () => {
|
|
88
|
+
const baseOptions = {
|
|
89
|
+
apiKey: "key",
|
|
90
|
+
sessionId: "ses-cwd-test",
|
|
91
|
+
model: "gpt-test",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
it("createAgent passes the single workspace dir as local.cwd", async () => {
|
|
95
|
+
const workspaceRootDir = freshWorkspaceRoot();
|
|
96
|
+
await createAgent({
|
|
97
|
+
...baseOptions,
|
|
98
|
+
workspaceDirs: ["/work/repo-a"],
|
|
99
|
+
workspaceRootDir,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const callOptions = vi.mocked(Agent.create).mock.calls.at(-1)![0] as any;
|
|
103
|
+
expect(callOptions.local.cwd).toBe("/work/repo-a");
|
|
104
|
+
expect(callOptions.local.settingSources).toContain("project");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("resumeAgent re-supplies local.cwd (not persisted by Agent.resume)", async () => {
|
|
108
|
+
const workspaceRootDir = freshWorkspaceRoot();
|
|
109
|
+
await resumeAgent({
|
|
110
|
+
...baseOptions,
|
|
111
|
+
agentId: "agent-123",
|
|
112
|
+
workspaceDirs: ["/work/repo-a"],
|
|
113
|
+
workspaceRootDir,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const [agentId, callOptions] = vi.mocked(Agent.resume).mock.calls.at(-1)! as [string, any];
|
|
117
|
+
expect(agentId).toBe("agent-123");
|
|
118
|
+
// The load-bearing assertion: without cwd the SDK re-roots the agent at
|
|
119
|
+
// process.cwd() and the project HITL hook never loads on resumed turns.
|
|
120
|
+
expect(callOptions.local.cwd).toBe("/work/repo-a");
|
|
121
|
+
expect(callOptions.local.settingSources).toContain("project");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("resumeAgent passes multiple workspace dirs as an array cwd", async () => {
|
|
125
|
+
const workspaceRootDir = freshWorkspaceRoot();
|
|
126
|
+
await resumeAgent({
|
|
127
|
+
...baseOptions,
|
|
128
|
+
agentId: "agent-456",
|
|
129
|
+
workspaceDirs: ["/work/repo-a", "/work/repo-b"],
|
|
130
|
+
workspaceRootDir,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const callOptions = vi.mocked(Agent.resume).mock.calls.at(-1)![1] as any;
|
|
134
|
+
expect(callOptions.local.cwd).toEqual(["/work/repo-a", "/work/repo-b"]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -16,12 +16,20 @@
|
|
|
16
16
|
* so its ledger is the authoritative record of what was gated this turn
|
|
17
17
|
* 5. Returns { "permission": "allow" } or { "permission": "deny" } on stdout
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* Identity extraction runs on the SAME Node.js binary as the runner (its
|
|
20
|
+
* absolute path — process.execPath — is baked into the script at generation
|
|
21
|
+
* time), because the identity token must be byte-identical to the one the
|
|
22
|
+
* runner computes from the parsed stream event. The original grep/cut
|
|
23
|
+
* extraction is kept only as a best-effort fallback if that binary cannot run:
|
|
24
|
+
* grep's `"command":"[^"]*"` truncates at the first JSON-escaped quote, so for
|
|
25
|
+
* a shell command like `printf '%s' "x" > file` the fallback token will NOT
|
|
26
|
+
* match the runner's — the call is still denied (the gate holds) but the
|
|
27
|
+
* denial cannot be overlaid onto the real streamed tool call and a grant for
|
|
28
|
+
* it will not match on reinvocation. All policy decisions are pre-computed by
|
|
29
|
+
* the runner into the state file (and into this generated script); the hook
|
|
30
|
+
* only performs mechanical field extraction and string lookups — the policy
|
|
31
|
+
* itself is authored once in TypeScript (approval-policy.ts /
|
|
32
|
+
* approval-state.ts).
|
|
25
33
|
*
|
|
26
34
|
* Cross-taxonomy identity (the crux):
|
|
27
35
|
* The preToolUse hook and the SDK event stream name the same operation
|
|
@@ -74,11 +82,43 @@ function buildCategoryCaseArms(): string {
|
|
|
74
82
|
const arms: string[] = [];
|
|
75
83
|
for (const [category, names] of byCategory) {
|
|
76
84
|
const pattern = names.map((n) => `"${n}"`).join("|");
|
|
77
|
-
arms.push(`
|
|
85
|
+
arms.push(` ${pattern}) CATEGORY="${category}" ;;`);
|
|
78
86
|
}
|
|
79
87
|
return arms.join("\n");
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Build the inline Node.js identity extractor embedded in the hook script.
|
|
92
|
+
*
|
|
93
|
+
* Parses the hook's stdin JSON properly (the bash fallback's grep truncates
|
|
94
|
+
* string values at the first escaped quote) and emits four lines:
|
|
95
|
+
* tool_name, canonical category, identity token, and MCP name-token. The token
|
|
96
|
+
* encodings must stay byte-identical to grantToken() in approval-state.ts.
|
|
97
|
+
*
|
|
98
|
+
* Authored as a single-quoted bash string, so the JS must not contain single
|
|
99
|
+
* quotes. The category map and salient field list are baked from
|
|
100
|
+
* approval-policy.ts — the same source the runner uses — so the two sides can
|
|
101
|
+
* never disagree.
|
|
102
|
+
*/
|
|
103
|
+
function buildNodeIdentityScript(): string {
|
|
104
|
+
const categoryMap: Record<string, string> = {};
|
|
105
|
+
for (const [name, category] of getBuiltInGatedCategories()) {
|
|
106
|
+
categoryMap[name] = category;
|
|
107
|
+
}
|
|
108
|
+
const categories = JSON.stringify(categoryMap);
|
|
109
|
+
const fields = JSON.stringify(SALIENT_ARG_FIELDS);
|
|
110
|
+
return [
|
|
111
|
+
`const t=JSON.parse(require("fs").readFileSync(0,"utf8"));`,
|
|
112
|
+
`const name=typeof t.tool_name==="string"?t.tool_name:"";`,
|
|
113
|
+
`const cat=(${categories})[name]||"";`,
|
|
114
|
+
`const a=(t.tool_input&&typeof t.tool_input==="object")?t.tool_input:{};`,
|
|
115
|
+
`let s="";`,
|
|
116
|
+
`for(const f of ${fields}){const v=a[f];if(typeof v==="string"&&v){s=v;break;}}`,
|
|
117
|
+
`const b=(x)=>Buffer.from(x,"utf8").toString("base64");`,
|
|
118
|
+
`process.stdout.write(name+"\\n"+cat+"\\n"+b(cat+"\\n"+s)+"\\n"+b(name+"\\n"));`,
|
|
119
|
+
].join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
82
122
|
/**
|
|
83
123
|
* Generates the bash hook script content.
|
|
84
124
|
*
|
|
@@ -95,6 +135,7 @@ function buildCategoryCaseArms(): string {
|
|
|
95
135
|
export function generateHookScript(stateFilePath: string, ledgerFilePath: string): string {
|
|
96
136
|
const salientFields = SALIENT_ARG_FIELDS.join(" ");
|
|
97
137
|
const categoryCaseArms = buildCategoryCaseArms();
|
|
138
|
+
const nodeIdentityScript = buildNodeIdentityScript();
|
|
98
139
|
return `#!/bin/bash
|
|
99
140
|
# Stigmer HITL approval hook for Cursor preToolUse
|
|
100
141
|
# Generated by cursor-runner — do not edit manually.
|
|
@@ -108,15 +149,45 @@ set -euo pipefail
|
|
|
108
149
|
|
|
109
150
|
INPUT=$(cat)
|
|
110
151
|
|
|
111
|
-
# Extract tool_name from the hook input JSON. The hook receives PascalCase names
|
|
112
|
-
# (Write/Shell/Delete/Read/...). Every extraction ends with '|| true': under
|
|
113
|
-
# 'set -e' a non-matching grep would otherwise abort the script and emit no
|
|
114
|
-
# decision.
|
|
115
|
-
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
116
|
-
|
|
117
152
|
STATE_FILE="${stateFilePath}"
|
|
118
153
|
LEDGER_FILE="${ledgerFilePath}"
|
|
119
154
|
|
|
155
|
+
# --- Canonical identity: tool_name / category / identity token / MCP token ---
|
|
156
|
+
# Computed by the same Node.js binary that runs the cursor-runner (absolute path
|
|
157
|
+
# baked at generation time) so JSON string values — file paths and especially
|
|
158
|
+
# shell commands containing quotes, newlines, or unicode escapes — decode to the
|
|
159
|
+
# exact bytes the runner sees in the stream event. ELECTRON_RUN_AS_NODE makes
|
|
160
|
+
# the invocation safe when the runner is embedded in an Electron app (where
|
|
161
|
+
# process.execPath is the Electron binary).
|
|
162
|
+
NODE_BIN="${process.execPath}"
|
|
163
|
+
IDENTITY=$(printf '%s' "$INPUT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${nodeIdentityScript}' 2>/dev/null || true)
|
|
164
|
+
if [ -n "$IDENTITY" ]; then
|
|
165
|
+
TOOL_NAME=$(printf '%s\\n' "$IDENTITY" | sed -n 1p)
|
|
166
|
+
CATEGORY=$(printf '%s\\n' "$IDENTITY" | sed -n 2p)
|
|
167
|
+
TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 3p)
|
|
168
|
+
MCP_TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 4p)
|
|
169
|
+
else
|
|
170
|
+
# Fallback when the Node binary cannot run: grep/cut extraction. Best-effort
|
|
171
|
+
# only — '"field":"[^"]*"' truncates at the first JSON-escaped quote, so the
|
|
172
|
+
# token may not match the runner's for values containing escapes. Gating still
|
|
173
|
+
# holds (deny goes out); only denial correlation and grant precision degrade.
|
|
174
|
+
# Every extraction ends with '|| true': under 'set -e' a non-matching grep
|
|
175
|
+
# would otherwise abort the script and emit no decision.
|
|
176
|
+
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
177
|
+
SALIENT=""
|
|
178
|
+
for field in ${salientFields}; do
|
|
179
|
+
v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
|
|
180
|
+
if [ -n "$v" ]; then SALIENT="$v"; break; fi
|
|
181
|
+
done
|
|
182
|
+
CATEGORY=""
|
|
183
|
+
case "$TOOL_NAME" in
|
|
184
|
+
${categoryCaseArms}
|
|
185
|
+
*) CATEGORY="" ;;
|
|
186
|
+
esac
|
|
187
|
+
TOKEN=$(printf '%s\\n%s' "$CATEGORY" "$SALIENT" | base64 | tr -d '\\n')
|
|
188
|
+
MCP_TOKEN=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
|
|
189
|
+
fi
|
|
190
|
+
|
|
120
191
|
# --- Failsafe: missing state file → deny (fail-closed) ---
|
|
121
192
|
if [ ! -f "$STATE_FILE" ]; then
|
|
122
193
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
|
|
@@ -131,22 +202,6 @@ if echo "$STATE" | grep -q '"autoApproveAll":true'; then
|
|
|
131
202
|
exit 0
|
|
132
203
|
fi
|
|
133
204
|
|
|
134
|
-
# --- Salient resource value (file path / command), spanning both taxonomies'
|
|
135
|
-
# arg field names (file_path here, path on the stream side). First match wins. ---
|
|
136
|
-
SALIENT=""
|
|
137
|
-
for field in ${salientFields}; do
|
|
138
|
-
v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
|
|
139
|
-
if [ -n "$v" ]; then SALIENT="$v"; break; fi
|
|
140
|
-
done
|
|
141
|
-
|
|
142
|
-
# --- Canonical approval category for this hook tool_name (baked from
|
|
143
|
-
# approval-policy.ts). Empty for non-gated tools. ---
|
|
144
|
-
CATEGORY=""
|
|
145
|
-
case "$TOOL_NAME" in
|
|
146
|
-
${categoryCaseArms}
|
|
147
|
-
*) CATEGORY="" ;;
|
|
148
|
-
esac
|
|
149
|
-
|
|
150
205
|
# Append a denial record to the ledger. Best-effort: a ledger write failure must
|
|
151
206
|
# never abort the decision (the deny still goes out on stdout). toolName is raw
|
|
152
207
|
# for human-readable debugging; token drives correlation in the runner.
|
|
@@ -156,8 +211,6 @@ record_denial() {
|
|
|
156
211
|
|
|
157
212
|
# --- 2. Gated built-in tools (category non-empty) ---
|
|
158
213
|
if [ -n "$CATEGORY" ]; then
|
|
159
|
-
# Canonical identity token: base64("$CATEGORY\\n$SALIENT").
|
|
160
|
-
TOKEN=$(printf '%s\\n%s' "$CATEGORY" "$SALIENT" | base64 | tr -d '\\n')
|
|
161
214
|
# Reinvocation grant: this exact resource was approved earlier → allow.
|
|
162
215
|
if echo "$STATE" | grep -qF "\\"$TOKEN\\""; then
|
|
163
216
|
echo '{"permission":"allow"}'
|
|
@@ -176,7 +229,6 @@ fi
|
|
|
176
229
|
if echo "$STATE" | grep -q "\\"mcpToolPolicies\\"" && [ -n "$TOOL_NAME" ]; then
|
|
177
230
|
TOOL_POLICY=$(echo "$STATE" | grep -o "\\"$TOOL_NAME\\":{[^}]*}" | head -1 || true)
|
|
178
231
|
if [ -n "$TOOL_POLICY" ] && ! echo "$TOOL_POLICY" | grep -q '"requiresApproval":false'; then
|
|
179
|
-
MCP_TOKEN=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
|
|
180
232
|
if echo "$STATE" | grep -qF "\\"$MCP_TOKEN\\""; then
|
|
181
233
|
echo '{"permission":"allow"}'
|
|
182
234
|
exit 0
|
|
@@ -93,6 +93,15 @@ export interface ResumeAgentOptions {
|
|
|
93
93
|
apiKey: string;
|
|
94
94
|
agentId: string;
|
|
95
95
|
sessionId: string;
|
|
96
|
+
/**
|
|
97
|
+
* Workspace directories — same as {@link CreateAgentOptions.workspaceDirs}.
|
|
98
|
+
* NOT persisted across Agent.resume(): without an explicit cwd the SDK falls
|
|
99
|
+
* back to process.cwd(), which both mis-roots the resumed agent and loads
|
|
100
|
+
* the "project" setting source (and therefore the HITL approval hook in
|
|
101
|
+
* .cursor/hooks.json) from the wrong directory — silently disabling the
|
|
102
|
+
* approval gate on every resumed turn.
|
|
103
|
+
*/
|
|
104
|
+
workspaceDirs: string[];
|
|
96
105
|
/** Durable workspace volume root; the SDK state store lives under it. */
|
|
97
106
|
workspaceRootDir: string;
|
|
98
107
|
model?: string;
|
|
@@ -244,6 +253,10 @@ export async function createAgent(options: CreateAgentOptions): Promise<SDKAgent
|
|
|
244
253
|
* propagate or fall back to a fresh agent with continuation context.
|
|
245
254
|
*/
|
|
246
255
|
export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent> {
|
|
256
|
+
const cwd = options.workspaceDirs.length === 1
|
|
257
|
+
? options.workspaceDirs[0]
|
|
258
|
+
: options.workspaceDirs;
|
|
259
|
+
|
|
247
260
|
const platform = resolvePlatformOptions(options.sessionId, options.workspaceRootDir);
|
|
248
261
|
console.log(
|
|
249
262
|
`resumeAgent: agentId=${options.agentId}, sessionId=${options.sessionId}, ` +
|
|
@@ -254,9 +267,13 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent
|
|
|
254
267
|
return Agent.resume(options.agentId, {
|
|
255
268
|
apiKey: options.apiKey,
|
|
256
269
|
model: options.model ? { id: options.model } : undefined,
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
270
|
+
// Neither cwd nor settingSources survive Agent.resume(); both must be
|
|
271
|
+
// re-supplied every turn. Omitting cwd makes the SDK fall back to
|
|
272
|
+
// process.cwd(), which re-roots the agent in the runner's own working
|
|
273
|
+
// directory and loads the "project" setting source — the .cursor/hooks.json
|
|
274
|
+
// carrying the HITL approval hook — from that wrong directory, silently
|
|
275
|
+
// disabling the approval gate on every resumed turn.
|
|
276
|
+
local: { cwd, settingSources: [...LOCAL_SETTING_SOURCES] },
|
|
260
277
|
mcpServers: options.mcpServers as Record<string, any>,
|
|
261
278
|
agents: options.agents,
|
|
262
279
|
platform,
|
|
@@ -355,6 +372,7 @@ export async function resolveAgent(
|
|
|
355
372
|
apiKey: options.apiKey,
|
|
356
373
|
agentId: harnessStateId,
|
|
357
374
|
sessionId: (options as CreateAgentOptions).sessionId,
|
|
375
|
+
workspaceDirs: (options as CreateAgentOptions).workspaceDirs,
|
|
358
376
|
workspaceRootDir: (options as CreateAgentOptions).workspaceRootDir,
|
|
359
377
|
model: options.model,
|
|
360
378
|
mcpServers: options.mcpServers,
|