@stigmer/runner 3.12.1 → 3.12.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/execute-cursor/capture-flow.d.ts +5 -4
- package/dist/activities/execute-cursor/capture-flow.js +5 -4
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
- package/dist/activities/execute-cursor/cas-observations.js +10 -4
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +39 -13
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +23 -0
- package/dist/activities/execute-cursor/skill-resolver.js +121 -32
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +8 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +25 -10
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/generate-session-subject.d.ts +80 -0
- package/dist/activities/generate-session-subject.js +283 -0
- package/dist/activities/generate-session-subject.js.map +1 -0
- package/dist/client/stigmer-client.d.ts +17 -1
- package/dist/client/stigmer-client.js +20 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +25 -10
- package/dist/middleware/approval-gate.js +33 -14
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -5
- package/dist/middleware/index.js +8 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/otel-spans.d.ts +2 -1
- package/dist/middleware/otel-spans.js +2 -1
- package/dist/middleware/otel-spans.js.map +1 -1
- package/dist/middleware/tool-intent.d.ts +57 -0
- package/dist/middleware/tool-intent.js +152 -0
- package/dist/middleware/tool-intent.js.map +1 -0
- package/dist/runner-manager.js +3 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +3 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/attachment-vision.js +9 -0
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/mcp-manager.js +8 -0
- package/dist/shared/mcp-manager.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +267 -8
- package/src/activities/execute-cursor/capture-flow.ts +5 -4
- package/src/activities/execute-cursor/cas-observations.ts +10 -4
- package/src/activities/execute-cursor/hook-script.ts +39 -13
- package/src/activities/execute-cursor/skill-resolver.ts +148 -37
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
- package/src/activities/execute-deep-agent/setup.ts +8 -0
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -10
- package/src/activities/generate-session-subject.ts +370 -0
- package/src/client/stigmer-client.ts +23 -1
- package/src/middleware/__tests__/approval-gate.test.ts +130 -1
- package/src/middleware/__tests__/tool-intent.test.ts +266 -0
- package/src/middleware/approval-gate.ts +58 -24
- package/src/middleware/index.ts +9 -5
- package/src/middleware/otel-spans.ts +2 -1
- package/src/middleware/tool-intent.ts +174 -0
- package/src/runner-manager.ts +3 -0
- package/src/runner.ts +3 -0
- package/src/shared/__tests__/attachment-vision.test.ts +4 -0
- package/src/shared/__tests__/mcp-manager.test.ts +7 -2
- package/src/shared/attachment-vision.ts +9 -0
- package/src/shared/filereview/__tests__/capture.test.ts +48 -0
- package/src/shared/mcp-manager.ts +8 -0
|
@@ -753,11 +753,47 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
753
753
|
expect(obs.secretPaths).toEqual([]);
|
|
754
754
|
});
|
|
755
755
|
|
|
756
|
-
it("
|
|
756
|
+
it("stages a non-secret gitignored DELETE and allows it (issue #303)", async () => {
|
|
757
757
|
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
758
|
-
|
|
758
|
+
writeFileSync(join(h.root, "app.log"), "DOOMED", "utf-8");
|
|
759
|
+
expect(h.decide(hookDelete("app.log")).permission).toBe("allow");
|
|
760
|
+
// Flowed, not denied — reviewed post-hoc as a DELETE entry, not a pause.
|
|
761
|
+
expect(h.ledger()).toEqual([]);
|
|
762
|
+
const obs = await h.observations();
|
|
763
|
+
expect(obs.secretPaths).toEqual([]);
|
|
764
|
+
expect(obs.captured).toHaveLength(1);
|
|
765
|
+
expect(obs.captured[0].path).toBe("app.log");
|
|
766
|
+
// The pre-delete bytes are staged — the restorable "before" side.
|
|
767
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("DOOMED");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("first-touch-wins across write-then-delete: the true pre-turn before survives", async () => {
|
|
771
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
772
|
+
writeFileSync(join(h.root, "app.log"), "ORIGINAL", "utf-8");
|
|
773
|
+
expect(h.decide(hookWrite("app.log", "REWRITTEN")).permission).toBe("allow");
|
|
774
|
+
// Simulate the write having applied, then a delete later this turn.
|
|
775
|
+
writeFileSync(join(h.root, "app.log"), "REWRITTEN", "utf-8");
|
|
776
|
+
expect(h.decide(hookDelete("app.log")).permission).toBe("allow");
|
|
777
|
+
const obs = await h.observations();
|
|
778
|
+
expect(obs.captured).toHaveLength(1);
|
|
779
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("keeps a SECRET-LIKE gitignored delete on the deny-gate: approvable, never staged (issue #303)", async () => {
|
|
783
|
+
// Unlike a secret write (hard-blocked — its content must never surface), a
|
|
784
|
+
// delete's args expose no secret content, so a human may approve it. Its
|
|
785
|
+
// before-bytes must never enter the sidecar though: staging would both
|
|
786
|
+
// persist the secret bytes and mark the path "secret", making the boundary
|
|
787
|
+
// author a blocking DIFF_UNREVIEWABLE for a merely-gated delete.
|
|
788
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: [".env"] });
|
|
789
|
+
writeFileSync(join(h.root, ".env"), "API_KEY=abc", "utf-8");
|
|
790
|
+
const d = h.decide(hookDelete(".env"));
|
|
791
|
+
expect(d.permission).toBe("deny");
|
|
792
|
+
expect(d.raw).toContain("submitted to the user for approval"); // gated, not blocked
|
|
793
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
|
|
759
794
|
const obs = await h.observations();
|
|
760
795
|
expect(obs.captured).toEqual([]);
|
|
796
|
+
expect(obs.secretPaths).toEqual([]); // no secret marker for a gated delete
|
|
761
797
|
});
|
|
762
798
|
|
|
763
799
|
it("with captureIgnored OFF, a gitignored write stays denied and stages nothing", async () => {
|
|
@@ -768,10 +804,10 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
768
804
|
});
|
|
769
805
|
});
|
|
770
806
|
|
|
771
|
-
// Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write
|
|
772
|
-
// CAS-staged and flowed for review
|
|
773
|
-
//
|
|
774
|
-
//
|
|
807
|
+
// Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write and
|
|
808
|
+
// (issue #303) every non-secret delete is CAS-staged and flowed for review —
|
|
809
|
+
// not only gitignored ones — while shell/MCP gate as always. The workspace is
|
|
810
|
+
// deliberately NOT git-initialized.
|
|
775
811
|
describe("non-git workspace CAS capture (Slice 2c)", () => {
|
|
776
812
|
it("stages EVERY write (not just gitignored) and allows it, no denial", async () => {
|
|
777
813
|
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
@@ -806,12 +842,27 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
806
842
|
expect(obs.secretPaths).toEqual([".env"]);
|
|
807
843
|
});
|
|
808
844
|
|
|
809
|
-
it("
|
|
845
|
+
it("stages a DELETE with its pre-delete bytes and allows it (issue #303)", async () => {
|
|
810
846
|
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
811
|
-
|
|
812
|
-
expect(h.
|
|
847
|
+
writeFileSync(join(h.root, "notes.md"), "KEEP ME", "utf-8");
|
|
848
|
+
expect(h.decide(hookDelete("notes.md")).permission).toBe("allow");
|
|
849
|
+
expect(h.ledger()).toEqual([]);
|
|
850
|
+
const obs = await h.observations();
|
|
851
|
+
expect(obs.captured).toHaveLength(1);
|
|
852
|
+
expect(obs.captured[0].path).toBe("notes.md");
|
|
853
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("KEEP ME");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("keeps a secret-like DELETE gated (approvable) and stages nothing", async () => {
|
|
857
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
858
|
+
writeFileSync(join(h.root, ".env"), "API_KEY=abc", "utf-8");
|
|
859
|
+
const d = h.decide(hookDelete(".env"));
|
|
860
|
+
expect(d.permission).toBe("deny");
|
|
861
|
+
expect(d.raw).toContain("submitted to the user for approval");
|
|
862
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
|
|
813
863
|
const obs = await h.observations();
|
|
814
864
|
expect(obs.captured).toEqual([]);
|
|
865
|
+
expect(obs.secretPaths).toEqual([]);
|
|
815
866
|
});
|
|
816
867
|
|
|
817
868
|
it("still gates shell (never git-reversible, never CAS-captured)", async () => {
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import { ConnectError, Code } from "@connectrpc/connect";
|
|
6
|
+
import { resolveSkills, downloadArtifact } from "../skill-resolver.js";
|
|
6
7
|
import { buildZip } from "../../../__test-utils__/zip-fixtures.js";
|
|
7
8
|
|
|
9
|
+
/** A server that predates the transfer lane (#675) answers the mint RPC
|
|
10
|
+
* with UNIMPLEMENTED — the default posture for these tests, which keeps
|
|
11
|
+
* every pre-existing case pinned to the unary getSkillArtifact fallback. */
|
|
12
|
+
function unimplementedMint() {
|
|
13
|
+
return vi.fn().mockRejectedValue(new ConnectError("unimplemented", Code.Unimplemented));
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
9
17
|
|
|
10
18
|
function makeTempDir(prefix: string): string {
|
|
@@ -17,6 +25,7 @@ function makeSkillProto(overrides: {
|
|
|
17
25
|
description?: string;
|
|
18
26
|
skillMd?: string;
|
|
19
27
|
artifactStorageKey?: string;
|
|
28
|
+
versionHash?: string;
|
|
20
29
|
} = {}) {
|
|
21
30
|
return {
|
|
22
31
|
metadata: {
|
|
@@ -31,7 +40,7 @@ function makeSkillProto(overrides: {
|
|
|
31
40
|
},
|
|
32
41
|
status: {
|
|
33
42
|
artifactStorageKey: overrides.artifactStorageKey ?? "",
|
|
34
|
-
versionHash: "abc",
|
|
43
|
+
versionHash: overrides.versionHash ?? "abc",
|
|
35
44
|
},
|
|
36
45
|
} as any;
|
|
37
46
|
}
|
|
@@ -72,15 +81,18 @@ describe("resolveSkills — artifact extraction", () => {
|
|
|
72
81
|
const client = {
|
|
73
82
|
getSkillByReference: vi.fn(),
|
|
74
83
|
getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
|
|
84
|
+
getSkillArtifactDownloadUrl: unimplementedMint(),
|
|
75
85
|
...clientOverrides,
|
|
76
86
|
} as any;
|
|
77
87
|
|
|
88
|
+
// Re-run resolution for the same session — the mount-cache tests model
|
|
89
|
+
// one session's successive executions, which share the platform dir.
|
|
90
|
+
const resolveAgain = () =>
|
|
91
|
+
resolveSkills(client, refs, { sessionId, primaryWorkspaceDir: workspaceDir });
|
|
92
|
+
|
|
78
93
|
try {
|
|
79
|
-
const result = await
|
|
80
|
-
|
|
81
|
-
primaryWorkspaceDir: workspaceDir,
|
|
82
|
-
});
|
|
83
|
-
return { result, platformDir, client };
|
|
94
|
+
const result = await resolveAgain();
|
|
95
|
+
return { result, platformDir, client, resolveAgain };
|
|
84
96
|
} catch (err) {
|
|
85
97
|
// Clean up on failure
|
|
86
98
|
rmSync(platformDir, { recursive: true, force: true });
|
|
@@ -189,6 +201,159 @@ describe("resolveSkills — artifact extraction", () => {
|
|
|
189
201
|
}
|
|
190
202
|
});
|
|
191
203
|
|
|
204
|
+
it("re-resolving an unchanged version skips the artifact download (mount cache hit)", async () => {
|
|
205
|
+
const artifact = buildZip([
|
|
206
|
+
{ name: "SKILL.md", content: "# Cached" },
|
|
207
|
+
{ name: "references/guide.md", content: "guide" },
|
|
208
|
+
]);
|
|
209
|
+
const getSkillByReference = vi.fn().mockResolvedValue(
|
|
210
|
+
makeSkillProto({
|
|
211
|
+
name: "cached-skill",
|
|
212
|
+
slug: "cached-skill",
|
|
213
|
+
skillMd: "# Cached",
|
|
214
|
+
artifactStorageKey: "artifacts/cached.zip",
|
|
215
|
+
versionHash: "hash-v1",
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
const getSkillArtifact = vi.fn().mockResolvedValue({ artifact });
|
|
219
|
+
|
|
220
|
+
const { result, platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
221
|
+
[makeRef("cached-skill")],
|
|
222
|
+
{ getSkillByReference, getSkillArtifact },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
expect(result).toHaveLength(1);
|
|
227
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
|
|
228
|
+
|
|
229
|
+
const second = await resolveAgain();
|
|
230
|
+
expect(second).toHaveLength(1);
|
|
231
|
+
expect(second[0].name).toBe("cached-skill");
|
|
232
|
+
// Metadata was re-fetched (latest-version freshness)...
|
|
233
|
+
expect(client.getSkillByReference).toHaveBeenCalledTimes(2);
|
|
234
|
+
// ...but the artifact transfer and rewrite were skipped.
|
|
235
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
|
|
236
|
+
|
|
237
|
+
const skillDir = join(platformDir, "skills", "cached-skill");
|
|
238
|
+
expect(readFileSync(join(skillDir, "references", "guide.md"), "utf-8")).toBe("guide");
|
|
239
|
+
} finally {
|
|
240
|
+
cleanupPlatformDir(platformDir);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("re-downloads on version change and clears stale files from the old mount", async () => {
|
|
245
|
+
const v1 = makeSkillProto({
|
|
246
|
+
name: "evolving-skill",
|
|
247
|
+
slug: "evolving-skill",
|
|
248
|
+
skillMd: "# V1",
|
|
249
|
+
artifactStorageKey: "artifacts/v1.zip",
|
|
250
|
+
versionHash: "hash-v1",
|
|
251
|
+
});
|
|
252
|
+
const v2 = makeSkillProto({
|
|
253
|
+
name: "evolving-skill",
|
|
254
|
+
slug: "evolving-skill",
|
|
255
|
+
skillMd: "# V2",
|
|
256
|
+
artifactStorageKey: "artifacts/v2.zip",
|
|
257
|
+
versionHash: "hash-v2",
|
|
258
|
+
});
|
|
259
|
+
const artifactV1 = buildZip([{ name: "references/removed-in-v2.md", content: "old" }]);
|
|
260
|
+
const artifactV2 = buildZip([{ name: "references/new-in-v2.md", content: "new" }]);
|
|
261
|
+
|
|
262
|
+
const getSkillByReference = vi.fn().mockResolvedValueOnce(v1).mockResolvedValueOnce(v2);
|
|
263
|
+
const getSkillArtifact = vi
|
|
264
|
+
.fn()
|
|
265
|
+
.mockResolvedValueOnce({ artifact: artifactV1 })
|
|
266
|
+
.mockResolvedValueOnce({ artifact: artifactV2 });
|
|
267
|
+
|
|
268
|
+
const { platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
269
|
+
[makeRef("evolving-skill")],
|
|
270
|
+
{ getSkillByReference, getSkillArtifact },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const skillDir = join(platformDir, "skills", "evolving-skill");
|
|
275
|
+
expect(readFileSync(join(skillDir, "references", "removed-in-v2.md"), "utf-8")).toBe("old");
|
|
276
|
+
|
|
277
|
+
await resolveAgain();
|
|
278
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
279
|
+
expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# V2");
|
|
280
|
+
expect(readFileSync(join(skillDir, "references", "new-in-v2.md"), "utf-8")).toBe("new");
|
|
281
|
+
// The v1-only file must not linger in the v2 mount (the stale-file leak).
|
|
282
|
+
expect(existsSync(join(skillDir, "references", "removed-in-v2.md"))).toBe(false);
|
|
283
|
+
} finally {
|
|
284
|
+
cleanupPlatformDir(platformDir);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not cache a SKILL.md-only fallback — the next execution retries the download", async () => {
|
|
289
|
+
const proto = makeSkillProto({
|
|
290
|
+
name: "retry-skill",
|
|
291
|
+
slug: "retry-skill",
|
|
292
|
+
skillMd: "# Retry",
|
|
293
|
+
artifactStorageKey: "artifacts/retry.zip",
|
|
294
|
+
versionHash: "hash-v1",
|
|
295
|
+
});
|
|
296
|
+
const artifact = buildZip([{ name: "references/late.md", content: "finally" }]);
|
|
297
|
+
const getSkillByReference = vi.fn().mockResolvedValue(proto);
|
|
298
|
+
const getSkillArtifact = vi
|
|
299
|
+
.fn()
|
|
300
|
+
.mockRejectedValueOnce(new Error("Network timeout"))
|
|
301
|
+
.mockResolvedValueOnce({ artifact });
|
|
302
|
+
|
|
303
|
+
const { result, platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
304
|
+
[makeRef("retry-skill")],
|
|
305
|
+
{ getSkillByReference, getSkillArtifact },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
// First pass degraded to SKILL.md only.
|
|
310
|
+
expect(result).toHaveLength(1);
|
|
311
|
+
const skillDir = join(platformDir, "skills", "retry-skill");
|
|
312
|
+
expect(existsSync(join(skillDir, "references"))).toBe(false);
|
|
313
|
+
|
|
314
|
+
// Second pass retries and completes the mount.
|
|
315
|
+
await resolveAgain();
|
|
316
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
317
|
+
expect(readFileSync(join(skillDir, "references", "late.md"), "utf-8")).toBe("finally");
|
|
318
|
+
|
|
319
|
+
// Third pass is a cache hit.
|
|
320
|
+
await resolveAgain();
|
|
321
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
322
|
+
} finally {
|
|
323
|
+
cleanupPlatformDir(platformDir);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("treats a corrupted mount marker as stale and remounts", async () => {
|
|
328
|
+
const artifact = buildZip([{ name: "references/guide.md", content: "guide" }]);
|
|
329
|
+
const getSkillByReference = vi.fn().mockResolvedValue(
|
|
330
|
+
makeSkillProto({
|
|
331
|
+
name: "tampered-skill",
|
|
332
|
+
slug: "tampered-skill",
|
|
333
|
+
skillMd: "# Tampered",
|
|
334
|
+
artifactStorageKey: "artifacts/tampered.zip",
|
|
335
|
+
versionHash: "hash-v1",
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
const getSkillArtifact = vi.fn().mockResolvedValue({ artifact });
|
|
339
|
+
|
|
340
|
+
const { platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
341
|
+
[makeRef("tampered-skill")],
|
|
342
|
+
{ getSkillByReference, getSkillArtifact },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const skillDir = join(platformDir, "skills", "tampered-skill");
|
|
347
|
+
writeFileSync(join(skillDir, ".stigmer-mount.json"), "not json {");
|
|
348
|
+
|
|
349
|
+
await resolveAgain();
|
|
350
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
351
|
+
expect(readFileSync(join(skillDir, "references", "guide.md"), "utf-8")).toBe("guide");
|
|
352
|
+
} finally {
|
|
353
|
+
cleanupPlatformDir(platformDir);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
192
357
|
it("SKILL.md from spec takes precedence over ZIP copy", async () => {
|
|
193
358
|
const specContent = "# Authoritative SKILL.md from spec";
|
|
194
359
|
const zipContent = "# Stale SKILL.md from ZIP";
|
|
@@ -224,3 +389,97 @@ describe("resolveSkills — artifact extraction", () => {
|
|
|
224
389
|
}
|
|
225
390
|
});
|
|
226
391
|
});
|
|
392
|
+
|
|
393
|
+
// ─── downloadArtifact — transfer lane routing (#675) ─────────────────────
|
|
394
|
+
|
|
395
|
+
describe("downloadArtifact — transfer lane routing", () => {
|
|
396
|
+
afterEach(() => {
|
|
397
|
+
vi.unstubAllGlobals();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
function makeClient(overrides: Record<string, any> = {}) {
|
|
401
|
+
return {
|
|
402
|
+
getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
|
|
403
|
+
getSkillArtifactDownloadUrl: unimplementedMint(),
|
|
404
|
+
...overrides,
|
|
405
|
+
} as any;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
it("fetches bytes over HTTP when the server mints a download URL", async () => {
|
|
409
|
+
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
410
|
+
const client = makeClient({
|
|
411
|
+
getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
|
|
412
|
+
url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
|
|
413
|
+
sizeBytes: 5n,
|
|
414
|
+
ttlSeconds: 0,
|
|
415
|
+
}),
|
|
416
|
+
});
|
|
417
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
418
|
+
ok: true,
|
|
419
|
+
arrayBuffer: async () => bytes.buffer,
|
|
420
|
+
});
|
|
421
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
422
|
+
|
|
423
|
+
const got = await downloadArtifact(client, "skills/abc.zip");
|
|
424
|
+
|
|
425
|
+
expect(got).toEqual(bytes);
|
|
426
|
+
expect(fetchMock).toHaveBeenCalledWith("http://localhost:7234/v1/skill-artifacts/skills/abc.zip");
|
|
427
|
+
// The unary lane (10MB-capped) must not be touched when the URL lane works.
|
|
428
|
+
expect(client.getSkillArtifact).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("falls back to the unary RPC when the server predates the lane", async () => {
|
|
432
|
+
const bytes = new Uint8Array([9, 9]);
|
|
433
|
+
const client = makeClient({
|
|
434
|
+
getSkillArtifact: vi.fn().mockResolvedValue({ artifact: bytes }),
|
|
435
|
+
});
|
|
436
|
+
vi.stubGlobal("fetch", vi.fn()); // must never be called
|
|
437
|
+
|
|
438
|
+
const got = await downloadArtifact(client, "skills/abc.zip");
|
|
439
|
+
|
|
440
|
+
expect(got).toEqual(bytes);
|
|
441
|
+
expect(client.getSkillArtifact).toHaveBeenCalledWith("skills/abc.zip");
|
|
442
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("does NOT fall back on non-Unimplemented mint failures", async () => {
|
|
446
|
+
const client = makeClient({
|
|
447
|
+
getSkillArtifactDownloadUrl: vi.fn().mockRejectedValue(
|
|
448
|
+
new ConnectError("boom", Code.Internal),
|
|
449
|
+
),
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow("boom");
|
|
453
|
+
// Falling back here would mask real server faults behind the capped lane.
|
|
454
|
+
expect(client.getSkillArtifact).not.toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("rejects truncated fetches via the minted size", async () => {
|
|
458
|
+
const client = makeClient({
|
|
459
|
+
getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
|
|
460
|
+
url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
|
|
461
|
+
sizeBytes: 100n,
|
|
462
|
+
ttlSeconds: 0,
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
466
|
+
ok: true,
|
|
467
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
468
|
+
}));
|
|
469
|
+
|
|
470
|
+
await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow(/truncated/);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("surfaces HTTP failures with the status code", async () => {
|
|
474
|
+
const client = makeClient({
|
|
475
|
+
getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
|
|
476
|
+
url: "http://localhost:7234/v1/skill-artifacts/skills/gone.zip",
|
|
477
|
+
sizeBytes: 0n,
|
|
478
|
+
ttlSeconds: 0,
|
|
479
|
+
}),
|
|
480
|
+
});
|
|
481
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
482
|
+
|
|
483
|
+
await expect(downloadArtifact(client, "skills/gone.zip")).rejects.toThrow(/HTTP 404/);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
@@ -112,10 +112,11 @@ export function captureBaselineToLedger(opts: {
|
|
|
112
112
|
* bytes withheld from durable storage.
|
|
113
113
|
*
|
|
114
114
|
* `deniedTokens` are the identities the hook gated this turn (shell/MCP, or a
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
115
|
+
* secret-like delete — non-secret CAS deletes flow since issue #303). A streamed
|
|
116
|
+
* file-edit row whose identity is in that set is left for the deny-gate
|
|
117
|
+
* reconcile path — it did NOT flow. A flowed gitignored write or delete is NOT
|
|
118
|
+
* in that set (the hook allowed it), so it is stamped like any other flowed
|
|
119
|
+
* edit and its captured delta surfaces as a CAS entry in the set.
|
|
119
120
|
*
|
|
120
121
|
* `hitlDir`/`storage` are omitted only by callers with no artifact storage
|
|
121
122
|
* (captureIgnored off); the CAS half is then skipped and this is a git-only
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Cursor harness's disk-backed observer for
|
|
2
|
+
* The Cursor harness's disk-backed observer for CAS-owned file mutations —
|
|
3
|
+
* gitignored / non-git writes, and (issue #303) pre-delete byte capture — the
|
|
3
4
|
* out-of-process analog of the deep-agent `CasCaptureFilesystemBackend`
|
|
4
5
|
* ({@link ../execute-deep-agent/cas-capture-backend.js}).
|
|
5
6
|
*
|
|
@@ -185,14 +186,19 @@ export function buildSecretClassifyScript(): string {
|
|
|
185
186
|
|
|
186
187
|
/**
|
|
187
188
|
* Build the standalone Node.js script the hook runs to observe a single
|
|
188
|
-
*
|
|
189
|
-
* `recordBefore` plus the DD-E secret gate.
|
|
189
|
+
* CAS-owned mutation — the disk-backed mirror of `CasCaptureFilesystemBackend`'s
|
|
190
|
+
* `recordBefore` plus the DD-E secret gate. Staging is mutation-agnostic: it
|
|
191
|
+
* records the PRE-mutation bytes, so the hook runs it for a write/edit and —
|
|
192
|
+
* issue #303 — for a delete (whose before-bytes exist only until the tool runs;
|
|
193
|
+
* the boundary later reads after=null and authors the DELETE). The hook's
|
|
194
|
+
* delete arm pre-classifies secrets and never invokes this for a secret-like
|
|
195
|
+
* delete (a gated delete must not leave a "secret" marker in the sidecar).
|
|
190
196
|
*
|
|
191
197
|
* Invoked as `printf %s "$SALIENT" | node -e '<this>' <workspaceRoot> <obsDir>`
|
|
192
198
|
* — the (arbitrary) salient path arrives on stdin so no argv escaping is needed;
|
|
193
199
|
* the two absolute paths ride argv. Prints one token on stdout for the hook:
|
|
194
200
|
* - `captured` — non-secret: staged the pre-turn bytes (first-touch-wins), the
|
|
195
|
-
* hook then ALLOWS the
|
|
201
|
+
* hook then ALLOWS the mutation to flow;
|
|
196
202
|
* - `secret` — secret-like: recorded a content-less marker, the hook then
|
|
197
203
|
* DENIES with the security message (nothing is written);
|
|
198
204
|
* - `error` — the path escaped the workspace or a filesystem error occurred,
|
|
@@ -574,9 +574,8 @@ if echo "$STATE" | grep -q '"captureIgnored":true'; then
|
|
|
574
574
|
fi
|
|
575
575
|
# gitWorkspace selects the capture substrate (Slice 2c). Default true when the
|
|
576
576
|
# key is absent (older state files). When false the workspace is NOT a git tree:
|
|
577
|
-
# there is no git snapshot, so EVERY file write is CAS-staged below (not
|
|
578
|
-
# gitignored ones)
|
|
579
|
-
# (no CAS delete-capture path, parity with the deep-agent).
|
|
577
|
+
# there is no git snapshot, so EVERY file write/delete is CAS-staged below (not
|
|
578
|
+
# only gitignored ones) and the git-tracked flow arm is skipped.
|
|
580
579
|
GIT_WORKSPACE=true
|
|
581
580
|
if echo "$STATE" | grep -q '"gitWorkspace":false'; then
|
|
582
581
|
GIT_WORKSPACE=false
|
|
@@ -597,9 +596,10 @@ fi
|
|
|
597
596
|
# secret-like one must be hard-blocked in every mode. WHICH writes are CAS-owned
|
|
598
597
|
# depends on the substrate: in a git tree it is only the GITIGNORED writes (git
|
|
599
598
|
# captures the tracked ones); in a NON-GIT workspace it is EVERY write (there is
|
|
600
|
-
# no git snapshot). Only a built-in write/edit (category "write") takes
|
|
601
|
-
# deletes
|
|
602
|
-
# approval gate). The staging runs on
|
|
599
|
+
# no git snapshot). Only a built-in write/edit (category "write") takes THIS arm;
|
|
600
|
+
# deletes take their own staging arm just below (issue #303), shell/MCP stay on
|
|
601
|
+
# the deny-gate (parity with the deep-agent approval gate). The staging runs on
|
|
602
|
+
# the runner's own Node binary (the
|
|
603
603
|
# disk-backed mirror of CasCaptureFilesystemBackend.recordBefore): the salient
|
|
604
604
|
# path rides stdin (no argv escaping), the workspace root and the per-turn
|
|
605
605
|
# cas-observations dir ride argv. "captured" -> allow (apply-then-review);
|
|
@@ -629,6 +629,32 @@ if [ "$CAPTURE_IGNORED" = "true" ] && [ "$CATEGORY" = "write" ] && [ -n "$SALIEN
|
|
|
629
629
|
fi
|
|
630
630
|
fi
|
|
631
631
|
|
|
632
|
+
# --- Capture mode: observe CAS-owned deletes for review (issue #303) --------
|
|
633
|
+
# The delete twin of the write arm above — a non-secret CAS-owned delete stages
|
|
634
|
+
# its before-bytes (the one moment they still exist on disk) and flows; the
|
|
635
|
+
# turn boundary then reads after=null and authors a reviewable, restorable
|
|
636
|
+
# DELETE entry. One deliberate difference from the write arm: the path is
|
|
637
|
+
# classified for secret-likeness BEFORE any staging touches the sidecar. A
|
|
638
|
+
# secret-like delete must stay on the deny-gate below where a human may still
|
|
639
|
+
# approve it (its args expose no secret content, unlike a write) — staging it
|
|
640
|
+
# would both persist the secret before-bytes and mark the sidecar "secret",
|
|
641
|
+
# making the boundary author a blocking DIFF_UNREVIEWABLE for a merely-gated
|
|
642
|
+
# delete. Every failure mode (secret, classify error, staging error) falls
|
|
643
|
+
# through to the deny-gate rather than denying here, so grants and leases keep
|
|
644
|
+
# applying to a gated delete exactly as before this arm existed; the eventual
|
|
645
|
+
# resolution records its own ledger kind.
|
|
646
|
+
if [ "$CAPTURE_IGNORED" = "true" ] && [ "$CATEGORY" = "delete" ] && [ -n "$SALIENT" ] && { [ "$GIT_WORKSPACE" = "false" ] || __stigmer_is_gitignored "$SALIENT"; }; then
|
|
647
|
+
DELETE_SECRET_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${secretClassifyScript}' 2>/dev/null || echo error)
|
|
648
|
+
if [ "$DELETE_SECRET_RESULT" = "ok" ]; then
|
|
649
|
+
OBS_DIR="$(dirname "$STATE_FILE")/${CAS_OBSERVATIONS_DIRNAME}"
|
|
650
|
+
OBS_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${observationStagingScript}' "$GIT_ROOT" "$OBS_DIR" 2>/dev/null || echo error)
|
|
651
|
+
if [ "$OBS_RESULT" = "captured" ]; then
|
|
652
|
+
echo '{"permission":"allow"}'
|
|
653
|
+
exit 0
|
|
654
|
+
fi
|
|
655
|
+
fi
|
|
656
|
+
fi
|
|
657
|
+
|
|
632
658
|
# --- 1a. MCP capability manifest: enabled_tools (issue #350) ---
|
|
633
659
|
# Runs BEFORE the auto-approve-all shortcut and every grant check because
|
|
634
660
|
# enabled_tools is a capability manifest, not an approval gate: no bypass may
|
|
@@ -700,13 +726,13 @@ if [ -n "$CATEGORY" ]; then
|
|
|
700
726
|
# Capture mode (GIT tree only): a git-tracked file mutation (write/edit/delete)
|
|
701
727
|
# flows freely — the runner captures the whole change set with git at the turn
|
|
702
728
|
# boundary and gates it per-file for review. A gitignored path is invisible to
|
|
703
|
-
# that git snapshot: a non-secret gitignored WRITE was already
|
|
704
|
-
# (staged + allowed,
|
|
705
|
-
# reaches the deny-gate when captureIgnored
|
|
706
|
-
#
|
|
707
|
-
# (category "shell") never takes this branch
|
|
708
|
-
# NON-GIT workspace this arm is skipped
|
|
709
|
-
#
|
|
729
|
+
# that git snapshot: a non-secret gitignored WRITE or DELETE was already
|
|
730
|
+
# handled above (staged + allowed, hard-blocked, or left to fall through) when
|
|
731
|
+
# captureIgnored is on; here it only reaches the deny-gate when captureIgnored
|
|
732
|
+
# is off (no artifact storage), or it is a secret-like DELETE (approvable, but
|
|
733
|
+
# never staged — issue #303). shell (category "shell") never takes this branch
|
|
734
|
+
# and stays gated as always. In a NON-GIT workspace this arm is skipped
|
|
735
|
+
# entirely (there is no git diff): writes and deletes were CAS-staged above.
|
|
710
736
|
if [ "$CAPTURE_MODE" = "true" ] && [ "$GIT_WORKSPACE" = "true" ] && { [ "$CATEGORY" = "write" ] || [ "$CATEGORY" = "delete" ]; }; then
|
|
711
737
|
if ! __stigmer_is_gitignored "$SALIENT"; then
|
|
712
738
|
echo '{"permission":"allow"}'
|