@stigmer/runner 3.1.1 → 3.1.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/attachment-resolver.d.ts +49 -8
- package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
- package/dist/activities/execute-cursor/cas-observations.js +39 -5
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +19 -1
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -0
- package/dist/activities/execute-cursor/index.js +222 -160
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.js +10 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
- package/dist/activities/execute-cursor/prompt-builder.js +38 -20
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
- package/dist/activities/execute-cursor/skill-resolver.js +3 -55
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
- package/dist/activities/execute-cursor/stigmer-link.js +73 -0
- package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
- package/dist/activities/execute-cursor/workspace-provision.js +5 -3
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +89 -5
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +22 -16
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +36 -6
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +32 -0
- package/dist/shared/artifact-storage.js +74 -1
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +42 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +3 -1
- package/dist/shared/filereview/index.js +3 -1
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +96 -0
- package/dist/shared/filereview/progress.js +134 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +44 -0
- package/dist/shared/implement-plan-prompt.js +98 -0
- package/dist/shared/implement-plan-prompt.js.map +1 -0
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +32 -0
- package/dist/shared/plan-mode-prompt.js +56 -0
- package/dist/shared/plan-mode-prompt.js.map +1 -0
- package/dist/shared/status.d.ts +11 -0
- package/dist/shared/status.js +12 -2
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-row.d.ts +30 -1
- package/dist/shared/tool-row.js +53 -4
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/session-root.d.ts +35 -0
- package/dist/shared/workspace/session-root.js +53 -0
- package/dist/shared/workspace/session-root.js.map +1 -0
- package/dist/shared/workspace/workspace-lock.d.ts +86 -0
- package/dist/shared/workspace/workspace-lock.js +187 -0
- package/dist/shared/workspace/workspace-lock.js.map +1 -0
- package/package.json +8 -5
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
- package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/cas-observations.ts +41 -5
- package/src/activities/execute-cursor/hook-script.ts +19 -1
- package/src/activities/execute-cursor/index.ts +243 -178
- package/src/activities/execute-cursor/message-translator.ts +9 -1
- package/src/activities/execute-cursor/prompt-builder.ts +60 -22
- package/src/activities/execute-cursor/skill-resolver.ts +3 -59
- package/src/activities/execute-cursor/stigmer-link.ts +78 -0
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-cursor/workspace-provision.ts +5 -3
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
- package/src/activities/execute-deep-agent/index.ts +104 -5
- package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
- package/src/activities/execute-deep-agent/setup.ts +25 -17
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/config.ts +17 -0
- package/src/middleware/__tests__/approval-gate.test.ts +77 -12
- package/src/middleware/approval-gate.ts +39 -7
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +111 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
- package/src/shared/__tests__/status.test.ts +7 -2
- package/src/shared/__tests__/tool-row.test.ts +136 -1
- package/src/shared/artifact-storage.ts +83 -1
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -0
- package/src/shared/implement-plan-prompt.ts +104 -0
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +56 -0
- package/src/shared/status.ts +11 -1
- package/src/shared/tool-row.ts +58 -5
- package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
- package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
- package/src/shared/workspace/session-root.ts +60 -0
- package/src/shared/workspace/workspace-lock.ts +241 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { describe, it, expect } from "vitest";
|
|
2
3
|
import { create } from "@bufbuild/protobuf";
|
|
3
4
|
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
@@ -11,8 +12,9 @@ import { makeInMemoryArtifactStorage } from "../../__test-utils__/fake-artifact-
|
|
|
11
12
|
import {
|
|
12
13
|
extractFinalPlanText,
|
|
13
14
|
publishPlanArtifact,
|
|
15
|
+
planArtifactName,
|
|
16
|
+
isPlanArtifactName,
|
|
14
17
|
PLAN_ARTIFACT_NAME,
|
|
15
|
-
PLAN_ARTIFACT_SANDBOX_PATH,
|
|
16
18
|
} from "../plan-artifact.js";
|
|
17
19
|
|
|
18
20
|
/** Records uploads and returns deterministic download URLs (canonical double + shim). */
|
|
@@ -27,6 +29,11 @@ function fakeStorage(): ArtifactStorage & { uploads: { key: string; content: Buf
|
|
|
27
29
|
return Object.assign(storage, { uploads });
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Mirrors the runner's content-hash discriminator so tests can assert exact names. */
|
|
33
|
+
function planId(text: string): string {
|
|
34
|
+
return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
function statusWith(...messages: { type: MessageType; content: string }[]) {
|
|
31
38
|
return create(AgentExecutionStatusSchema, {
|
|
32
39
|
messages: messages.map((m) =>
|
|
@@ -59,30 +66,129 @@ describe("extractFinalPlanText", () => {
|
|
|
59
66
|
});
|
|
60
67
|
});
|
|
61
68
|
|
|
69
|
+
describe("planArtifactName", () => {
|
|
70
|
+
it("derives a hyphenated <slug>_<id>.plan.md name from the plan's leading # H1", () => {
|
|
71
|
+
const text = "# Plan Card UX Cleanup\n\nBody";
|
|
72
|
+
// "Plan Card…" has no label separator, so the genuine word "plan" survives.
|
|
73
|
+
expect(planArtifactName(text)).toBe(`plan-card-ux-cleanup_${planId(text)}.plan.md`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("strips a leading 'Plan:' LABEL (colon/dash-anchored) from the slug", () => {
|
|
77
|
+
const text = "# Plan: Fix the download UX\n\nbody";
|
|
78
|
+
expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("collapses runs of punctuation/whitespace and trims edge hyphens", () => {
|
|
82
|
+
const text = "# Fix: the (download) UX!! \nx";
|
|
83
|
+
expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("unwraps a ```markdown fenced plan before reading the title (SDK parity)", () => {
|
|
87
|
+
const text = "```markdown\n# Named Plan\n\nbody\n```";
|
|
88
|
+
expect(planArtifactName(text)).toBe(`named-plan_${planId(text)}.plan.md`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("unwraps a bare ``` fenced plan before reading the title (SDK parity)", () => {
|
|
92
|
+
const text = "```\n# Bare Fenced Plan\n\nbody\n```";
|
|
93
|
+
expect(planArtifactName(text)).toBe(`bare-fenced-plan_${planId(text)}.plan.md`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("caps the slug length so filenames stay bounded", () => {
|
|
97
|
+
const longTitle = "# " + "word ".repeat(40); // ~200 chars of words
|
|
98
|
+
const name = planArtifactName(longTitle);
|
|
99
|
+
const match = /^(.+)_[0-9a-f]{8}\.plan\.md$/.exec(name);
|
|
100
|
+
expect(match).not.toBeNull();
|
|
101
|
+
const slug = match![1];
|
|
102
|
+
expect(slug.length).toBeLessThanOrEqual(60);
|
|
103
|
+
expect(slug.endsWith("-")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("falls back to a bare <id>.plan.md when there is no leading H1", () => {
|
|
107
|
+
const noTitle = "No title here\n\nbody";
|
|
108
|
+
expect(planArtifactName(noTitle)).toBe(`${planId(noTitle)}.plan.md`);
|
|
109
|
+
// An H1 further down is body content, not the title.
|
|
110
|
+
const later = "intro\n\n# Later Heading";
|
|
111
|
+
expect(planArtifactName(later)).toBe(`${planId(later)}.plan.md`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back to a bare <id>.plan.md when the title is only the 'Plan:' label", () => {
|
|
115
|
+
const text = "# Plan:\n\nbody";
|
|
116
|
+
expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to a bare <id>.plan.md when the title has no alphanumerics", () => {
|
|
120
|
+
const text = "# ---\n\nbody";
|
|
121
|
+
expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("never freshly emits the legacy plan.md name", () => {
|
|
125
|
+
for (const text of ["# A Title\n\nbody", "no heading", "# Plan:\n\nx"]) {
|
|
126
|
+
expect(planArtifactName(text)).not.toBe(PLAN_ARTIFACT_NAME);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("isPlanArtifactName", () => {
|
|
132
|
+
it("accepts the legacy exact name and any *.plan.md", () => {
|
|
133
|
+
expect(isPlanArtifactName("plan.md")).toBe(true);
|
|
134
|
+
expect(isPlanArtifactName("feature_x.plan.md")).toBe(true);
|
|
135
|
+
expect(isPlanArtifactName("fix-the-download-ux_a1b2c3d4.plan.md")).toBe(true);
|
|
136
|
+
expect(isPlanArtifactName("a1b2c3d4.plan.md")).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects non-plan filenames", () => {
|
|
140
|
+
expect(isPlanArtifactName("notes.md")).toBe(false);
|
|
141
|
+
expect(isPlanArtifactName("plan.md.bak")).toBe(false);
|
|
142
|
+
expect(isPlanArtifactName("myplan.md")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
62
146
|
describe("publishPlanArtifact", () => {
|
|
63
|
-
it("uploads plan
|
|
147
|
+
it("uploads the named plan and registers a FILE artifact on the status", async () => {
|
|
64
148
|
const status = create(AgentExecutionStatusSchema, {});
|
|
65
149
|
const storage = fakeStorage();
|
|
66
150
|
|
|
151
|
+
const planText = "# Plan Card UX Cleanup\n1. step one\n2. step two\n";
|
|
152
|
+
const name = `plan-card-ux-cleanup_${planId(planText)}.plan.md`;
|
|
67
153
|
await publishPlanArtifact({
|
|
68
154
|
status,
|
|
69
155
|
executionId: "aex_123",
|
|
70
|
-
planText
|
|
156
|
+
planText,
|
|
71
157
|
artifactStorage: storage,
|
|
72
158
|
});
|
|
73
159
|
|
|
74
160
|
expect(storage.uploads).toHaveLength(1);
|
|
75
|
-
expect(storage.uploads[0].key).toBe(
|
|
161
|
+
expect(storage.uploads[0].key).toBe(`artifacts/aex_123/${name}`);
|
|
76
162
|
expect(storage.uploads[0].contentType).toBe("text/markdown");
|
|
77
163
|
|
|
78
164
|
expect(status.artifacts).toHaveLength(1);
|
|
79
165
|
const artifact = status.artifacts[0];
|
|
80
|
-
expect(artifact.name).toBe(
|
|
81
|
-
expect(artifact.sandboxPath).toBe(
|
|
166
|
+
expect(artifact.name).toBe(name);
|
|
167
|
+
expect(artifact.sandboxPath).toBe(`.stigmer/plans/${name}`);
|
|
82
168
|
expect(artifact.kind).toBe(ExecutionArtifactKind.FILE);
|
|
83
|
-
expect(artifact.storageKey).toBe(
|
|
169
|
+
expect(artifact.storageKey).toBe(`artifacts/aex_123/${name}`);
|
|
84
170
|
expect(artifact.sizeBytes).toBeGreaterThan(0n);
|
|
85
171
|
expect(artifact.contentHash).toHaveLength(64);
|
|
172
|
+
// The filename's <id> is the leading 8 hex of the full content hash.
|
|
173
|
+
expect(artifact.contentHash.startsWith(planId(planText))).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("uses a bare <id>.plan.md name for a titleless plan (never the legacy plan.md)", async () => {
|
|
177
|
+
const status = create(AgentExecutionStatusSchema, {});
|
|
178
|
+
const storage = fakeStorage();
|
|
179
|
+
|
|
180
|
+
const planText = "just a paragraph, no heading";
|
|
181
|
+
const name = `${planId(planText)}.plan.md`;
|
|
182
|
+
await publishPlanArtifact({
|
|
183
|
+
status,
|
|
184
|
+
executionId: "aex_notitle",
|
|
185
|
+
planText,
|
|
186
|
+
artifactStorage: storage,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(status.artifacts[0].name).toBe(name);
|
|
190
|
+
expect(status.artifacts[0].name).not.toBe(PLAN_ARTIFACT_NAME);
|
|
191
|
+
expect(storage.uploads[0].key).toBe(`artifacts/aex_notitle/${name}`);
|
|
86
192
|
});
|
|
87
193
|
|
|
88
194
|
it("is a no-op for empty plan text (nothing to publish)", async () => {
|
|
@@ -100,20 +206,47 @@ describe("publishPlanArtifact", () => {
|
|
|
100
206
|
expect(status.artifacts).toHaveLength(0);
|
|
101
207
|
});
|
|
102
208
|
|
|
103
|
-
it("replaces an existing plan
|
|
209
|
+
it("replaces an existing plan rather than appending a duplicate", async () => {
|
|
104
210
|
const status = create(AgentExecutionStatusSchema, {});
|
|
105
211
|
const storage = fakeStorage();
|
|
106
212
|
|
|
107
213
|
await publishPlanArtifact({ status, executionId: "aex_re", planText: "v1", artifactStorage: storage });
|
|
108
214
|
await publishPlanArtifact({ status, executionId: "aex_re", planText: "v2 longer plan", artifactStorage: storage });
|
|
109
215
|
|
|
216
|
+
// Replacement is at the STATUS level, matched by the *.plan.md predicate:
|
|
217
|
+
// exactly one plan artifact survives. Because names are content-derived,
|
|
218
|
+
// the two differing-content writes land under DIFFERENT keys (the first is
|
|
219
|
+
// an orphan blob) — but this synthetic double-publish never happens in
|
|
220
|
+
// production, where publishPlanArtifact runs once per execution finalization
|
|
221
|
+
// and a same-content retry re-writes the same key.
|
|
110
222
|
expect(status.artifacts).toHaveLength(1);
|
|
111
223
|
expect(storage.uploads).toHaveLength(2);
|
|
112
|
-
// The surviving artifact reflects the latest content hash.
|
|
113
224
|
const latestHash = storage.uploads[1].content;
|
|
114
225
|
expect(status.artifacts[0].sizeBytes).toBe(BigInt(latestHash.length));
|
|
115
226
|
});
|
|
116
227
|
|
|
228
|
+
it("replaces a prior plan even when the derived name changed (predicate match)", async () => {
|
|
229
|
+
const status = create(AgentExecutionStatusSchema, {});
|
|
230
|
+
const storage = fakeStorage();
|
|
231
|
+
|
|
232
|
+
await publishPlanArtifact({
|
|
233
|
+
status,
|
|
234
|
+
executionId: "aex_rename",
|
|
235
|
+
planText: "# First Title\n\nbody",
|
|
236
|
+
artifactStorage: storage,
|
|
237
|
+
});
|
|
238
|
+
const secondText = "# Second Title\n\nbody";
|
|
239
|
+
await publishPlanArtifact({
|
|
240
|
+
status,
|
|
241
|
+
executionId: "aex_rename",
|
|
242
|
+
planText: secondText,
|
|
243
|
+
artifactStorage: storage,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(status.artifacts).toHaveLength(1);
|
|
247
|
+
expect(status.artifacts[0].name).toBe(`second-title_${planId(secondText)}.plan.md`);
|
|
248
|
+
});
|
|
249
|
+
|
|
117
250
|
it("never throws when the upload fails (publish is non-fatal)", async () => {
|
|
118
251
|
const status = create(AgentExecutionStatusSchema, {});
|
|
119
252
|
const { storage: failing } = makeInMemoryArtifactStorage();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deny-gate secret leak-scan (DD-26 follow-up #2) — the load-bearing guarantee.
|
|
3
|
+
*
|
|
4
|
+
* The runner-level analog of the offline Go `TestOffline_FileReview_
|
|
5
|
+
* SecretUnderGlobalBypass_NeverPersisted`: assemble a realistic deny-gate status
|
|
6
|
+
* whose transcript carries a secret-like write with its content, run the
|
|
7
|
+
* Invariant-A backstop that BOTH harnesses call before persisting, then serialize
|
|
8
|
+
* the whole status and assert none of the secret bytes survive.
|
|
9
|
+
*
|
|
10
|
+
* An offline end-to-end test is structurally infeasible for this path (DD-23 /
|
|
11
|
+
* DD-26 F3: the offline harness always runs a git workspace with LocalArtifactDir,
|
|
12
|
+
* so deriveCaptureMode is always true and the no-storage deny-gate is unreachable).
|
|
13
|
+
* This test exercises the exact function both `execute-deep-agent/index.ts` and the
|
|
14
|
+
* Cursor `persist` wrapper invoke, over a full AgentExecutionStatus.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from "vitest";
|
|
18
|
+
import { create, toJsonString } from "@bufbuild/protobuf";
|
|
19
|
+
import {
|
|
20
|
+
AgentMessageSchema,
|
|
21
|
+
ToolCallSchema,
|
|
22
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
23
|
+
import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
24
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
25
|
+
import { ToolCallStatus, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
26
|
+
import { withholdSecretContentFromMessages } from "../tool-row.js";
|
|
27
|
+
|
|
28
|
+
const SECRET = "API_KEY=SUPER_SECRET_LEAK_TOKEN_9f3a";
|
|
29
|
+
|
|
30
|
+
function secretWriteRow(id: string, path: string) {
|
|
31
|
+
return create(ToolCallSchema, {
|
|
32
|
+
id,
|
|
33
|
+
name: "write",
|
|
34
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
35
|
+
result: "wrote file",
|
|
36
|
+
argsPreview: JSON.stringify({ path, content: SECRET }),
|
|
37
|
+
args: { path, content: SECRET },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("deny-gate secret leak-scan", () => {
|
|
42
|
+
it("no secret bytes survive in the serialized status (top-level + sub-agent), non-secret content preserved", () => {
|
|
43
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
44
|
+
messages: [
|
|
45
|
+
create(AgentMessageSchema, {
|
|
46
|
+
type: MessageType.MESSAGE_AI,
|
|
47
|
+
toolCalls: [
|
|
48
|
+
secretWriteRow("tc-secret", ".env"),
|
|
49
|
+
// A non-secret write in the same turn must keep its content (the
|
|
50
|
+
// backstop is scoped to secret-like paths only).
|
|
51
|
+
create(ToolCallSchema, {
|
|
52
|
+
id: "tc-ok",
|
|
53
|
+
name: "write",
|
|
54
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
55
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
56
|
+
args: { path: "notes.md", content: "PUBLIC_NOTES_BODY" },
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
subAgentExecutions: [
|
|
62
|
+
create(SubAgentExecutionSchema, {
|
|
63
|
+
id: "sa-1",
|
|
64
|
+
messages: [
|
|
65
|
+
create(AgentMessageSchema, {
|
|
66
|
+
type: MessageType.MESSAGE_AI,
|
|
67
|
+
toolCalls: [secretWriteRow("tc-sa-secret", "config/credentials.json")],
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// The mechanism both harnesses call before every persist.
|
|
75
|
+
withholdSecretContentFromMessages(status.messages, status.subAgentExecutions);
|
|
76
|
+
|
|
77
|
+
const serialized = toJsonString(AgentExecutionStatusSchema, status);
|
|
78
|
+
expect(serialized).not.toContain(SECRET);
|
|
79
|
+
// The paths remain (a filename is not the secret); non-secret content survives.
|
|
80
|
+
expect(serialized).toContain(".env");
|
|
81
|
+
expect(serialized).toContain("config/credentials.json");
|
|
82
|
+
expect(serialized).toContain("PUBLIC_NOTES_BODY");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("global bypass: the backstop is the sole guarantee when no gate scrubbed the row", () => {
|
|
86
|
+
// Under auto_approve_all neither harness installs a gate, so a secret write
|
|
87
|
+
// flows and its content lands on the streamed row unscrubbed. The backstop —
|
|
88
|
+
// called unconditionally before persist — is the only thing standing between
|
|
89
|
+
// that row and durable storage.
|
|
90
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
91
|
+
messages: [
|
|
92
|
+
create(AgentMessageSchema, {
|
|
93
|
+
type: MessageType.MESSAGE_AI,
|
|
94
|
+
toolCalls: [secretWriteRow("tc-flowed", ".aws/credentials")],
|
|
95
|
+
}),
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
withholdSecretContentFromMessages(status.messages, status.subAgentExecutions);
|
|
100
|
+
|
|
101
|
+
const serialized = toJsonString(AgentExecutionStatusSchema, status);
|
|
102
|
+
expect(serialized).not.toContain(SECRET);
|
|
103
|
+
expect(serialized).toContain(".aws/credentials"); // path kept, honest record
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -324,7 +324,7 @@ describe("persistStatus — transient retry", () => {
|
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
describe("reportSetupProgress", () => {
|
|
327
|
-
it("persists
|
|
327
|
+
it("persists the label WITHOUT forcing a phase transition", async () => {
|
|
328
328
|
const mockClient = {
|
|
329
329
|
updateStatus: vi.fn().mockResolvedValue({
|
|
330
330
|
signal: ExecutionControlSignal.UNSPECIFIED,
|
|
@@ -334,7 +334,12 @@ describe("reportSetupProgress", () => {
|
|
|
334
334
|
expect(mockClient.updateStatus).toHaveBeenCalledOnce();
|
|
335
335
|
const [id, status] = mockClient.updateStatus.mock.calls[0];
|
|
336
336
|
expect(id).toBe("exec-3");
|
|
337
|
-
|
|
337
|
+
// UNSPECIFIED = "leave the phase unchanged". The server keeps
|
|
338
|
+
// setup_progress only while the merged phase is still PENDING, so a
|
|
339
|
+
// setup report must never flip the phase itself — sending IN_PROGRESS
|
|
340
|
+
// here made every setup label (and the workspace-lock waiting state)
|
|
341
|
+
// self-destruct on arrival.
|
|
342
|
+
expect(status.phase).toBe(ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED);
|
|
338
343
|
expect(status.setupProgress?.currentPhase).toBe("Resolving MCP servers");
|
|
339
344
|
});
|
|
340
345
|
});
|
|
@@ -10,7 +10,14 @@ import { create } from "@bufbuild/protobuf";
|
|
|
10
10
|
import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
11
11
|
import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
12
12
|
import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
collectSubAgentToolCallIds,
|
|
15
|
+
hideToolCallRow,
|
|
16
|
+
isToolCallRowHidden,
|
|
17
|
+
stampFileEditRow,
|
|
18
|
+
withholdSecretContentFromMessages,
|
|
19
|
+
withholdSecretFileContent,
|
|
20
|
+
} from "../tool-row.js";
|
|
14
21
|
|
|
15
22
|
describe("stampFileEditRow", () => {
|
|
16
23
|
it("stamps additively: content, status, and identity all survive", () => {
|
|
@@ -90,6 +97,134 @@ describe("stampFileEditRow", () => {
|
|
|
90
97
|
});
|
|
91
98
|
});
|
|
92
99
|
|
|
100
|
+
describe("withholdSecretFileContent", () => {
|
|
101
|
+
it("reduces a secret-like row's args to { path } and clears args_preview, keeping result", () => {
|
|
102
|
+
const tc = create(ToolCallSchema, {
|
|
103
|
+
id: "tc-1",
|
|
104
|
+
name: "write",
|
|
105
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
106
|
+
result: "Tool 'write' was blocked for security: '.env' matches a secret-like path. Nothing was written.",
|
|
107
|
+
argsPreview: '{"path":".env"}',
|
|
108
|
+
args: { path: ".env", content: "API_KEY=super-secret" },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(withholdSecretFileContent(tc)).toBe(true);
|
|
112
|
+
expect(tc.args).toEqual({ path: ".env" });
|
|
113
|
+
expect(tc.argsPreview).toBe("");
|
|
114
|
+
// result is left intact — the deny-gate uses it for the safe "blocked" message.
|
|
115
|
+
expect(tc.result).toContain("blocked for security");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("leaves a non-secret row untouched and returns false", () => {
|
|
119
|
+
const tc = create(ToolCallSchema, {
|
|
120
|
+
id: "tc-2",
|
|
121
|
+
name: "write",
|
|
122
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
123
|
+
argsPreview: '{"path":"src/app.ts"}',
|
|
124
|
+
args: { path: "src/app.ts", content: "console.log('hi')" },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(withholdSecretFileContent(tc)).toBe(false);
|
|
128
|
+
expect(tc.args).toEqual({ path: "src/app.ts", content: "console.log('hi')" });
|
|
129
|
+
expect(tc.argsPreview).toBe('{"path":"src/app.ts"}');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("fail-closes when the path cannot be determined (args → undefined)", () => {
|
|
133
|
+
const tc = create(ToolCallSchema, {
|
|
134
|
+
id: "tc-3",
|
|
135
|
+
name: "write",
|
|
136
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
137
|
+
args: { unrecognized_shape: "payload" },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(withholdSecretFileContent(tc)).toBe(true);
|
|
141
|
+
expect(tc.args).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("withholdSecretContentFromMessages", () => {
|
|
146
|
+
function writeRow(id: string, path: string, content = "SECRET_BODY"): ReturnType<typeof create<typeof ToolCallSchema>> {
|
|
147
|
+
return create(ToolCallSchema, {
|
|
148
|
+
id,
|
|
149
|
+
name: "write",
|
|
150
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
151
|
+
argsPreview: JSON.stringify({ path }),
|
|
152
|
+
args: { path, content },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
it("withholds content from a secret write row but leaves a non-secret one intact", () => {
|
|
157
|
+
const msg = create(AgentMessageSchema, {
|
|
158
|
+
type: 1,
|
|
159
|
+
toolCalls: [writeRow("tc-secret", ".env"), writeRow("tc-ok", "src/app.ts")],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
withholdSecretContentFromMessages([msg]);
|
|
163
|
+
|
|
164
|
+
expect(msg.toolCalls[0].args).toEqual({ path: ".env" });
|
|
165
|
+
expect(msg.toolCalls[0].argsPreview).toBe("");
|
|
166
|
+
expect(msg.toolCalls[1].args).toEqual({ path: "src/app.ts", content: "SECRET_BODY" });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("covers edit-family rows (category write) too", () => {
|
|
170
|
+
const editRow = create(ToolCallSchema, {
|
|
171
|
+
id: "tc-edit",
|
|
172
|
+
name: "StrReplace",
|
|
173
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
174
|
+
args: { path: ".ssh/id_rsa", old_string: "a", new_string: "b" },
|
|
175
|
+
});
|
|
176
|
+
const msg = create(AgentMessageSchema, { type: 1, toolCalls: [editRow] });
|
|
177
|
+
|
|
178
|
+
withholdSecretContentFromMessages([msg]);
|
|
179
|
+
|
|
180
|
+
expect(editRow.args).toEqual({ path: ".ssh/id_rsa" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does NOT touch a delete row (deletes carry no content)", () => {
|
|
184
|
+
const deleteRow = create(ToolCallSchema, {
|
|
185
|
+
id: "tc-del",
|
|
186
|
+
name: "delete",
|
|
187
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
188
|
+
args: { path: ".env" },
|
|
189
|
+
});
|
|
190
|
+
const msg = create(AgentMessageSchema, { type: 1, toolCalls: [deleteRow] });
|
|
191
|
+
|
|
192
|
+
withholdSecretContentFromMessages([msg]);
|
|
193
|
+
|
|
194
|
+
// A delete's args are path-only already; the write-scoped pass leaves it alone.
|
|
195
|
+
expect(deleteRow.args).toEqual({ path: ".env" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("walks sub-agent transcripts", () => {
|
|
199
|
+
const topMsg = create(AgentMessageSchema, { type: 1, toolCalls: [writeRow("tc-top", "notes.md")] });
|
|
200
|
+
const sa = create(SubAgentExecutionSchema, {
|
|
201
|
+
id: "sa-1",
|
|
202
|
+
messages: [create(AgentMessageSchema, { type: 1, toolCalls: [writeRow("tc-sa", "credentials.json")] })],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
withholdSecretContentFromMessages([topMsg], [sa]);
|
|
206
|
+
|
|
207
|
+
expect(topMsg.toolCalls[0].args).toEqual({ path: "notes.md", content: "SECRET_BODY" }); // non-secret untouched
|
|
208
|
+
expect(sa.messages[0].toolCalls[0].args).toEqual({ path: "credentials.json" }); // sub-agent secret scrubbed
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("is idempotent and agrees with stampFileEditRow's content-less shape", () => {
|
|
212
|
+
const row = writeRow("tc-secret", ".env");
|
|
213
|
+
const msg = create(AgentMessageSchema, { type: 1, toolCalls: [row] });
|
|
214
|
+
|
|
215
|
+
withholdSecretContentFromMessages([msg]);
|
|
216
|
+
const afterFirst = { args: row.args, argsPreview: row.argsPreview };
|
|
217
|
+
withholdSecretContentFromMessages([msg]);
|
|
218
|
+
expect(row.args).toEqual(afterFirst.args);
|
|
219
|
+
expect(row.argsPreview).toBe(afterFirst.argsPreview);
|
|
220
|
+
|
|
221
|
+
// stampFileEditRow on the already-scrubbed row yields the same content-less shape.
|
|
222
|
+
stampFileEditRow(row, "exec-1:0");
|
|
223
|
+
expect(row.args).toEqual({ path: ".env" });
|
|
224
|
+
expect(row.argsPreview).toBe("");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
93
228
|
describe("hideToolCallRow", () => {
|
|
94
229
|
it("collapses a completed file-edit row to the hidden shape", () => {
|
|
95
230
|
const tc = create(ToolCallSchema, {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* DD-6: No direct R2 backend. Local + Proxy only.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { mkdir, writeFile, readFile, access } from "node:fs/promises";
|
|
19
|
+
import { mkdir, writeFile, readFile, access, rm } from "node:fs/promises";
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
21
|
import type { Config } from "../config.js";
|
|
22
22
|
|
|
@@ -256,3 +256,85 @@ export function createArtifactStorage(cfg: ArtifactStorageConfig): ArtifactStora
|
|
|
256
256
|
|
|
257
257
|
return new LocalArtifactStorage(cfg.localPath, cfg.localServeUrl);
|
|
258
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Prove that `basePath` can actually be written to, the way {@link
|
|
262
|
+
* LocalArtifactStorage.upload} writes: create the directory tree, write a
|
|
263
|
+
* throwaway file, then remove it. Returns `false` on any failure.
|
|
264
|
+
*
|
|
265
|
+
* We do an actual write rather than `access(basePath, W_OK)` deliberately:
|
|
266
|
+
* `access` can lie under root / ACLs / overlay filesystems (it checks the
|
|
267
|
+
* permission bits, not the real outcome), it does not exercise the recursive
|
|
268
|
+
* `mkdir` + `writeFile` that `upload` performs (so it misses a `basePath` whose
|
|
269
|
+
* parent is a file, an ENOTDIR), and it cannot catch a full disk. The scratch
|
|
270
|
+
* file is uniquely named so concurrent runners never collide, and is removed
|
|
271
|
+
* even though `basePath` (which we want to exist anyway) is left in place.
|
|
272
|
+
*/
|
|
273
|
+
async function isLocalPathWritable(basePath: string): Promise<boolean> {
|
|
274
|
+
const probePath = join(basePath, `.write-probe-${process.pid}-${Date.now()}`);
|
|
275
|
+
try {
|
|
276
|
+
await mkdir(basePath, { recursive: true });
|
|
277
|
+
await writeFile(probePath, "");
|
|
278
|
+
await rm(probePath, { force: true });
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolve a *usable* artifact store for the file-review capture / status-offload
|
|
287
|
+
* path, degrading to `undefined` instead of crashing when there is no working
|
|
288
|
+
* substrate. This is the single construct-or-degrade seam shared by both
|
|
289
|
+
* harnesses (the deep-agent and Cursor activities) so they degrade identically.
|
|
290
|
+
*
|
|
291
|
+
* Two degrade paths:
|
|
292
|
+
* - **Proxy misconfig** — {@link createArtifactStorage} throws for a missing
|
|
293
|
+
* endpoint/token; caught here and reported absent.
|
|
294
|
+
* - **Unwritable local path** — {@link LocalArtifactStorage} constructs around a
|
|
295
|
+
* path string and never throws, so an unwritable base path (bad mount, EPERM,
|
|
296
|
+
* full disk) would otherwise let file writes FLOW during the turn and only
|
|
297
|
+
* crash at the turn-boundary upload, with the workspace already mutated and no
|
|
298
|
+
* review authored. We probe writability up front and report absent instead.
|
|
299
|
+
*
|
|
300
|
+
* Proxy is intentionally NOT probed at the network layer: a live-endpoint check
|
|
301
|
+
* would add a round-trip to every cloud setup and risk falsely degrading on a
|
|
302
|
+
* transient blip; construction already validates its config.
|
|
303
|
+
*
|
|
304
|
+
* An absent store is a first-class, already-supported state (DD-26): capture
|
|
305
|
+
* degrades to the deny-gate (via {@link deriveCaptureMode}'s `hasArtifactStorage`
|
|
306
|
+
* argument), tool-output offload is disabled (the aggregate size guard still
|
|
307
|
+
* applies), and attachment / plan-artifact publishing surface a clear error.
|
|
308
|
+
* This is the fail-safe realization of DD-26 follow-up #1.
|
|
309
|
+
*
|
|
310
|
+
* NOTE: this resolver is for the capture/offload path only. Claimcheck (Temporal
|
|
311
|
+
* payload offload) MUST have storage and has no deny-gate to fall back to, so it
|
|
312
|
+
* deliberately keeps calling {@link createArtifactStorage} directly (fail-hard).
|
|
313
|
+
*/
|
|
314
|
+
export async function resolveUsableArtifactStorage(
|
|
315
|
+
cfg: ArtifactStorageConfig,
|
|
316
|
+
ctx: { executionId: string },
|
|
317
|
+
): Promise<ArtifactStorage | undefined> {
|
|
318
|
+
let storage: ArtifactStorage;
|
|
319
|
+
try {
|
|
320
|
+
storage = createArtifactStorage(cfg);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.warn(
|
|
323
|
+
`[artifact-storage] unavailable — file capture degrades to the deny-gate ` +
|
|
324
|
+
`and tool-output offload is disabled: execution=${ctx.executionId}, ` +
|
|
325
|
+
`type=${cfg.type}, error=${err}`,
|
|
326
|
+
);
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (cfg.type === "local" && !(await isLocalPathWritable(cfg.localPath))) {
|
|
331
|
+
console.warn(
|
|
332
|
+
`[artifact-storage] local path not writable — file capture degrades to the ` +
|
|
333
|
+
`deny-gate and tool-output offload is disabled: execution=${ctx.executionId}, ` +
|
|
334
|
+
`path=${cfg.localPath}`,
|
|
335
|
+
);
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return storage;
|
|
340
|
+
}
|