@stigmer/runner 3.12.4 → 3.12.5
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/cursor-mcp-config.d.ts +45 -0
- package/dist/activities/execute-cursor/cursor-mcp-config.js +76 -0
- package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -0
- package/dist/activities/execute-cursor/index.d.ts +10 -1
- package/dist/activities/execute-cursor/index.js +47 -29
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +13 -2
- package/dist/activities/execute-cursor/prompt-builder.js +11 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +2 -21
- package/dist/activities/execute-cursor/skill-resolver.js +5 -108
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/approval-file-change.d.ts +9 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js +2 -1
- package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +18 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +20 -3
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +57 -5
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +9 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +10 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +17 -16
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +17 -11
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/middleware/path-normalization.d.ts +49 -49
- package/dist/middleware/path-normalization.js +73 -84
- package/dist/middleware/path-normalization.js.map +1 -1
- package/dist/middleware/types.d.ts +3 -3
- package/dist/runner-manager.d.ts +1 -1
- package/dist/runner-manager.js +22 -15
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +11 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/args-preview.d.ts +8 -0
- package/dist/shared/args-preview.js +14 -3
- package/dist/shared/args-preview.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +11 -1
- package/dist/shared/artifact-storage.js +10 -1
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/declared-preferences.d.ts +47 -0
- package/dist/shared/declared-preferences.js +64 -0
- package/dist/shared/declared-preferences.js.map +1 -0
- package/dist/shared/mcp-enabled-tools.d.ts +2 -3
- package/dist/shared/mcp-enabled-tools.js +2 -3
- package/dist/shared/mcp-enabled-tools.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +20 -15
- package/dist/shared/mcp-resolver.js +11 -12
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/placeholder-resolver.d.ts +9 -2
- package/dist/shared/placeholder-resolver.js +9 -2
- package/dist/shared/placeholder-resolver.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +26 -46
- package/dist/shared/plan-mode-permissions.js +27 -55
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/skill-mount.d.ts +89 -0
- package/dist/shared/skill-mount.js +142 -0
- package/dist/shared/skill-mount.js.map +1 -0
- package/dist/shared/skill-writer.d.ts +28 -26
- package/dist/shared/skill-writer.js +79 -102
- package/dist/shared/skill-writer.js.map +1 -1
- package/dist/shared/worker-shutdown.d.ts +67 -0
- package/dist/shared/worker-shutdown.js +79 -0
- package/dist/shared/worker-shutdown.js.map +1 -0
- package/dist/shared/workspace/types.d.ts +3 -2
- package/dist/workflow-engine/loader.js +38 -1
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.js +23 -2
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/package.json +15 -11
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +50 -0
- package/src/activities/execute-cursor/__tests__/cursor-mcp-config.test.ts +111 -0
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -94
- package/src/activities/execute-cursor/cursor-mcp-config.ts +107 -0
- package/src/activities/execute-cursor/index.ts +63 -35
- package/src/activities/execute-cursor/prompt-builder.ts +29 -2
- package/src/activities/execute-cursor/session-lifecycle.ts +1 -1
- package/src/activities/execute-cursor/skill-resolver.ts +10 -137
- package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +15 -0
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +75 -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__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +42 -29
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +52 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +11 -8
- package/src/activities/execute-deep-agent/approval-file-change.ts +11 -1
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +20 -3
- package/src/activities/execute-deep-agent/index.ts +57 -5
- package/src/activities/execute-deep-agent/prompt-builder.ts +22 -0
- package/src/activities/execute-deep-agent/setup.ts +21 -19
- package/src/activities/execute-deep-agent/subagent-transformer.ts +19 -12
- package/src/activities/execute-deep-agent/subagent-wiring.ts +3 -3
- package/src/index.ts +1 -1
- package/src/middleware/__tests__/path-normalization.test.ts +32 -32
- package/src/middleware/path-normalization.ts +78 -90
- package/src/middleware/types.ts +3 -3
- package/src/runner-manager.ts +26 -16
- package/src/runner.ts +14 -0
- package/src/shared/__tests__/artifact-storage.test.ts +28 -0
- package/src/shared/__tests__/bedrock-seam.test.ts +5 -5
- package/src/shared/__tests__/declared-preferences.test.ts +109 -0
- package/src/shared/__tests__/foundry-seam.test.ts +5 -5
- package/src/shared/__tests__/plan-mode-permissions.test.ts +15 -44
- package/src/shared/__tests__/skill-mount.test.ts +238 -0
- package/src/shared/__tests__/skill-writer.test.ts +151 -128
- package/src/shared/__tests__/vertex-adapter.test.ts +30 -3
- package/src/shared/__tests__/vertex-seam.test.ts +5 -5
- package/src/shared/__tests__/worker-shutdown.test.ts +101 -0
- package/src/shared/args-preview.ts +17 -3
- package/src/shared/artifact-storage.ts +20 -1
- package/src/shared/declared-preferences.ts +84 -0
- package/src/shared/mcp-enabled-tools.ts +2 -3
- package/src/shared/mcp-resolver.ts +20 -20
- package/src/shared/placeholder-resolver.ts +9 -2
- package/src/shared/plan-mode-permissions.ts +27 -58
- package/src/shared/skill-mount.ts +179 -0
- package/src/shared/skill-writer.ts +96 -130
- package/src/shared/worker-shutdown.ts +99 -0
- package/src/shared/workspace/types.ts +3 -2
- package/src/workflow-engine/__tests__/loader.test.ts +51 -0
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +117 -0
- package/src/workflow-engine/loader.ts +46 -1
- package/src/workflow-engine/tasks/human-input.ts +33 -5
- package/dist/activities/execute-cursor/connect-backfill.d.ts +0 -19
- package/dist/activities/execute-cursor/connect-backfill.js +0 -27
- package/dist/activities/execute-cursor/connect-backfill.js.map +0 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +0 -120
- package/dist/activities/execute-cursor/mcp-resolver.js +0 -194
- package/dist/activities/execute-cursor/mcp-resolver.js.map +0 -1
- package/dist/activities/execute-cursor/placeholder-resolver.d.ts +0 -34
- package/dist/activities/execute-cursor/placeholder-resolver.js +0 -82
- package/dist/activities/execute-cursor/placeholder-resolver.js.map +0 -1
- package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +0 -125
- package/src/activities/execute-cursor/connect-backfill.ts +0 -52
- package/src/activities/execute-cursor/mcp-resolver.ts +0 -324
- package/src/activities/execute-cursor/placeholder-resolver.ts +0 -109
package/src/runner.ts
CHANGED
|
@@ -324,6 +324,16 @@ export async function createStigmerRunner(
|
|
|
324
324
|
// unaffected — their env-injected platform key wins inside the loader.
|
|
325
325
|
const payloadCodecs = await createPayloadCodecs(config, coordinates.payloadEncryption);
|
|
326
326
|
|
|
327
|
+
// Register the queue's shutdown signal BEFORE the worker can poll, so an
|
|
328
|
+
// activity interrupted by shutdown() below classifies the cancellation as a
|
|
329
|
+
// worker shutdown, not a user pause (issue #776). Static mode historically
|
|
330
|
+
// never registered one, leaving CLI daemons with the same misclassification
|
|
331
|
+
// the desktop manager had.
|
|
332
|
+
const { registerWorkerShutdownSignal } = await import(
|
|
333
|
+
"./shared/worker-shutdown.js"
|
|
334
|
+
);
|
|
335
|
+
const shutdownController = registerWorkerShutdownSignal(config.taskQueue);
|
|
336
|
+
|
|
327
337
|
const { startWorker } = await import("./worker.js");
|
|
328
338
|
const worker = await startWorker({ config, activities, payloadCodecs });
|
|
329
339
|
markBoot("worker_created");
|
|
@@ -349,6 +359,10 @@ export async function createStigmerRunner(
|
|
|
349
359
|
},
|
|
350
360
|
shutdown() {
|
|
351
361
|
tokenRenewal?.stop();
|
|
362
|
+
// Classification first, drain second: an in-flight activity cancelled
|
|
363
|
+
// by the drain must observe the signal already aborted (see
|
|
364
|
+
// shared/worker-shutdown.ts for the ownership contract).
|
|
365
|
+
shutdownController.abort();
|
|
352
366
|
worker.shutdown();
|
|
353
367
|
},
|
|
354
368
|
};
|
|
@@ -618,6 +618,22 @@ describe("createArtifactStorage", () => {
|
|
|
618
618
|
};
|
|
619
619
|
expect(() => createArtifactStorage(cfg)).toThrow("STIGMER_TOKEN");
|
|
620
620
|
});
|
|
621
|
+
|
|
622
|
+
it("throws for the deliberate 'none' posture — resolveUsable degrades it, claimcheck fail-hards", async () => {
|
|
623
|
+
const cfg: ArtifactStorageConfig = {
|
|
624
|
+
type: "none",
|
|
625
|
+
localPath: "/tmp/artifacts",
|
|
626
|
+
localServeUrl: "http://localhost:7235",
|
|
627
|
+
proxyEndpoint: null,
|
|
628
|
+
proxyAuthToken: null,
|
|
629
|
+
};
|
|
630
|
+
expect(() => createArtifactStorage(cfg)).toThrow(/deliberately disabled/i);
|
|
631
|
+
// The capture/offload resolver treats it as the first-class absent store:
|
|
632
|
+
// deny-gate mode, not a crash.
|
|
633
|
+
await expect(
|
|
634
|
+
resolveUsableArtifactStorage(cfg, { executionId: "exec-none" }),
|
|
635
|
+
).resolves.toBeUndefined();
|
|
636
|
+
});
|
|
621
637
|
});
|
|
622
638
|
|
|
623
639
|
describe("loadArtifactStorageConfig", () => {
|
|
@@ -680,6 +696,18 @@ describe("loadArtifactStorageConfig", () => {
|
|
|
680
696
|
expect(cfg.proxyAuthToken).toBe("tok");
|
|
681
697
|
});
|
|
682
698
|
|
|
699
|
+
it("honors ARTIFACT_STORAGE_TYPE=none even when a proxy endpoint is configured", () => {
|
|
700
|
+
// "none" is a deliberate operator/e2e posture — it must beat the
|
|
701
|
+
// storage-follows-transport default exactly like the other overrides.
|
|
702
|
+
process.env.ARTIFACT_STORAGE_TYPE = "none";
|
|
703
|
+
const cfg = loadArtifactStorageConfig({
|
|
704
|
+
...baseConfig,
|
|
705
|
+
proxyEndpoint: "https://proxy.example.com",
|
|
706
|
+
stigmerToken: "tok",
|
|
707
|
+
});
|
|
708
|
+
expect(cfg.type).toBe("none");
|
|
709
|
+
});
|
|
710
|
+
|
|
683
711
|
it("respects ARTIFACT_STORAGE_TYPE override", () => {
|
|
684
712
|
process.env.ARTIFACT_STORAGE_TYPE = "proxy";
|
|
685
713
|
const cfg = loadArtifactStorageConfig({
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Characterization test for the ChatAnthropic `createClient` ->
|
|
3
3
|
* AnthropicBedrock seam — the integration the T04 bedrock backend adapter
|
|
4
4
|
* is built on. Sibling of vertex-seam.test.ts; same rules: this pins REAL
|
|
5
|
-
* cross-package behavior (`@langchain/anthropic`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* of in production. See scripts/check-langchain-deps.sh for
|
|
9
|
-
*
|
|
5
|
+
* cross-package behavior (`@langchain/anthropic` driving
|
|
6
|
+
* `@anthropic-ai/bedrock-sdk`, both resolving the single override-pinned
|
|
7
|
+
* @anthropic-ai/sdk copy), so a future bump of either side fails here in
|
|
8
|
+
* CI instead of in production. See scripts/check-langchain-deps.sh for
|
|
9
|
+
* the override rationale and the single-copy invariant it guards.
|
|
10
10
|
*
|
|
11
11
|
* Determinism: zero live credentials, zero network. SigV4 signing runs for
|
|
12
12
|
* real — it is pure HMAC over static test keys passed through the SDK's
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the declared-preferences module (stigmer/stigmer#293,
|
|
3
|
+
* DD-002). Like conversation-catchup there is no string key to mirror-guard —
|
|
4
|
+
* the value rides the typed `AgentExecutionSpec.declared_preferences` proto
|
|
5
|
+
* field, so codegen enforces the cross-repo contract. What IS pinned here:
|
|
6
|
+
* the per-scope blank-is-absent read semantics (the server stamps the field
|
|
7
|
+
* on EVERY eligible create, usually with blank scopes), the per-scope
|
|
8
|
+
* attribution, and the framing's behavioral contract.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { create } from "@bufbuild/protobuf";
|
|
13
|
+
import { DeclaredPreferencesSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
formatDeclaredPreferencesText,
|
|
17
|
+
readDeclaredPreferences,
|
|
18
|
+
} from "../declared-preferences.js";
|
|
19
|
+
|
|
20
|
+
const ORG_CONTEXT = "We deploy to us-east-1. All costs in USD.";
|
|
21
|
+
const USER_CONTEXT = "Keep answers terse. Prefer Go examples.";
|
|
22
|
+
|
|
23
|
+
describe("readDeclaredPreferences", () => {
|
|
24
|
+
it("reads both scopes when both are set", () => {
|
|
25
|
+
const preferences = create(DeclaredPreferencesSchema, {
|
|
26
|
+
orgContext: ORG_CONTEXT,
|
|
27
|
+
userContext: USER_CONTEXT,
|
|
28
|
+
});
|
|
29
|
+
expect(readDeclaredPreferences(preferences)).toEqual({
|
|
30
|
+
orgContext: ORG_CONTEXT,
|
|
31
|
+
userContext: USER_CONTEXT,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("answers undefined when the field is absent (pre-#293 executions)", () => {
|
|
36
|
+
expect(readDeclaredPreferences(undefined)).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("answers undefined when both scopes are blank — the server stamps the field on every eligible create", () => {
|
|
40
|
+
const preferences = create(DeclaredPreferencesSchema, {
|
|
41
|
+
orgContext: " ",
|
|
42
|
+
userContext: "",
|
|
43
|
+
});
|
|
44
|
+
expect(readDeclaredPreferences(preferences)).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("blank-is-absent applies per scope: an org-only snapshot carries no user scope", () => {
|
|
48
|
+
const preferences = create(DeclaredPreferencesSchema, {
|
|
49
|
+
orgContext: ORG_CONTEXT,
|
|
50
|
+
userContext: " ",
|
|
51
|
+
});
|
|
52
|
+
expect(readDeclaredPreferences(preferences)).toEqual({
|
|
53
|
+
orgContext: ORG_CONTEXT,
|
|
54
|
+
userContext: undefined,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("trims surrounding whitespace from each scope", () => {
|
|
59
|
+
const preferences = create(DeclaredPreferencesSchema, {
|
|
60
|
+
orgContext: " org facts ",
|
|
61
|
+
userContext: "\nuser facts\n",
|
|
62
|
+
});
|
|
63
|
+
expect(readDeclaredPreferences(preferences)).toEqual({
|
|
64
|
+
orgContext: "org facts",
|
|
65
|
+
userContext: "user facts",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("formatDeclaredPreferencesText", () => {
|
|
71
|
+
it("frames the preferences as already-known, non-announced background", () => {
|
|
72
|
+
const framed = formatDeclaredPreferencesText({
|
|
73
|
+
orgContext: ORG_CONTEXT,
|
|
74
|
+
userContext: USER_CONTEXT,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(framed).toContain("Standing preferences declared by");
|
|
78
|
+
expect(framed).toContain("Do not repeat them back");
|
|
79
|
+
expect(framed).toContain("not instructions that override your task");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("attributes each scope explicitly, organization first, user last", () => {
|
|
83
|
+
const framed = formatDeclaredPreferencesText({
|
|
84
|
+
orgContext: ORG_CONTEXT,
|
|
85
|
+
userContext: USER_CONTEXT,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const org = framed.indexOf("Declared by the organization:");
|
|
89
|
+
const user = framed.indexOf("Declared by the user:");
|
|
90
|
+
expect(org).toBeGreaterThan(-1);
|
|
91
|
+
expect(user).toBeGreaterThan(org);
|
|
92
|
+
expect(framed).toContain(ORG_CONTEXT);
|
|
93
|
+
expect(framed.endsWith(USER_CONTEXT)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders only the org subsection for an org-only snapshot", () => {
|
|
97
|
+
const framed = formatDeclaredPreferencesText({ orgContext: ORG_CONTEXT });
|
|
98
|
+
|
|
99
|
+
expect(framed).toContain("Declared by the organization:");
|
|
100
|
+
expect(framed).not.toContain("Declared by the user:");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders only the user subsection for a user-only snapshot", () => {
|
|
104
|
+
const framed = formatDeclaredPreferencesText({ userContext: USER_CONTEXT });
|
|
105
|
+
|
|
106
|
+
expect(framed).not.toContain("Declared by the organization:");
|
|
107
|
+
expect(framed).toContain("Declared by the user:");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* AnthropicFoundry seam — the integration the T05 foundry backend adapter
|
|
4
4
|
* is built on. Sibling of vertex-seam.test.ts and bedrock-seam.test.ts;
|
|
5
5
|
* same rules: this pins REAL cross-package behavior (`@langchain/anthropic`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* fails here in CI instead of in production. See
|
|
9
|
-
* scripts/check-langchain-deps.sh for
|
|
10
|
-
*
|
|
6
|
+
* driving `@anthropic-ai/foundry-sdk`, both resolving the single
|
|
7
|
+
* override-pinned @anthropic-ai/sdk copy), so a future bump of either
|
|
8
|
+
* side fails here in CI instead of in production. See
|
|
9
|
+
* scripts/check-langchain-deps.sh for the override rationale and the
|
|
10
|
+
* single-copy invariant it guards.
|
|
11
11
|
*
|
|
12
12
|
* Determinism: zero live credentials, zero network. API-key auth is a
|
|
13
13
|
* static test key; Entra ID auth is a fake `azureADTokenProvider` (the
|
|
@@ -1,58 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit pins for the plan-mode rule builder
|
|
2
|
+
* Unit pins for the plan-mode rule builder.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Since issue #754 the rules carry ONLY plan mode's write-deny: the read
|
|
5
|
+
* boundary is structural (virtual-rooted backends confine every path to the
|
|
6
|
+
* workspace), so there is no workspace-root glob, no escaping, and no rule
|
|
7
|
+
* ordering left to protect. The single rule's SHAPE is still the security
|
|
8
|
+
* boundary for writes — "/**" must stay the match-all virtual pattern and
|
|
9
|
+
* the operation class must stay `write` (deepagents evaluates
|
|
10
|
+
* first-match-wins with a permissive default, so reads flow by omission).
|
|
11
|
+
* End-to-end behavior is pinned against the real deepagents runtime in
|
|
10
12
|
* execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { describe, it, expect } from "vitest";
|
|
14
|
-
import {
|
|
15
|
-
buildPlanModePermissions,
|
|
16
|
-
escapeGlobLiteral,
|
|
17
|
-
} from "../plan-mode-permissions.js";
|
|
16
|
+
import { buildPlanModePermissions } from "../plan-mode-permissions.js";
|
|
18
17
|
|
|
19
18
|
describe("buildPlanModePermissions", () => {
|
|
20
|
-
it("builds the policy in
|
|
21
|
-
expect(buildPlanModePermissions(
|
|
22
|
-
{ operations: ["read"], paths: ["/ws/session-1/repo/**"] },
|
|
23
|
-
{ operations: ["read"], paths: ["/**"], mode: "deny" },
|
|
19
|
+
it("builds exactly the write-deny policy in the virtual dialect", () => {
|
|
20
|
+
expect(buildPlanModePermissions()).toEqual([
|
|
24
21
|
{ operations: ["write"], paths: ["/**"], mode: "deny" },
|
|
25
22
|
]);
|
|
26
23
|
});
|
|
27
24
|
|
|
28
|
-
it("
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// trailing-slash root would silently match nothing.
|
|
32
|
-
const [allow] = buildPlanModePermissions("/ws//session-1/repo/");
|
|
33
|
-
expect(allow.paths).toEqual(["/ws/session-1/repo/**"]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("glob-escapes the root so special characters match literally", () => {
|
|
37
|
-
const [allow] = buildPlanModePermissions("/Users/x/My (work) [v2]");
|
|
38
|
-
expect(allow.paths).toEqual(["/Users/x/My \\(work\\) \\[v2\\]/**"]);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("escapeGlobLiteral", () => {
|
|
43
|
-
it("escapes every micromatch metacharacter", () => {
|
|
44
|
-
expect(escapeGlobLiteral("a*b?c(d)e[f]g{h}i!j+k@l")).toBe(
|
|
45
|
-
"a\\*b\\?c\\(d\\)e\\[f\\]g\\{h\\}i\\!j\\+k\\@l",
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("escapes backslashes themselves", () => {
|
|
50
|
-
expect(escapeGlobLiteral("a\\b")).toBe("a\\\\b");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("leaves plain path characters alone", () => {
|
|
54
|
-
expect(escapeGlobLiteral("/ws/session-1/repo.dir")).toBe(
|
|
55
|
-
"/ws/session-1/repo.dir",
|
|
56
|
-
);
|
|
25
|
+
it("carries no read rules — the read boundary is structural, not policy", () => {
|
|
26
|
+
const operations = buildPlanModePermissions().flatMap((rule) => rule.operations);
|
|
27
|
+
expect(operations).not.toContain("read");
|
|
57
28
|
});
|
|
58
29
|
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { ConnectError, Code } from "@connectrpc/connect";
|
|
6
|
+
import {
|
|
7
|
+
MOUNT_MARKER_FILE,
|
|
8
|
+
mountIsFresh,
|
|
9
|
+
downloadArtifact,
|
|
10
|
+
writeSkillMount,
|
|
11
|
+
} from "../skill-mount.js";
|
|
12
|
+
import { buildZip } from "../../__test-utils__/zip-fixtures.js";
|
|
13
|
+
|
|
14
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function makeSkillProto(overrides: {
|
|
17
|
+
name?: string;
|
|
18
|
+
skillMd?: string;
|
|
19
|
+
artifactStorageKey?: string;
|
|
20
|
+
versionHash?: string;
|
|
21
|
+
} = {}) {
|
|
22
|
+
return {
|
|
23
|
+
metadata: {
|
|
24
|
+
id: `skill-${overrides.name ?? "test"}`,
|
|
25
|
+
slug: overrides.name ?? "test-skill",
|
|
26
|
+
org: "test-org",
|
|
27
|
+
},
|
|
28
|
+
spec: {
|
|
29
|
+
name: overrides.name ?? "test-skill",
|
|
30
|
+
description: "A test skill",
|
|
31
|
+
skillMd: overrides.skillMd ?? "# Test Skill",
|
|
32
|
+
},
|
|
33
|
+
status: {
|
|
34
|
+
artifactStorageKey: overrides.artifactStorageKey ?? "",
|
|
35
|
+
versionHash: overrides.versionHash ?? "abc",
|
|
36
|
+
},
|
|
37
|
+
} as any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── writeSkillMount + mountIsFresh — marker mechanics ───────────────────
|
|
41
|
+
|
|
42
|
+
describe("writeSkillMount — marker mechanics", () => {
|
|
43
|
+
let skillDir: string;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
skillDir = join(mkdtempSync(join(tmpdir(), "skill-mount-")), "test-skill");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
rmSync(join(skillDir, ".."), { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stamps a marker recording the hash and a fully mounted artifact", async () => {
|
|
54
|
+
const artifact = buildZip([{ name: "references/guide.md", content: "guide" }]);
|
|
55
|
+
await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, artifact);
|
|
56
|
+
|
|
57
|
+
const marker = JSON.parse(readFileSync(join(skillDir, MOUNT_MARKER_FILE), "utf-8"));
|
|
58
|
+
expect(marker).toEqual({ versionHash: "hash-v1", artifactMounted: true });
|
|
59
|
+
expect(await mountIsFresh(skillDir, "hash-v1", true)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("records a degraded SKILL.md-only mount so the next execution retries the artifact", async () => {
|
|
63
|
+
await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, undefined);
|
|
64
|
+
|
|
65
|
+
const marker = JSON.parse(readFileSync(join(skillDir, MOUNT_MARKER_FILE), "utf-8"));
|
|
66
|
+
expect(marker).toEqual({ versionHash: "hash-v1", artifactMounted: false });
|
|
67
|
+
// Stale for a skill that carries an artifact (retry the download)...
|
|
68
|
+
expect(await mountIsFresh(skillDir, "hash-v1", true)).toBe(false);
|
|
69
|
+
// ...fresh for a skill that never had one.
|
|
70
|
+
expect(await mountIsFresh(skillDir, "hash-v1", false)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("stamps no marker when the skill has no version hash — such mounts never cache", async () => {
|
|
74
|
+
await writeSkillMount(makeSkillProto({ versionHash: "" }), skillDir, undefined);
|
|
75
|
+
|
|
76
|
+
expect(existsSync(join(skillDir, MOUNT_MARKER_FILE))).toBe(false);
|
|
77
|
+
expect(await mountIsFresh(skillDir, "", false)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is stale for a different hash", async () => {
|
|
81
|
+
await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, undefined);
|
|
82
|
+
expect(await mountIsFresh(skillDir, "hash-v2", false)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("is stale when the marker is missing or unparseable", async () => {
|
|
86
|
+
expect(await mountIsFresh(skillDir, "hash-v1", false)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("removes files from a previous mount that the new version no longer carries", async () => {
|
|
90
|
+
const v1 = buildZip([{ name: "references/removed-in-v2.md", content: "old" }]);
|
|
91
|
+
const v2 = buildZip([{ name: "references/new-in-v2.md", content: "new" }]);
|
|
92
|
+
|
|
93
|
+
await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, v1);
|
|
94
|
+
await writeSkillMount(makeSkillProto({ versionHash: "hash-v2" }), skillDir, v2);
|
|
95
|
+
|
|
96
|
+
expect(existsSync(join(skillDir, "references", "removed-in-v2.md"))).toBe(false);
|
|
97
|
+
expect(readFileSync(join(skillDir, "references", "new-in-v2.md"), "utf-8")).toBe("new");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("owns SKILL.md and the marker name unconditionally — zip copies are ignored", async () => {
|
|
101
|
+
const artifact = buildZip([
|
|
102
|
+
{ name: "SKILL.md", content: "# Stale zip copy" },
|
|
103
|
+
{ name: MOUNT_MARKER_FILE, content: "{\"versionHash\":\"forged\"}" },
|
|
104
|
+
]);
|
|
105
|
+
await writeSkillMount(
|
|
106
|
+
makeSkillProto({ skillMd: "# Authoritative", versionHash: "hash-v1" }),
|
|
107
|
+
skillDir,
|
|
108
|
+
artifact,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# Authoritative");
|
|
112
|
+
const marker = JSON.parse(readFileSync(join(skillDir, MOUNT_MARKER_FILE), "utf-8"));
|
|
113
|
+
expect(marker.versionHash).toBe("hash-v1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects zip entries that escape the mount directory", async () => {
|
|
117
|
+
const artifact = buildZip([{ name: "../escape.txt", content: "evil" }]);
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, artifact),
|
|
121
|
+
).rejects.toThrow(/escapes its mount directory/);
|
|
122
|
+
expect(existsSync(join(skillDir, "..", "escape.txt"))).toBe(false);
|
|
123
|
+
// The interrupted mount must not have been stamped as complete.
|
|
124
|
+
expect(existsSync(join(skillDir, MOUNT_MARKER_FILE))).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("writes script entries with the executable bit set", async () => {
|
|
128
|
+
const artifact = buildZip([
|
|
129
|
+
{ name: "scripts/run.py", content: "print('hi')" },
|
|
130
|
+
{ name: "references/notes.md", content: "notes" },
|
|
131
|
+
]);
|
|
132
|
+
await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, artifact);
|
|
133
|
+
|
|
134
|
+
expect(statSync(join(skillDir, "scripts", "run.py")).mode & 0o111).not.toBe(0);
|
|
135
|
+
expect(statSync(join(skillDir, "references", "notes.md")).mode & 0o111).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── downloadArtifact — transfer lane routing (#675) ─────────────────────
|
|
140
|
+
|
|
141
|
+
/** A server that predates the transfer lane (#675) answers the mint RPC
|
|
142
|
+
* with UNIMPLEMENTED — the default posture for these tests, which keeps
|
|
143
|
+
* every pre-existing case pinned to the unary getSkillArtifact fallback. */
|
|
144
|
+
function unimplementedMint() {
|
|
145
|
+
return vi.fn().mockRejectedValue(new ConnectError("unimplemented", Code.Unimplemented));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("downloadArtifact — transfer lane routing", () => {
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
vi.unstubAllGlobals();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
function makeClient(overrides: Record<string, any> = {}) {
|
|
154
|
+
return {
|
|
155
|
+
getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
|
|
156
|
+
getSkillArtifactDownloadUrl: unimplementedMint(),
|
|
157
|
+
...overrides,
|
|
158
|
+
} as any;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it("fetches bytes over HTTP when the server mints a download URL", async () => {
|
|
162
|
+
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
163
|
+
const client = makeClient({
|
|
164
|
+
getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
|
|
165
|
+
url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
|
|
166
|
+
sizeBytes: 5n,
|
|
167
|
+
ttlSeconds: 0,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
171
|
+
ok: true,
|
|
172
|
+
arrayBuffer: async () => bytes.buffer,
|
|
173
|
+
});
|
|
174
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
175
|
+
|
|
176
|
+
const got = await downloadArtifact(client, "skills/abc.zip");
|
|
177
|
+
|
|
178
|
+
expect(got).toEqual(bytes);
|
|
179
|
+
expect(fetchMock).toHaveBeenCalledWith("http://localhost:7234/v1/skill-artifacts/skills/abc.zip");
|
|
180
|
+
// The unary lane (10MB-capped) must not be touched when the URL lane works.
|
|
181
|
+
expect(client.getSkillArtifact).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("falls back to the unary RPC when the server predates the lane", async () => {
|
|
185
|
+
const bytes = new Uint8Array([9, 9]);
|
|
186
|
+
const client = makeClient({
|
|
187
|
+
getSkillArtifact: vi.fn().mockResolvedValue({ artifact: bytes }),
|
|
188
|
+
});
|
|
189
|
+
vi.stubGlobal("fetch", vi.fn()); // must never be called
|
|
190
|
+
|
|
191
|
+
const got = await downloadArtifact(client, "skills/abc.zip");
|
|
192
|
+
|
|
193
|
+
expect(got).toEqual(bytes);
|
|
194
|
+
expect(client.getSkillArtifact).toHaveBeenCalledWith("skills/abc.zip");
|
|
195
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does NOT fall back on non-Unimplemented mint failures", async () => {
|
|
199
|
+
const client = makeClient({
|
|
200
|
+
getSkillArtifactDownloadUrl: vi.fn().mockRejectedValue(
|
|
201
|
+
new ConnectError("boom", Code.Internal),
|
|
202
|
+
),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow("boom");
|
|
206
|
+
// Falling back here would mask real server faults behind the capped lane.
|
|
207
|
+
expect(client.getSkillArtifact).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("rejects truncated fetches via the minted size", async () => {
|
|
211
|
+
const client = makeClient({
|
|
212
|
+
getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
|
|
213
|
+
url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
|
|
214
|
+
sizeBytes: 100n,
|
|
215
|
+
ttlSeconds: 0,
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
219
|
+
ok: true,
|
|
220
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow(/truncated/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("surfaces HTTP failures with the status code", async () => {
|
|
227
|
+
const client = makeClient({
|
|
228
|
+
getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
|
|
229
|
+
url: "http://localhost:7234/v1/skill-artifacts/skills/gone.zip",
|
|
230
|
+
sizeBytes: 0n,
|
|
231
|
+
ttlSeconds: 0,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
235
|
+
|
|
236
|
+
await expect(downloadArtifact(client, "skills/gone.zip")).rejects.toThrow(/HTTP 404/);
|
|
237
|
+
});
|
|
238
|
+
});
|