@stigmer/runner 3.9.0 → 3.11.0
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/README.md +12 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-llm.js +9 -10
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/classify-tool-approvals.d.ts +2 -1
- package/dist/activities/classify-tool-approvals.js +28 -2
- package/dist/activities/classify-tool-approvals.js.map +1 -1
- package/dist/activities/discover-mcp-server.d.ts +32 -0
- package/dist/activities/discover-mcp-server.js +162 -27
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
- package/dist/activities/execute-cursor/approval-state.js +7 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
- package/dist/activities/execute-cursor/attachment-resolver.js +25 -4
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
- package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/env-resolver.js +3 -1
- package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
- package/dist/activities/execute-cursor/error-classifier.js +81 -3
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
- package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
- package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
- package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
- package/dist/activities/execute-cursor/hook-script.js +72 -10
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -1
- package/dist/activities/execute-cursor/index.js +59 -58
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
- package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
- package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
- package/dist/activities/execute-cursor/prompt-builder.js +12 -7
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +4 -1
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/environment.js +3 -1
- package/dist/activities/execute-deep-agent/environment.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +15 -0
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
- package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
- package/dist/activities/execute-deep-agent/setup.js +65 -26
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
- package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +3 -1
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/activities/workflow-event-activities.d.ts +28 -10
- package/dist/activities/workflow-event-activities.js +87 -58
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/claimcheck/payload-codec.js +21 -1
- package/dist/claimcheck/payload-codec.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +9 -4
- package/dist/client/stigmer-client.js +28 -15
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/encryption/config.d.ts +32 -0
- package/dist/encryption/config.js +68 -0
- package/dist/encryption/config.js.map +1 -0
- package/dist/encryption/index.d.ts +3 -0
- package/dist/encryption/index.js +3 -0
- package/dist/encryption/index.js.map +1 -0
- package/dist/encryption/payload-codec.d.ts +41 -0
- package/dist/encryption/payload-codec.js +130 -0
- package/dist/encryption/payload-codec.js.map +1 -0
- package/dist/payload-codecs.d.ts +16 -0
- package/dist/payload-codecs.js +38 -0
- package/dist/payload-codecs.js.map +1 -0
- package/dist/preflight.d.ts +31 -0
- package/dist/preflight.js +43 -0
- package/dist/preflight.js.map +1 -1
- package/dist/runner-manager.js +5 -15
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +5 -16
- package/dist/runner.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +9 -3
- package/dist/shared/approval-policy.js +15 -6
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/attachment-naming.d.ts +53 -0
- package/dist/shared/attachment-naming.js +59 -0
- package/dist/shared/attachment-naming.js.map +1 -0
- package/dist/shared/attachment-vision.d.ts +43 -2
- package/dist/shared/attachment-vision.js +72 -6
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/caller-identity.d.ts +23 -2
- package/dist/shared/caller-identity.js +36 -5
- package/dist/shared/caller-identity.js.map +1 -1
- package/dist/shared/channel-attachment.js +1 -0
- package/dist/shared/channel-attachment.js.map +1 -1
- package/dist/shared/checkpointer/http-saver.d.ts +42 -1
- package/dist/shared/checkpointer/http-saver.js +96 -8
- package/dist/shared/checkpointer/http-saver.js.map +1 -1
- package/dist/shared/conversation-attachment.js +1 -0
- package/dist/shared/conversation-attachment.js.map +1 -1
- package/dist/shared/datastore-attachment.d.ts +50 -7
- package/dist/shared/datastore-attachment.js +93 -11
- package/dist/shared/datastore-attachment.js.map +1 -1
- package/dist/shared/http-retry.d.ts +43 -0
- package/dist/shared/http-retry.js +50 -0
- package/dist/shared/http-retry.js.map +1 -0
- package/dist/shared/llm-backend.d.ts +275 -0
- package/dist/shared/llm-backend.js +425 -0
- package/dist/shared/llm-backend.js.map +1 -0
- package/dist/shared/llm-proxy.d.ts +8 -0
- package/dist/shared/llm-proxy.js +15 -0
- package/dist/shared/llm-proxy.js.map +1 -1
- package/dist/shared/mcp-enabled-tools.d.ts +57 -0
- package/dist/shared/mcp-enabled-tools.js +86 -0
- package/dist/shared/mcp-enabled-tools.js.map +1 -0
- package/dist/shared/mcp-manager.d.ts +17 -2
- package/dist/shared/mcp-manager.js +37 -25
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +39 -2
- package/dist/shared/mcp-resolver.js +38 -2
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/model-client.d.ts +12 -5
- package/dist/shared/model-client.js +138 -18
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-error.js +198 -5
- package/dist/shared/model-error.js.map +1 -1
- package/dist/shared/model-registry.d.ts +20 -2
- package/dist/shared/model-registry.js +37 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +26 -0
- package/dist/shared/plan-mode-permissions.js +28 -0
- package/dist/shared/plan-mode-permissions.js.map +1 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +2 -4
- package/dist/worker.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +18 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
- package/dist/workflows/call-agent-orchestrator.js +1 -0
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/dist/workflows/connect-mcp-server.js +7 -0
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/engine-core.js +23 -2
- package/dist/workflows/engine-core.js.map +1 -1
- package/dist/workflows/execute-from-execution.d.ts +1 -1
- package/dist/workflows/execute-from-execution.js +11 -1
- package/dist/workflows/execute-from-execution.js.map +1 -1
- package/package.json +8 -2
- package/src/__tests__/claimcheck-codec.test.ts +36 -0
- package/src/__tests__/encryption-codec.test.ts +234 -0
- package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
- package/src/__tests__/history-encryption-e2e.test.ts +243 -0
- package/src/__tests__/preflight.test.ts +50 -2
- package/src/activities/__tests__/call-llm.test.ts +75 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
- package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
- package/src/activities/call-llm.ts +9 -16
- package/src/activities/classify-tool-approvals.ts +34 -4
- package/src/activities/discover-mcp-server.ts +190 -32
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +92 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
- package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
- package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
- package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
- package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
- package/src/activities/execute-cursor/approval-state.ts +30 -1
- package/src/activities/execute-cursor/attachment-resolver.ts +38 -3
- package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
- package/src/activities/execute-cursor/env-resolver.ts +3 -1
- package/src/activities/execute-cursor/error-classifier.ts +91 -4
- package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
- package/src/activities/execute-cursor/hook-script.ts +74 -10
- package/src/activities/execute-cursor/index.ts +63 -72
- package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
- package/src/activities/execute-cursor/prompt-builder.ts +34 -9
- package/src/activities/execute-cursor/turn-stream.ts +5 -2
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +132 -8
- package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -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__/prompt-builder.test.ts +34 -5
- 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 +173 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
- package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
- package/src/activities/execute-deep-agent/environment.ts +3 -1
- package/src/activities/execute-deep-agent/index.ts +20 -0
- package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
- package/src/activities/execute-deep-agent/setup.ts +86 -31
- package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
- package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
- package/src/activities/hydrate-workflow-execution.ts +3 -1
- package/src/activities/workflow-event-activities.ts +96 -69
- package/src/claimcheck/payload-codec.ts +33 -1
- package/src/client/__tests__/stigmer-client.test.ts +8 -8
- package/src/client/stigmer-client.ts +32 -18
- package/src/encryption/config.ts +91 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/payload-codec.ts +152 -0
- package/src/payload-codecs.ts +56 -0
- package/src/preflight.ts +45 -0
- package/src/runner-manager.ts +6 -24
- package/src/runner.ts +6 -25
- package/src/shared/__tests__/approval-policy.test.ts +82 -39
- package/src/shared/__tests__/attachment-naming.test.ts +159 -0
- package/src/shared/__tests__/attachment-vision.test.ts +97 -0
- package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
- package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
- package/src/shared/__tests__/caller-identity.test.ts +25 -0
- package/src/shared/__tests__/channel-attachment.test.ts +1 -1
- package/src/shared/__tests__/connect-backfill.test.ts +1 -0
- package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
- package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
- package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
- package/src/shared/__tests__/foundry-seam.test.ts +482 -0
- package/src/shared/__tests__/http-retry.test.ts +67 -0
- package/src/shared/__tests__/llm-backend.test.ts +616 -0
- package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
- package/src/shared/__tests__/mcp-manager.test.ts +170 -2
- package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
- package/src/shared/__tests__/model-client.test.ts +154 -0
- package/src/shared/__tests__/model-error.test.ts +289 -1
- package/src/shared/__tests__/model-registry.test.ts +71 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
- package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
- package/src/shared/__tests__/vertex-seam.test.ts +295 -0
- package/src/shared/approval-policy.ts +14 -7
- package/src/shared/attachment-naming.ts +78 -0
- package/src/shared/attachment-vision.ts +92 -9
- package/src/shared/caller-identity.ts +40 -5
- package/src/shared/channel-attachment.ts +1 -0
- package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
- package/src/shared/checkpointer/http-saver.ts +117 -9
- package/src/shared/conversation-attachment.ts +1 -0
- package/src/shared/datastore-attachment.ts +106 -11
- package/src/shared/http-retry.ts +50 -0
- package/src/shared/llm-backend.ts +544 -0
- package/src/shared/llm-proxy.ts +15 -0
- package/src/shared/mcp-enabled-tools.ts +105 -0
- package/src/shared/mcp-manager.ts +43 -26
- package/src/shared/mcp-resolver.ts +73 -2
- package/src/shared/model-client.ts +161 -19
- package/src/shared/model-error.ts +222 -4
- package/src/shared/model-registry.ts +50 -2
- package/src/shared/plan-mode-permissions.ts +30 -0
- package/src/worker.ts +4 -5
- package/src/workflow-engine/types.ts +18 -0
- package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
- package/src/workflows/call-agent-orchestrator.ts +10 -0
- package/src/workflows/connect-mcp-server.ts +7 -0
- package/src/workflows/engine-core.ts +23 -2
- package/src/workflows/execute-from-execution.ts +12 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datastore degradation disclosure, activity half (issue #325).
|
|
3
|
+
*
|
|
4
|
+
* The activity appends the operator-facing MESSAGE_SYSTEM notice to
|
|
5
|
+
* `initialStatus.messages` BEFORE streaming (index.ts) — deliberately not
|
|
6
|
+
* through a status builder, because the v2 and v3 stream paths each
|
|
7
|
+
* construct their own builder around that same proto. These tests pin the
|
|
8
|
+
* property that push site depends on: BOTH builders preserve pre-seeded
|
|
9
|
+
* messages (ExecutionState only appends; index rebuilds touch runtime
|
|
10
|
+
* tool-call state, and a MESSAGE_SYSTEM row carries no tool calls), so
|
|
11
|
+
* the notice survives whichever stream version runs and rides every
|
|
12
|
+
* persist. The prompt half (section selection) is pinned in
|
|
13
|
+
* shared/__tests__/datastore-attachment.test.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
17
|
+
import { create } from "@bufbuild/protobuf";
|
|
18
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
19
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
20
|
+
import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
21
|
+
import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
22
|
+
import {
|
|
23
|
+
EXPECTED_RECORD_TOOLS,
|
|
24
|
+
formatDatastoreDegradationNotice,
|
|
25
|
+
} from "../../../shared/datastore-attachment.js";
|
|
26
|
+
import { StatusBuilder } from "../status-builder.js";
|
|
27
|
+
import { V3StatusBuilder } from "../v3-status-builder.js";
|
|
28
|
+
import { normalize } from "../v3-protocol-normalizer.js";
|
|
29
|
+
import {
|
|
30
|
+
resetSeq,
|
|
31
|
+
makeMessageStart,
|
|
32
|
+
makeMessageFinish,
|
|
33
|
+
makeTextDelta,
|
|
34
|
+
} from "../__test-utils__/v3-event-fixtures.js";
|
|
35
|
+
|
|
36
|
+
beforeEach(() => resetSeq());
|
|
37
|
+
|
|
38
|
+
/** initialStatus as index.ts shapes it when setup reports missing tools. */
|
|
39
|
+
function statusWithDegradationNotice(): AgentExecutionStatus {
|
|
40
|
+
const status = create(AgentExecutionStatusSchema, {});
|
|
41
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
42
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
43
|
+
content: formatDatastoreDegradationNotice(1, [...EXPECTED_RECORD_TOOLS]),
|
|
44
|
+
timestamp: "2026-08-11T00:00:00.000Z",
|
|
45
|
+
}));
|
|
46
|
+
return status;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("datastore degradation notice survives streaming (issue #325)", () => {
|
|
50
|
+
it("v2 StatusBuilder preserves the pre-seeded notice through a streamed turn", () => {
|
|
51
|
+
const initialStatus = statusWithDegradationNotice();
|
|
52
|
+
const sb = new StatusBuilder("exec-1", initialStatus);
|
|
53
|
+
|
|
54
|
+
sb.processEvent({
|
|
55
|
+
event: "on_chat_model_stream",
|
|
56
|
+
run_id: "run-1",
|
|
57
|
+
data: { chunk: { content: "I cannot reach the datastore right now." } },
|
|
58
|
+
});
|
|
59
|
+
sb.processEvent({ event: "on_chat_model_end", run_id: "run-1", data: {} });
|
|
60
|
+
|
|
61
|
+
const messages = sb.currentStatus.messages;
|
|
62
|
+
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
63
|
+
expect(messages[0].type).toBe(MessageType.MESSAGE_SYSTEM);
|
|
64
|
+
expect(messages[0].content).toContain("0/5 record tools connected");
|
|
65
|
+
expect(messages[0].content).toContain("declared 1 datastore(s)");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("v3 V3StatusBuilder preserves the pre-seeded notice through a streamed turn", () => {
|
|
69
|
+
const initialStatus = statusWithDegradationNotice();
|
|
70
|
+
const sb = new V3StatusBuilder("exec-1", initialStatus);
|
|
71
|
+
|
|
72
|
+
const protocolEvents = [
|
|
73
|
+
makeMessageStart("run-1"),
|
|
74
|
+
makeTextDelta("run-1", "I cannot reach the datastore right now."),
|
|
75
|
+
makeMessageFinish("run-1"),
|
|
76
|
+
];
|
|
77
|
+
for (const raw of protocolEvents) {
|
|
78
|
+
for (const e of normalize(raw)) {
|
|
79
|
+
sb.processEvent(e);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const messages = sb.currentStatus.messages;
|
|
84
|
+
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
85
|
+
expect(messages[0].type).toBe(MessageType.MESSAGE_SYSTEM);
|
|
86
|
+
expect(messages[0].content).toContain("0/5 record tools connected");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("both builders share the initialStatus proto, so the append needs no builder", () => {
|
|
90
|
+
// The push-site contract itself: mutating initialStatus.messages is
|
|
91
|
+
// visible through whichever builder wraps it.
|
|
92
|
+
const initialStatus = create(AgentExecutionStatusSchema, {});
|
|
93
|
+
const sb = new StatusBuilder("exec-1", initialStatus);
|
|
94
|
+
initialStatus.messages.push(create(AgentMessageSchema, {
|
|
95
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
96
|
+
content: formatDatastoreDegradationNotice(2, ["delete_record"]),
|
|
97
|
+
timestamp: "2026-08-11T00:00:00.000Z",
|
|
98
|
+
}));
|
|
99
|
+
expect(sb.currentStatus.messages).toHaveLength(1);
|
|
100
|
+
expect(sb.currentStatus.messages[0].content).toContain(
|
|
101
|
+
"4/5 record tools connected",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -184,6 +184,7 @@ function fakeHttpRejectSetup(): SetupResult {
|
|
|
184
184
|
session: { spec: { workspaceEntries: [] } },
|
|
185
185
|
workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-http" },
|
|
186
186
|
mcpConnection: null,
|
|
187
|
+
datastoreToolsMissing: [],
|
|
187
188
|
mergedEnvVars: {},
|
|
188
189
|
secretKeys: new Set<string>(),
|
|
189
190
|
modelName: "claude-sonnet",
|
|
@@ -219,6 +220,7 @@ function fakeMemoryRejectSetup(): SetupResult {
|
|
|
219
220
|
session: { spec: { workspaceEntries: [] } },
|
|
220
221
|
workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-mem" },
|
|
221
222
|
mcpConnection: null,
|
|
223
|
+
datastoreToolsMissing: [],
|
|
222
224
|
mergedEnvVars: {},
|
|
223
225
|
secretKeys: new Set<string>(),
|
|
224
226
|
modelName: "claude-sonnet",
|
|
@@ -264,6 +264,7 @@ function fakeDurableResumeSetup(): SetupResult {
|
|
|
264
264
|
// path shared across files would serialize parallel test workers for real.
|
|
265
265
|
workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-hitl-approve-all" },
|
|
266
266
|
mcpConnection: null,
|
|
267
|
+
datastoreToolsMissing: [],
|
|
267
268
|
mergedEnvVars: {},
|
|
268
269
|
secretKeys: new Set<string>(),
|
|
269
270
|
modelName: "claude-sonnet",
|
|
@@ -219,6 +219,7 @@ function fakeDurableResumeSetup(): SetupResult {
|
|
|
219
219
|
// path shared across files would serialize parallel test workers for real.
|
|
220
220
|
workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-hitl-history" },
|
|
221
221
|
mcpConnection: null,
|
|
222
|
+
datastoreToolsMissing: [],
|
|
222
223
|
mergedEnvVars: {},
|
|
223
224
|
secretKeys: new Set<string>(),
|
|
224
225
|
modelName: "claude-sonnet",
|
|
@@ -142,6 +142,10 @@ describe("buildEnhancedSystemPrompt", () => {
|
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
it("includes injected files section with size info", () => {
|
|
145
|
+
// The section renders the injector's own result type (sizeBytes). A local
|
|
146
|
+
// structural twin with a `size` field used to live in the prompt builder,
|
|
147
|
+
// and production wiring silently dropped the size — this pin holds the
|
|
148
|
+
// real field name so that drift class cannot return.
|
|
145
149
|
const prompt = buildEnhancedSystemPrompt({
|
|
146
150
|
instructions: "Test",
|
|
147
151
|
provisionResults: [],
|
|
@@ -150,15 +154,40 @@ describe("buildEnhancedSystemPrompt", () => {
|
|
|
150
154
|
workspaceFileRefs: [],
|
|
151
155
|
workspaceRoot: "/workspace",
|
|
152
156
|
injectedFiles: [
|
|
153
|
-
{ filename: "data.csv", path: ".stigmer/inputs/data.csv",
|
|
154
|
-
{ filename: "notes.md", path: ".stigmer/inputs/notes.md",
|
|
157
|
+
{ filename: "data.csv", path: ".stigmer/inputs/data.csv", sizeBytes: 1024 },
|
|
158
|
+
{ filename: "notes.md", path: ".stigmer/inputs/notes.md", sizeBytes: 12 },
|
|
155
159
|
],
|
|
156
160
|
});
|
|
157
161
|
|
|
158
162
|
expect(prompt).toContain("## Input Files");
|
|
159
163
|
expect(prompt).toContain("`.stigmer/inputs/data.csv` (1024 bytes)");
|
|
160
|
-
expect(prompt).toContain("`.stigmer/inputs/notes.md`");
|
|
161
|
-
|
|
164
|
+
expect(prompt).toContain("`.stigmer/inputs/notes.md` (12 bytes)");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("discloses a duplicate-renamed injected file's original name", () => {
|
|
168
|
+
const prompt = buildEnhancedSystemPrompt({
|
|
169
|
+
instructions: "Test",
|
|
170
|
+
provisionResults: [],
|
|
171
|
+
containerRoot: "",
|
|
172
|
+
skillsPromptSection: "",
|
|
173
|
+
workspaceFileRefs: [],
|
|
174
|
+
workspaceRoot: "/workspace",
|
|
175
|
+
injectedFiles: [
|
|
176
|
+
{ filename: "report.pdf", path: ".stigmer/inputs/report.pdf", sizeBytes: 10 },
|
|
177
|
+
{
|
|
178
|
+
filename: "report-2.pdf",
|
|
179
|
+
path: ".stigmer/inputs/report-2.pdf",
|
|
180
|
+
sizeBytes: 20,
|
|
181
|
+
renamedFrom: "report.pdf",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(prompt).toContain(
|
|
187
|
+
"`.stigmer/inputs/report-2.pdf` (20 bytes) (renamed from duplicate 'report.pdf')",
|
|
188
|
+
);
|
|
189
|
+
// The first file keeps a clean entry — no disclosure noise.
|
|
190
|
+
expect(prompt).toContain("`.stigmer/inputs/report.pdf` (10 bytes)\n");
|
|
162
191
|
});
|
|
163
192
|
|
|
164
193
|
it("produces a git repo description for git workspace entries", () => {
|
|
@@ -361,7 +390,7 @@ describe("buildEnhancedSystemPrompt", () => {
|
|
|
361
390
|
const planFile = {
|
|
362
391
|
filename: "plan.md",
|
|
363
392
|
path: ".stigmer/inputs/plan.md",
|
|
364
|
-
|
|
393
|
+
sizeBytes: 1024,
|
|
365
394
|
};
|
|
366
395
|
|
|
367
396
|
it("appends the implement-plan directive pointing at the injected plan", () => {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end proof (real deepagents + LangGraph runtime, no LLM/network) that a
|
|
3
|
+
* plan-mode SUB-AGENT is read-only BY CONSTRUCTION (issue #255).
|
|
4
|
+
*
|
|
5
|
+
* The parent graph receives PLAN_MODE_PERMISSIONS in setup.ts, but deepagents'
|
|
6
|
+
* parent-permission inheritance covers only spec-style sub-agents — our
|
|
7
|
+
* pre-built CompiledSubAgents bypass `normalizeSubagentSpec` entirely, so
|
|
8
|
+
* `compileSubagents` must bake the rules into each sub-agent graph itself.
|
|
9
|
+
* These tests exercise that exact wiring with NO approval gate installed (the
|
|
10
|
+
* auto-approve-all shape): any denial must come from the permission rules
|
|
11
|
+
* alone, proving plan mode's "read-only by construction, not by gate"
|
|
12
|
+
* contract at the sub-agent level. A contrast case pins that without the
|
|
13
|
+
* rules (act mode) the same write flows — this change is a plan-mode-only
|
|
14
|
+
* delta.
|
|
15
|
+
*
|
|
16
|
+
* The rule under test is the PRODUCTION constant from
|
|
17
|
+
* shared/plan-mode-permissions.ts (the same one setup.ts applies to both
|
|
18
|
+
* graph sites), not a test copy, so a change to the plan-mode permission set
|
|
19
|
+
* re-proves sub-agent coverage automatically.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
23
|
+
import { mkdtemp, mkdir, rm, readFile, writeFile, access } from "node:fs/promises";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
|
|
27
|
+
|
|
28
|
+
import { PLAN_MODE_PERMISSIONS } from "../../../shared/plan-mode-permissions.js";
|
|
29
|
+
import { CasCaptureObserver } from "../cas-capture-observer.js";
|
|
30
|
+
import { compileSubagents } from "../subagent-transformer.js";
|
|
31
|
+
import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
|
|
32
|
+
|
|
33
|
+
const SEEDED_CONTENT = "PLAN_MODE_README_TOKEN: hello from the seeded file";
|
|
34
|
+
|
|
35
|
+
describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
|
|
36
|
+
let root: string;
|
|
37
|
+
let observer: CasCaptureObserver;
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
root = await mkdtemp(join(tmpdir(), "sa-plan-"));
|
|
41
|
+
await mkdir(join(root, "dist"), { recursive: true });
|
|
42
|
+
await writeFile(join(root, "notes.md"), SEEDED_CONTENT);
|
|
43
|
+
// Everything is gitignored from the observer's view — the strictest case:
|
|
44
|
+
// every write would be CAS-observed if it ever reached the backend.
|
|
45
|
+
observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(root, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compile a worker through the production path with NO approval gate, so the
|
|
54
|
+
* permission rules are the only thing standing between the model and the
|
|
55
|
+
* filesystem. `permissions` present = the plan-mode wiring under test;
|
|
56
|
+
* absent = the act-mode contrast.
|
|
57
|
+
*/
|
|
58
|
+
async function compileWorker(script: ScriptSelector, planMode: boolean) {
|
|
59
|
+
const compiled = await compileSubagents(
|
|
60
|
+
[{ name: "worker", description: "test worker", systemPrompt: "do the work", tools: [] }],
|
|
61
|
+
{
|
|
62
|
+
approvalGate: null,
|
|
63
|
+
parentModelName: "test-model",
|
|
64
|
+
workspaceRootDir: root,
|
|
65
|
+
casObserver: observer,
|
|
66
|
+
modelFactory: async () => new ScriptedModel(script),
|
|
67
|
+
...(planMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
expect(compiled).toHaveLength(1);
|
|
71
|
+
return compiled[0];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toolResultById(messages: BaseMessage[], toolCallId: string): string {
|
|
75
|
+
const match = messages.find(
|
|
76
|
+
(m): m is ToolMessage => m instanceof ToolMessage && m.tool_call_id === toolCallId,
|
|
77
|
+
);
|
|
78
|
+
expect(match, `expected a tool result for call '${toolCallId}'`).toBeDefined();
|
|
79
|
+
return typeof match!.content === "string" ? match!.content : JSON.stringify(match!.content);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
it("denies write_file and edit_file at the tool level, while read_file still works", async () => {
|
|
83
|
+
// One turn, three tool calls: both mutation tools must be denied by the
|
|
84
|
+
// permission rules; the read must pass through untouched (the deny rule
|
|
85
|
+
// covers the `write` operation class only).
|
|
86
|
+
// Real absolute workspace paths, as production models use (the prompt's
|
|
87
|
+
// file tree is absolute, and the tools' contract says absolute). With
|
|
88
|
+
// permission rules present this is also the only shape that reaches the
|
|
89
|
+
// rules at all: enforcePermission's canonicalization refuses relative
|
|
90
|
+
// paths with a validation error before any rule or backend runs — a
|
|
91
|
+
// different denial, but still nothing written.
|
|
92
|
+
const script: ScriptSelector = () => ({
|
|
93
|
+
toolCalls: [
|
|
94
|
+
{ name: "write_file", args: { file_path: join(root, "dist/out.txt"), content: "new file" }, id: "c_write" },
|
|
95
|
+
{ name: "edit_file", args: { file_path: join(root, "notes.md"), old_string: "hello", new_string: "HACKED" }, id: "c_edit" },
|
|
96
|
+
{ name: "read_file", args: { file_path: join(root, "notes.md") }, id: "c_read" },
|
|
97
|
+
],
|
|
98
|
+
done: "worker done",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const worker = await compileWorker(script, true);
|
|
102
|
+
const result = (await worker.runnable.invoke(
|
|
103
|
+
{ messages: [new HumanMessage({ content: "go" })] },
|
|
104
|
+
{ configurable: { thread_id: "t1" }, recursionLimit: 50 },
|
|
105
|
+
)) as { messages: BaseMessage[] };
|
|
106
|
+
|
|
107
|
+
// Both mutations denied with deepagents' permission error, and nothing on disk:
|
|
108
|
+
// the new file was never created and the seeded file is byte-identical.
|
|
109
|
+
expect(toolResultById(result.messages, "c_write")).toMatch(/permission denied for write/i);
|
|
110
|
+
expect(toolResultById(result.messages, "c_edit")).toMatch(/permission denied for write/i);
|
|
111
|
+
await expect(access(join(root, "dist/out.txt"))).rejects.toThrow();
|
|
112
|
+
expect(await readFile(join(root, "notes.md"), "utf8")).toBe(SEEDED_CONTENT);
|
|
113
|
+
|
|
114
|
+
// The read flowed: its result carries the seeded content, not a denial.
|
|
115
|
+
const readResult = toolResultById(result.messages, "c_read");
|
|
116
|
+
expect(readResult).toContain("PLAN_MODE_README_TOKEN");
|
|
117
|
+
expect(readResult).not.toMatch(/permission denied/i);
|
|
118
|
+
|
|
119
|
+
// The denial fired INSIDE the tool handler, before any backend was reached:
|
|
120
|
+
// the CAS observer (which records pre-write bytes on the backend's write/edit
|
|
121
|
+
// seams) saw neither mutation.
|
|
122
|
+
expect(observer.before.size).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("act-mode contrast: without the permissions option the same write lands (zero-delta outside plan mode)", async () => {
|
|
126
|
+
const script: ScriptSelector = () => ({
|
|
127
|
+
toolCalls: [
|
|
128
|
+
{ name: "write_file", args: { file_path: join(root, "dist/out.txt"), content: "new file" }, id: "c_write" },
|
|
129
|
+
],
|
|
130
|
+
done: "worker done",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const worker = await compileWorker(script, false);
|
|
134
|
+
const result = (await worker.runnable.invoke(
|
|
135
|
+
{ messages: [new HumanMessage({ content: "go" })] },
|
|
136
|
+
{ configurable: { thread_id: "t2" }, recursionLimit: 50 },
|
|
137
|
+
)) as { messages: BaseMessage[] };
|
|
138
|
+
|
|
139
|
+
expect(toolResultById(result.messages, "c_write")).not.toMatch(/permission denied/i);
|
|
140
|
+
expect(await readFile(join(root, "dist/out.txt"), "utf8")).toBe("new file");
|
|
141
|
+
// And the backend WAS reached this time — the observer recorded a write.
|
|
142
|
+
// (Key shape is DD-19 territory, deliberately not pinned here.)
|
|
143
|
+
expect(observer.before.size).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("relative-path mutations are refused too — by validation, before the rules", async () => {
|
|
147
|
+
// The native prompt steers models to workspace-relative paths, but
|
|
148
|
+
// deepagents' permission canonicalization accepts only absolute ones, so
|
|
149
|
+
// with rules present a relative-path call fails validation before any
|
|
150
|
+
// rule (or backend) runs. Read-only still holds — nothing is written —
|
|
151
|
+
// but the model sees "path must be absolute", not "permission denied",
|
|
152
|
+
// and prompt-compliant relative READS degrade the same way. That
|
|
153
|
+
// relative-vs-absolute friction is a pre-existing parent-plan-mode
|
|
154
|
+
// behavior, tracked as issue #429; this pins that the sub-agent's
|
|
155
|
+
// read-only contract survives it.
|
|
156
|
+
const script: ScriptSelector = () => ({
|
|
157
|
+
toolCalls: [
|
|
158
|
+
{ name: "write_file", args: { file_path: "dist/out.txt", content: "new file" }, id: "c_write_rel" },
|
|
159
|
+
],
|
|
160
|
+
done: "worker done",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const worker = await compileWorker(script, true);
|
|
164
|
+
const result = (await worker.runnable.invoke(
|
|
165
|
+
{ messages: [new HumanMessage({ content: "go" })] },
|
|
166
|
+
{ configurable: { thread_id: "t3" }, recursionLimit: 50 },
|
|
167
|
+
)) as { messages: BaseMessage[] };
|
|
168
|
+
|
|
169
|
+
expect(toolResultById(result.messages, "c_write_rel")).toMatch(/path must be absolute/i);
|
|
170
|
+
await expect(access(join(root, "dist/out.txt"))).rejects.toThrow();
|
|
171
|
+
expect(observer.before.size).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -7,10 +7,11 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
7
7
|
it("returns the correct middleware order without cost cap", () => {
|
|
8
8
|
const stack = buildSubAgentMiddleware();
|
|
9
9
|
|
|
10
|
-
expect(stack).toHaveLength(
|
|
10
|
+
expect(stack).toHaveLength(4);
|
|
11
11
|
expect(stack[0].name).toBe("LoopDetectionMiddleware");
|
|
12
12
|
expect(stack[1].name).toBe("ExecutionBudgetMiddleware");
|
|
13
13
|
expect(stack[2].name).toBe("ToolTruncationMiddleware");
|
|
14
|
+
expect(stack[3].name).toBe("ErrorHintsMiddleware");
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
it("includes cost cap view when parent cost cap is provided", () => {
|
|
@@ -24,8 +25,9 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
24
25
|
|
|
25
26
|
const stack = buildSubAgentMiddleware({ costCap: parentCostCap });
|
|
26
27
|
|
|
27
|
-
expect(stack).toHaveLength(
|
|
28
|
+
expect(stack).toHaveLength(5);
|
|
28
29
|
expect(stack[3].name).toBe("CostCapSubAgentView");
|
|
30
|
+
expect(stack[4].name).toBe("ErrorHintsMiddleware");
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
it("sub-agent cost cap view shares parent state", () => {
|
|
@@ -75,8 +77,8 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
75
77
|
approvalGate: { policies: new Map(), toolServerMap: new Map() },
|
|
76
78
|
});
|
|
77
79
|
|
|
78
|
-
// loop, budget, truncation, approval gate
|
|
79
|
-
expect(stack).toHaveLength(
|
|
80
|
+
// loop, budget, truncation, approval gate, error hints
|
|
81
|
+
expect(stack).toHaveLength(5);
|
|
80
82
|
expect(stack[3].name).toBe("ApprovalGateMiddleware");
|
|
81
83
|
expect(stack[3].wrapToolCall).toBeDefined();
|
|
82
84
|
});
|
|
@@ -84,7 +86,7 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
84
86
|
it("omits the approval gate when approvalGate is null (auto-approve-all parity)", () => {
|
|
85
87
|
const stack = buildSubAgentMiddleware({ approvalGate: null });
|
|
86
88
|
|
|
87
|
-
expect(stack).toHaveLength(
|
|
89
|
+
expect(stack).toHaveLength(4);
|
|
88
90
|
expect(stack.some((m) => m.name === "ApprovalGateMiddleware")).toBe(false);
|
|
89
91
|
});
|
|
90
92
|
|
|
@@ -102,10 +104,13 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
102
104
|
approvalGate: { policies: new Map(), toolServerMap: new Map() },
|
|
103
105
|
});
|
|
104
106
|
|
|
105
|
-
// loop, budget, truncation, approval gate, cost cap view
|
|
106
|
-
|
|
107
|
+
// loop, budget, truncation, approval gate, cost cap view, error hints.
|
|
108
|
+
// Hints AFTER the gate matches the parent nesting: the gate's HITL
|
|
109
|
+
// interrupt stays outside the hints' try/catch (issue #255).
|
|
110
|
+
expect(stack).toHaveLength(6);
|
|
107
111
|
expect(stack[3].name).toBe("ApprovalGateMiddleware");
|
|
108
112
|
expect(stack[4].name).toBe("CostCapSubAgentView");
|
|
113
|
+
expect(stack[5].name).toBe("ErrorHintsMiddleware");
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
// captureIgnored is the structural coupling that makes sub-agent gitignored
|
|
@@ -12,14 +12,22 @@
|
|
|
12
12
|
* Error model: fail-hard. Any attachment failure aborts the entire injection
|
|
13
13
|
* and propagates a descriptive error. Attachments are explicit user inputs —
|
|
14
14
|
* running with partial inputs produces silently incorrect results.
|
|
15
|
+
*
|
|
16
|
+
* Duplicate names are NOT a failure (issue #364): a default-derived mount
|
|
17
|
+
* path that collides is renamed with the platform's `stem-2.ext` semantics
|
|
18
|
+
* (shared/attachment-naming.ts) and the rename is disclosed in the prompt's
|
|
19
|
+
* Input Files section. Only two attachments EXPLICITLY pinning the same
|
|
20
|
+
* `mountPath` still abort — that is a user contradiction no rename can
|
|
21
|
+
* honestly resolve.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
import { readFile } from "node:fs/promises";
|
|
18
25
|
import { createInflateRaw } from "node:zlib";
|
|
19
|
-
import {
|
|
26
|
+
import { posix } from "node:path";
|
|
20
27
|
import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
21
28
|
import type { WorkspaceBackend } from "../../shared/workspace/types.js";
|
|
22
29
|
import type { ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
30
|
+
import { allocateUniqueName } from "../../shared/attachment-naming.js";
|
|
23
31
|
import type {
|
|
24
32
|
VisionBudget,
|
|
25
33
|
VisionDegradedReason,
|
|
@@ -38,9 +46,17 @@ export { MAX_ZIP_FILES, MAX_ZIP_EXTRACTED_SIZE };
|
|
|
38
46
|
// ── Types ────────────────────────────────────────────────────────────
|
|
39
47
|
|
|
40
48
|
export interface InjectedFile {
|
|
49
|
+
/** The final on-disk basename — after any duplicate rename, so it always
|
|
50
|
+
* agrees with {@link path} and with the vision payload's image label. */
|
|
41
51
|
readonly filename: string;
|
|
42
52
|
readonly path: string;
|
|
43
53
|
readonly sizeBytes: number;
|
|
54
|
+
/**
|
|
55
|
+
* The attachment's original filename, present only when a duplicate name
|
|
56
|
+
* was renamed (shared/attachment-naming.ts) — rendered as disclosure in
|
|
57
|
+
* the prompt's Input Files section.
|
|
58
|
+
*/
|
|
59
|
+
readonly renamedFrom?: string;
|
|
44
60
|
/** Present when the attachment was accepted into the turn's vision payload. */
|
|
45
61
|
readonly vision?: VisionImage;
|
|
46
62
|
/**
|
|
@@ -257,7 +273,9 @@ export function validateZipForExtraction(
|
|
|
257
273
|
/**
|
|
258
274
|
* Download, validate, and inject all attachments into the workspace.
|
|
259
275
|
*
|
|
260
|
-
*
|
|
276
|
+
* Resolves all mount paths before any downloads begin: duplicate
|
|
277
|
+
* default-derived names are renamed (never fatal — see module doc), and an
|
|
278
|
+
* explicit-mountPath contradiction throws before any bytes move.
|
|
261
279
|
* On any failure, throws AttachmentInjectionError with an actionable message.
|
|
262
280
|
*/
|
|
263
281
|
export async function injectAttachments(opts: InjectAttachmentsOptions): Promise<InjectedFile[]> {
|
|
@@ -273,17 +291,24 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
273
291
|
|
|
274
292
|
for (const attachment of attachments) {
|
|
275
293
|
const content = await downloadAttachment(attachment, storage, isLocalMode);
|
|
276
|
-
const mountPath = mountPaths.get(attachment)!;
|
|
294
|
+
const { path: mountPath, renamedFrom } = mountPaths.get(attachment)!;
|
|
277
295
|
|
|
278
296
|
if (attachment.extract) {
|
|
279
297
|
const entries = validateZipForExtraction(content, attachment.filename);
|
|
280
298
|
const extracted = await extractZipToWorkspace(
|
|
281
299
|
content, entries, mountPath, backend,
|
|
282
300
|
);
|
|
301
|
+
// A renamed mount DIR is visible through every extracted path; the
|
|
302
|
+
// entries themselves were not renamed, so they carry no renamedFrom
|
|
303
|
+
// (per-file disclosure would misattribute the rename).
|
|
283
304
|
injectedFiles.push(...extracted);
|
|
284
305
|
} else {
|
|
285
306
|
await backend.writeFileBuffer(mountPath, content);
|
|
286
|
-
|
|
307
|
+
// The final basename is the canonical filename: after a duplicate
|
|
308
|
+
// rename the original attachment.filename no longer names the file on
|
|
309
|
+
// disk, and the vision label must match what the prompt lists or the
|
|
310
|
+
// agent sees two images with one indistinguishable name.
|
|
311
|
+
const filename = posix.basename(mountPath);
|
|
287
312
|
// The bytes are already in hand for the workspace write — offer them to
|
|
288
313
|
// the vision budget before they go out of scope (the sniff decides
|
|
289
314
|
// eligibility; the budget owns every size/count rule).
|
|
@@ -292,6 +317,7 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
292
317
|
filename,
|
|
293
318
|
path: mountPath,
|
|
294
319
|
sizeBytes: content.length,
|
|
320
|
+
...(renamedFrom !== undefined ? { renamedFrom } : {}),
|
|
295
321
|
...(vision?.kind === "accepted" ? { vision: vision.image } : {}),
|
|
296
322
|
...(vision?.kind === "degraded" ? { visionDegraded: vision.reason } : {}),
|
|
297
323
|
});
|
|
@@ -307,14 +333,24 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
307
333
|
|
|
308
334
|
// ── Internal Helpers ─────────────────────────────────────────────────
|
|
309
335
|
|
|
336
|
+
interface ResolvedMountPath {
|
|
337
|
+
readonly path: string;
|
|
338
|
+
/** Present when a default-derived name was uniquified (issue #364). */
|
|
339
|
+
readonly renamedFrom?: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
310
342
|
function resolveMountPaths(
|
|
311
343
|
attachments: readonly Attachment[],
|
|
312
|
-
): Map<Attachment,
|
|
313
|
-
const result = new Map<Attachment,
|
|
344
|
+
): Map<Attachment, ResolvedMountPath> {
|
|
345
|
+
const result = new Map<Attachment, ResolvedMountPath>();
|
|
314
346
|
const pathToAttachment = new Map<string, Attachment>();
|
|
315
347
|
|
|
348
|
+
// Pass 1: explicit mount paths claim their exact targets first. Two
|
|
349
|
+
// attachments explicitly pinning the SAME path is a user contradiction no
|
|
350
|
+
// rename can honestly resolve — keep rejecting with the actionable message.
|
|
316
351
|
for (const attachment of attachments) {
|
|
317
|
-
|
|
352
|
+
if (!attachment.mountPath) continue;
|
|
353
|
+
const mountPath = resolveExplicitMountPath(attachment);
|
|
318
354
|
const existing = pathToAttachment.get(mountPath);
|
|
319
355
|
|
|
320
356
|
if (existing) {
|
|
@@ -326,35 +362,63 @@ function resolveMountPaths(
|
|
|
326
362
|
}
|
|
327
363
|
|
|
328
364
|
pathToAttachment.set(mountPath, attachment);
|
|
329
|
-
result.set(attachment, mountPath);
|
|
365
|
+
result.set(attachment, { path: mountPath });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Pass 2: default-derived names (`.stigmer/inputs/{filename}`) uniquify
|
|
369
|
+
// around everything already taken — other defaults AND explicit paths that
|
|
370
|
+
// landed inside the inputs prefix — instead of failing the execution
|
|
371
|
+
// (issue #364). The taken-set is seeded with the basenames the explicit
|
|
372
|
+
// pass claimed directly under the prefix.
|
|
373
|
+
const takenNames = new Set<string>();
|
|
374
|
+
for (const path of pathToAttachment.keys()) {
|
|
375
|
+
if (path.startsWith(`${DEFAULT_INPUTS_PREFIX}/`)) {
|
|
376
|
+
const rest = path.slice(DEFAULT_INPUTS_PREFIX.length + 1);
|
|
377
|
+
if (rest.length > 0 && !rest.includes("/")) takenNames.add(rest);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const attachment of attachments) {
|
|
381
|
+
if (attachment.mountPath) continue;
|
|
382
|
+
const { name, renamedFrom } = allocateUniqueName(
|
|
383
|
+
deriveDefaultFilename(attachment),
|
|
384
|
+
takenNames,
|
|
385
|
+
);
|
|
386
|
+
const path = `${DEFAULT_INPUTS_PREFIX}/${name}`;
|
|
387
|
+
pathToAttachment.set(path, attachment);
|
|
388
|
+
result.set(
|
|
389
|
+
attachment,
|
|
390
|
+
renamedFrom !== undefined ? { path, renamedFrom } : { path },
|
|
391
|
+
);
|
|
330
392
|
}
|
|
331
393
|
|
|
332
394
|
return result;
|
|
333
395
|
}
|
|
334
396
|
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
// A caller-supplied mount path is untrusted. Stripping leading slashes does
|
|
345
|
-
// not stop `..` segments from climbing out of the workspace root on the
|
|
346
|
-
// non-`.stigmer/` branch (the `.stigmer/`-routed branch is already guarded
|
|
347
|
-
// by LocalWorkspaceBackend.resolvePath). Reject any path that normalizes to
|
|
348
|
-
// an escape before it reaches the backend write.
|
|
349
|
-
if (escapesRoot(cleaned)) {
|
|
350
|
-
throw new AttachmentInjectionError(
|
|
351
|
-
attachment.filename,
|
|
352
|
-
`mount path '${attachment.mountPath}' escapes the workspace root`,
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
return cleaned;
|
|
397
|
+
function resolveExplicitMountPath(attachment: Attachment): string {
|
|
398
|
+
const cleaned = attachment.mountPath.replace(/^\/+/, "");
|
|
399
|
+
if (cleaned.length === 0) {
|
|
400
|
+
throw new AttachmentInjectionError(
|
|
401
|
+
attachment.filename,
|
|
402
|
+
"mountPath resolves to an empty path after removing leading slashes",
|
|
403
|
+
);
|
|
356
404
|
}
|
|
405
|
+
// A caller-supplied mount path is untrusted. Stripping leading slashes does
|
|
406
|
+
// not stop `..` segments from climbing out of the workspace root on the
|
|
407
|
+
// non-`.stigmer/` branch (the `.stigmer/`-routed branch is already guarded
|
|
408
|
+
// by LocalWorkspaceBackend.resolvePath). Reject any path that normalizes to
|
|
409
|
+
// an escape before it reaches the backend write.
|
|
410
|
+
if (escapesRoot(cleaned)) {
|
|
411
|
+
throw new AttachmentInjectionError(
|
|
412
|
+
attachment.filename,
|
|
413
|
+
`mount path '${attachment.mountPath}' escapes the workspace root`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return cleaned;
|
|
417
|
+
}
|
|
357
418
|
|
|
419
|
+
// Derives the single-component filename an attachment without an explicit
|
|
420
|
+
// mountPath materializes under (the name that pass 2 uniquifies).
|
|
421
|
+
function deriveDefaultFilename(attachment: Attachment): string {
|
|
358
422
|
const rawName = attachment.filename || deriveFilename(attachment.storageKey);
|
|
359
423
|
if (!rawName) {
|
|
360
424
|
throw new AttachmentInjectionError(
|
|
@@ -373,7 +437,7 @@ function resolveMountPath(attachment: Attachment): string {
|
|
|
373
437
|
);
|
|
374
438
|
}
|
|
375
439
|
|
|
376
|
-
return
|
|
440
|
+
return filename;
|
|
377
441
|
}
|
|
378
442
|
|
|
379
443
|
// escapesRoot reports whether a workspace-relative mount path would climb out
|
|
@@ -30,7 +30,9 @@ export async function resolveEnvironment(
|
|
|
30
30
|
): Promise<EnvironmentResult> {
|
|
31
31
|
// A desktop runner exchanges its bootstrap credential for a token scoped to
|
|
32
32
|
// this execution's session, so cloud's decrypt gate binds the read (#156).
|
|
33
|
-
// No-op for cloud sandbox and OSS runners.
|
|
33
|
+
// No-op for cloud sandbox and OSS runners. A failed exchange throws and
|
|
34
|
+
// fails the activity: the bootstrap credential no longer decrypts
|
|
35
|
+
// (stigmer-cloud#218), so proceeding would resolve redacted placeholders.
|
|
34
36
|
const scopedToken = await client.acquireScopedRunnerToken({
|
|
35
37
|
agentExecutionId: executionId,
|
|
36
38
|
});
|