create-windy 0.8.0 → 0.8.2
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/cli.js +14 -8
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/Dockerfile +1 -1
- package/template/apps/agent-server/README.md +1 -1
- package/template/apps/agent-server/src/southwind_agent_server/asgi.py +8 -0
- package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
- package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
- package/template/apps/agent-server/src/southwind_agent_server/runtime_logging.py +57 -0
- package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +145 -0
- package/template/apps/agent-server/src/southwind_agent_server/telemetry.py +4 -0
- package/template/apps/agent-server/tests/test_run_operational_logging.py +114 -0
- package/template/apps/agent-server/tests/test_structured_logging.py +142 -0
- package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
- package/template/apps/server/src/agent/isolated-execution-guard.test.ts +194 -0
- package/template/apps/server/src/agent/isolated-execution-guard.ts +202 -0
- package/template/apps/server/src/agent/run-definition-support.ts +11 -1
- package/template/apps/server/src/agent/run-facade.ts +7 -11
- package/template/apps/server/src/agent/run-operation-policy.ts +11 -0
- package/template/apps/server/src/agent/workflow-durable-runtime-input.ts +8 -5
- package/template/apps/server/src/jobs/legacy-service-runtime.ts +4 -0
- package/template/apps/server/src/jobs/runtime-logging.test.ts +212 -0
- package/template/apps/server/src/jobs/runtime-logging.ts +58 -0
- package/template/apps/server/src/jobs/worker.ts +54 -12
- package/template/apps/server/src/observability/bootstrap.ts +1 -4
- package/template/apps/server/src/observability/http-observability.ts +2 -3
- package/template/apps/server/src/observability/structured-logger.test.ts +31 -0
- package/template/apps/server/src/observability/structured-logger.ts +27 -0
- package/template/apps/server/src/platform-bootstrap.ts +9 -3
- package/template/apps/server/src/startup-finalization.ts +3 -3
- package/template/apps/server/src/system/built-in-roles.test.ts +23 -0
- package/template/apps/server/src/system/built-in-roles.ts +10 -4
- package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +16 -5
- package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +4 -0
- package/template/docs/architecture/log-governance-implementation.md +96 -0
- package/template/docs/architecture/log-governance-plan.md +39 -0
- package/template/docs/operations/observability.md +36 -4
- package/template/docs/platform/agent-runtime.md +18 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-isolated-execution-policy.test.ts +70 -0
- package/template/packages/modules/src/ai-isolated-execution-policy.ts +76 -0
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- package/template/packages/modules/src/manifest.ts +23 -0
- package/template/packages/modules/src/system-features.ts +11 -10
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +3 -2
- package/template/packages/shared/src/structured-log.test.ts +147 -0
- package/template/packages/shared/src/structured-log.ts +215 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from southwind_agent_server.attempt_context import govern_provider_attempt
|
|
14
|
+
from southwind_agent_server.attempt_context_projection import (
|
|
15
|
+
AttemptContextPreparation,
|
|
16
|
+
governed_envelope,
|
|
17
|
+
)
|
|
18
|
+
from southwind_agent_server.context_message_digest import canonical_json_digest
|
|
19
|
+
from southwind_agent_server.context_version_port import (
|
|
20
|
+
PreparedContext,
|
|
21
|
+
PreparedSource,
|
|
22
|
+
UnavailableContextVersionPort,
|
|
23
|
+
)
|
|
24
|
+
from southwind_agent_server.contracts import JsonValue, ProviderFieldPolicy
|
|
25
|
+
from southwind_agent_server.engine import (
|
|
26
|
+
EngineCompleted,
|
|
27
|
+
EngineRunRequest,
|
|
28
|
+
SafeEngineError,
|
|
29
|
+
ToolCompleted,
|
|
30
|
+
)
|
|
31
|
+
from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
|
|
32
|
+
from southwind_agent_server.tool_host import ToolHostClient
|
|
33
|
+
from tests.openai_provider_test_support import (
|
|
34
|
+
create_provider,
|
|
35
|
+
governance,
|
|
36
|
+
runtime_request,
|
|
37
|
+
sse_response,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
SCHEMA: dict[str, JsonValue] = {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {"total": {"type": "integer"}},
|
|
43
|
+
"required": ["total"],
|
|
44
|
+
"additionalProperties": False,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def structured_request() -> EngineRunRequest:
|
|
49
|
+
request = runtime_request("business.records.list")
|
|
50
|
+
return replace(
|
|
51
|
+
request,
|
|
52
|
+
output_schema_key="business.answer.v1",
|
|
53
|
+
output_schema=SCHEMA,
|
|
54
|
+
provider_data_policy=request.provider_data_policy.model_copy(
|
|
55
|
+
update={
|
|
56
|
+
"envelope_fields": [
|
|
57
|
+
*request.provider_data_policy.envelope_fields,
|
|
58
|
+
ProviderFieldPolicy(
|
|
59
|
+
field="output-schema",
|
|
60
|
+
classification="internal",
|
|
61
|
+
dlp_action="redact",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_tool_payload_uses_schema_guidance_without_mutation_or_duplicate() -> None:
|
|
70
|
+
provider = create_provider(httpx.MockTransport(lambda _: httpx.Response(500)))
|
|
71
|
+
request = runtime_request("business.records.list")
|
|
72
|
+
messages: list[dict[str, JsonValue]] = [{"role": "system", "content": "原指令"}]
|
|
73
|
+
body = provider.request_body(messages, request.allowed_tools, "answer.v1", SCHEMA)
|
|
74
|
+
payload = json.loads(body)
|
|
75
|
+
assert "response_format" not in payload
|
|
76
|
+
assert payload["tools"][0]["function"]["name"] == "business.records.list"
|
|
77
|
+
assert payload["messages"][0]["content"].startswith("原指令\n\n")
|
|
78
|
+
assert (
|
|
79
|
+
json.dumps(SCHEMA, ensure_ascii=False, separators=(",", ":"))
|
|
80
|
+
in payload["messages"][0]["content"]
|
|
81
|
+
)
|
|
82
|
+
assert messages[0]["content"] == "原指令"
|
|
83
|
+
assert (
|
|
84
|
+
provider.request_body(
|
|
85
|
+
payload["messages"], request.allowed_tools, "answer.v1", SCHEMA
|
|
86
|
+
)
|
|
87
|
+
== body
|
|
88
|
+
)
|
|
89
|
+
plain = json.loads(provider.request_body(messages, (), "answer.v1", SCHEMA))
|
|
90
|
+
assert plain["messages"] == messages
|
|
91
|
+
assert plain["response_format"]["json_schema"]["strict"] is True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("final", ['{"total":1}', '{"total":"1"}', "```json\n{}\n```"])
|
|
95
|
+
async def test_real_function_round_keeps_strict_final_schema(final: str) -> None:
|
|
96
|
+
turns = 0
|
|
97
|
+
calls = 0
|
|
98
|
+
|
|
99
|
+
async def provider_handler(request: httpx.Request) -> httpx.Response:
|
|
100
|
+
nonlocal turns
|
|
101
|
+
turns += 1
|
|
102
|
+
body = json.loads(request.content)
|
|
103
|
+
assert "response_format" not in body
|
|
104
|
+
assert '"required":["total"]' in body["messages"][0]["content"]
|
|
105
|
+
if turns == 1:
|
|
106
|
+
return sse_response(
|
|
107
|
+
[
|
|
108
|
+
{
|
|
109
|
+
"choices": [
|
|
110
|
+
{
|
|
111
|
+
"delta": {
|
|
112
|
+
"tool_calls": [
|
|
113
|
+
{
|
|
114
|
+
"index": 0,
|
|
115
|
+
"id": "call_1",
|
|
116
|
+
"function": {
|
|
117
|
+
"name": "business.records.list",
|
|
118
|
+
"arguments": "{}",
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
assert body["messages"][-1]["role"] == "tool"
|
|
129
|
+
return sse_response([{"choices": [{"delta": {"content": final}}]}])
|
|
130
|
+
|
|
131
|
+
async def tool_handler(request: httpx.Request) -> httpx.Response:
|
|
132
|
+
nonlocal calls
|
|
133
|
+
calls += 1
|
|
134
|
+
assert json.loads(request.content)["executionGrant"] == "grant_1"
|
|
135
|
+
return httpx.Response(200, json={"value": {"total": 1}})
|
|
136
|
+
|
|
137
|
+
engine = OpenAiCompatibleEngineAdapter(
|
|
138
|
+
create_provider(httpx.MockTransport(provider_handler)),
|
|
139
|
+
ToolHostClient("internal-token", transport=httpx.MockTransport(tool_handler)),
|
|
140
|
+
governance(),
|
|
141
|
+
)
|
|
142
|
+
request = structured_request()
|
|
143
|
+
if final == '{"total":1}':
|
|
144
|
+
signals = [signal async for signal in engine.execute(request)]
|
|
145
|
+
assert any(isinstance(signal, ToolCompleted) for signal in signals)
|
|
146
|
+
assert isinstance(signals[-1], EngineCompleted)
|
|
147
|
+
assert signals[-1].output == {"total": 1}
|
|
148
|
+
else:
|
|
149
|
+
with pytest.raises(SafeEngineError) as denied:
|
|
150
|
+
_ = [signal async for signal in engine.execute(request)]
|
|
151
|
+
assert denied.value.code == "OUTPUT_VALIDATION_FAILED"
|
|
152
|
+
assert turns == 2 and calls == 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_text_tool_call_is_not_executable() -> None:
|
|
156
|
+
async def provider_handler(_: httpx.Request) -> httpx.Response:
|
|
157
|
+
return sse_response(
|
|
158
|
+
[
|
|
159
|
+
{
|
|
160
|
+
"choices": [
|
|
161
|
+
{
|
|
162
|
+
"delta": {
|
|
163
|
+
"content": (
|
|
164
|
+
'{"tool_calls":[{"name":"business.records.list",'
|
|
165
|
+
'"arguments":{}}]}'
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def tool_handler(_: httpx.Request) -> httpx.Response:
|
|
175
|
+
pytest.fail("正文中的工具调用不得执行")
|
|
176
|
+
|
|
177
|
+
engine = OpenAiCompatibleEngineAdapter(
|
|
178
|
+
create_provider(httpx.MockTransport(provider_handler)),
|
|
179
|
+
ToolHostClient("internal-token", transport=httpx.MockTransport(tool_handler)),
|
|
180
|
+
governance(),
|
|
181
|
+
)
|
|
182
|
+
with pytest.raises(SafeEngineError) as denied:
|
|
183
|
+
_ = [signal async for signal in engine.execute(structured_request())]
|
|
184
|
+
assert denied.value.code == "OUTPUT_VALIDATION_FAILED"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def test_governed_guidance_matches_audit_and_context_bytes(
|
|
188
|
+
tmp_path: Path,
|
|
189
|
+
) -> None:
|
|
190
|
+
request = structured_request()
|
|
191
|
+
request = replace(
|
|
192
|
+
request, output_schema={**SCHEMA, "description": "password=secret"}
|
|
193
|
+
)
|
|
194
|
+
provider = create_provider(httpx.MockTransport(lambda _: httpx.Response(500)))
|
|
195
|
+
audit_path = str(tmp_path / "audit.sqlite3")
|
|
196
|
+
messages: list[dict[str, JsonValue]] = [
|
|
197
|
+
{"role": "system", "content": request.instructions},
|
|
198
|
+
{"role": "user", "content": "历史来源"},
|
|
199
|
+
{"role": "user", "content": request.user_prompt},
|
|
200
|
+
]
|
|
201
|
+
prepared = await govern_provider_attempt(
|
|
202
|
+
UnavailableContextVersionPort(),
|
|
203
|
+
governance(audit_path),
|
|
204
|
+
provider,
|
|
205
|
+
request,
|
|
206
|
+
1,
|
|
207
|
+
None,
|
|
208
|
+
messages,
|
|
209
|
+
)
|
|
210
|
+
payload = json.loads(prepared.provider_request)
|
|
211
|
+
assert payload["messages"] == prepared.governed.messages
|
|
212
|
+
assert "password=secret" not in prepared.provider_request.decode()
|
|
213
|
+
assert "[REDACTED]" in payload["messages"][0]["content"]
|
|
214
|
+
with sqlite3.connect(audit_path) as connection:
|
|
215
|
+
assert connection.execute(
|
|
216
|
+
"select sent_bytes, decision from provider_attempt_audit"
|
|
217
|
+
).fetchone() == (len(prepared.provider_request), "redact")
|
|
218
|
+
source = PreparedSource(
|
|
219
|
+
"business", "session", "source-1", "v1", 1, "a" * 64, {}, "历史来源", "user"
|
|
220
|
+
)
|
|
221
|
+
envelope = governed_envelope(
|
|
222
|
+
AttemptContextPreparation(
|
|
223
|
+
PreparedContext("preparation-1", (source,)), messages, 1
|
|
224
|
+
),
|
|
225
|
+
request.run_id,
|
|
226
|
+
"attempt-1",
|
|
227
|
+
prepared.governed.messages,
|
|
228
|
+
prepared.provider_request,
|
|
229
|
+
provider.config.deployment_key,
|
|
230
|
+
provider.config.model,
|
|
231
|
+
request.prompt_version,
|
|
232
|
+
(),
|
|
233
|
+
)
|
|
234
|
+
entries = cast(list[dict[str, JsonValue]], envelope["messages"])
|
|
235
|
+
assert entries[0]["content"] == payload["messages"][0]["content"]
|
|
236
|
+
assert (
|
|
237
|
+
cast(list[dict[str, JsonValue]], entries[1]["origins"])[0]["sourceId"]
|
|
238
|
+
== "source-1"
|
|
239
|
+
)
|
|
240
|
+
provider_fields = cast(dict[str, JsonValue], envelope["provider"])
|
|
241
|
+
assert (
|
|
242
|
+
provider_fields["requestDigest"]
|
|
243
|
+
== hashlib.sha256(prepared.provider_request).hexdigest()
|
|
244
|
+
)
|
|
245
|
+
assert provider_fields["outputSchemaDigest"] == hashlib.sha256(b"null").hexdigest()
|
|
246
|
+
assert entries[0]["digest"] == canonical_json_digest(payload["messages"][0])
|
|
247
|
+
assert entries[0]["digest"] != canonical_json_digest(cast(JsonValue, messages[0]))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def test_schema_guidance_still_requires_output_schema_authorization() -> None:
|
|
251
|
+
request = structured_request()
|
|
252
|
+
request = replace(
|
|
253
|
+
request,
|
|
254
|
+
provider_data_policy=runtime_request(
|
|
255
|
+
"business.records.list"
|
|
256
|
+
).provider_data_policy,
|
|
257
|
+
)
|
|
258
|
+
with pytest.raises(SafeEngineError) as denied:
|
|
259
|
+
await govern_provider_attempt(
|
|
260
|
+
UnavailableContextVersionPort(),
|
|
261
|
+
governance(),
|
|
262
|
+
create_provider(httpx.MockTransport(lambda _: httpx.Response(500))),
|
|
263
|
+
request,
|
|
264
|
+
1,
|
|
265
|
+
None,
|
|
266
|
+
[{"role": "system", "content": "指令"}],
|
|
267
|
+
)
|
|
268
|
+
assert denied.value.code == "FORBIDDEN"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def test_schema_guidance_counts_toward_input_token_budget() -> None:
|
|
272
|
+
calls = 0
|
|
273
|
+
|
|
274
|
+
async def handler(_: httpx.Request) -> httpx.Response:
|
|
275
|
+
nonlocal calls
|
|
276
|
+
calls += 1
|
|
277
|
+
return sse_response([])
|
|
278
|
+
|
|
279
|
+
provider = create_provider(httpx.MockTransport(handler))
|
|
280
|
+
request = structured_request()
|
|
281
|
+
messages: list[dict[str, JsonValue]] = [
|
|
282
|
+
{"role": "system", "content": request.instructions},
|
|
283
|
+
{"role": "user", "content": request.user_prompt},
|
|
284
|
+
]
|
|
285
|
+
original_bytes = len(provider.request_body(messages, request.allowed_tools))
|
|
286
|
+
assert original_bytes < 1024
|
|
287
|
+
request = replace(
|
|
288
|
+
request,
|
|
289
|
+
output_schema={**SCHEMA, "description": "业务结果字段说明。" * 100},
|
|
290
|
+
limits=request.limits.model_copy(update={"max_input_tokens": original_bytes}),
|
|
291
|
+
)
|
|
292
|
+
engine = OpenAiCompatibleEngineAdapter(
|
|
293
|
+
provider, ToolHostClient("internal-token"), governance()
|
|
294
|
+
)
|
|
295
|
+
with pytest.raises(SafeEngineError) as denied:
|
|
296
|
+
_ = [signal async for signal in engine.execute(request)]
|
|
297
|
+
assert denied.value.code == "BUDGET_EXCEEDED"
|
|
298
|
+
assert calls == 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def test_tool_schema_respects_model_attempt_limit() -> None:
|
|
302
|
+
calls = 0
|
|
303
|
+
|
|
304
|
+
async def handler(_: httpx.Request) -> httpx.Response:
|
|
305
|
+
nonlocal calls
|
|
306
|
+
calls += 1
|
|
307
|
+
return sse_response(
|
|
308
|
+
[
|
|
309
|
+
{
|
|
310
|
+
"choices": [
|
|
311
|
+
{
|
|
312
|
+
"delta": {
|
|
313
|
+
"tool_calls": [
|
|
314
|
+
{
|
|
315
|
+
"index": 0,
|
|
316
|
+
"id": "call_1",
|
|
317
|
+
"function": {
|
|
318
|
+
"name": "business.records.list",
|
|
319
|
+
"arguments": "{}",
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
]
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
request = structured_request()
|
|
331
|
+
request = replace(
|
|
332
|
+
request, limits=request.limits.model_copy(update={"max_model_attempts": 1})
|
|
333
|
+
)
|
|
334
|
+
engine = OpenAiCompatibleEngineAdapter(
|
|
335
|
+
create_provider(httpx.MockTransport(handler)),
|
|
336
|
+
ToolHostClient(
|
|
337
|
+
"internal-token",
|
|
338
|
+
transport=httpx.MockTransport(
|
|
339
|
+
lambda _: httpx.Response(200, json={"value": {"total": 1}})
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
governance(),
|
|
343
|
+
)
|
|
344
|
+
with pytest.raises(SafeEngineError) as denied:
|
|
345
|
+
_ = [signal async for signal in engine.execute(request)]
|
|
346
|
+
assert denied.value.code == "BUDGET_EXCEEDED"
|
|
347
|
+
assert calls == 1
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
3
|
+
import { buildScopeContext } from "../data-access/context.js";
|
|
4
|
+
import { createRequestGuardContext } from "../guards.js";
|
|
5
|
+
import type { InitializedModuleHosts } from "../module-host.js";
|
|
6
|
+
import { createServerRuntime } from "../runtime.js";
|
|
7
|
+
import { IsolatedExecutionGuard } from "./isolated-execution-guard.js";
|
|
8
|
+
import { createTestRunAuthorizationStore } from "./run-authorization.test-support.js";
|
|
9
|
+
import { buildAgentRuntimeRelease } from "./run-release.js";
|
|
10
|
+
|
|
11
|
+
const definition: NonNullable<ModuleManifest["aiOperations"]>[number] = {
|
|
12
|
+
key: "business.answer",
|
|
13
|
+
kind: "agent",
|
|
14
|
+
featureKey: "business.ai",
|
|
15
|
+
permissionKey: "business.ai.execute",
|
|
16
|
+
auditAction: "business.ai.execute",
|
|
17
|
+
inputSchemaKey: "business.input.v1",
|
|
18
|
+
outputSchemaKey: "business.output.v1",
|
|
19
|
+
requiredCapabilities: ["text-generation"],
|
|
20
|
+
allowedToolKeys: [],
|
|
21
|
+
dataClassification: "internal",
|
|
22
|
+
execution: "streaming",
|
|
23
|
+
toolNetworkPolicy: { mode: "forbidden" },
|
|
24
|
+
limits: { maxDurationSeconds: 60, maxInputBytes: 4096, maxOutputBytes: 4096 },
|
|
25
|
+
isolatedExecution: {
|
|
26
|
+
version: 1,
|
|
27
|
+
mode: "isolated",
|
|
28
|
+
languages: ["python"],
|
|
29
|
+
inputArtifactTypes: ["text/plain"],
|
|
30
|
+
network: "forbidden",
|
|
31
|
+
limits: {
|
|
32
|
+
maxWorkspaceBytes: 4096,
|
|
33
|
+
maxFileBytes: 2048,
|
|
34
|
+
maxOutputBytes: 1024,
|
|
35
|
+
maxDurationSeconds: 30,
|
|
36
|
+
maxCpuMillis: 30_000,
|
|
37
|
+
maxMemoryBytes: 134_217_728,
|
|
38
|
+
maxPids: 4,
|
|
39
|
+
maxConcurrentProcesses: 1,
|
|
40
|
+
maxToolCalls: 3,
|
|
41
|
+
maxToolRounds: 2,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const module = {
|
|
47
|
+
name: "business",
|
|
48
|
+
version: "0.1.0",
|
|
49
|
+
features: [
|
|
50
|
+
{
|
|
51
|
+
key: "business.ai",
|
|
52
|
+
enabled: true,
|
|
53
|
+
module: "business",
|
|
54
|
+
label: "AI",
|
|
55
|
+
visible: "visible",
|
|
56
|
+
stage: "stable",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
jsonSchemas: [
|
|
60
|
+
{ key: "business.input.v1", schema: { type: "object" } },
|
|
61
|
+
{ key: "business.output.v1", schema: { type: "object" } },
|
|
62
|
+
],
|
|
63
|
+
tools: [],
|
|
64
|
+
aiOperations: [definition],
|
|
65
|
+
} as unknown as ModuleManifest;
|
|
66
|
+
const hosts = { aiOperationHandlers: [] } as unknown as InitializedModuleHosts;
|
|
67
|
+
|
|
68
|
+
describe("隔离执行逐次准入", () => {
|
|
69
|
+
test("运行时或持久预算端口缺失时拒绝,绝不执行宿主脚本", async () => {
|
|
70
|
+
const fixture = await setup();
|
|
71
|
+
await expect(
|
|
72
|
+
fixture.guard().admit(fixture.invocation),
|
|
73
|
+
).rejects.toMatchObject({
|
|
74
|
+
code: "RUNTIME_UNAVAILABLE",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("当前授权、Run 状态及预算每次复核", async () => {
|
|
79
|
+
const fixture = await setup();
|
|
80
|
+
const calls: unknown[] = [];
|
|
81
|
+
const guard = fixture.guard({
|
|
82
|
+
state: { status: async () => "running" },
|
|
83
|
+
budget: {
|
|
84
|
+
reserve: async (input) => {
|
|
85
|
+
calls.push(input);
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
available: true,
|
|
90
|
+
});
|
|
91
|
+
await expect(guard.admit(fixture.invocation)).resolves.toEqual(
|
|
92
|
+
definition.isolatedExecution!,
|
|
93
|
+
);
|
|
94
|
+
expect(calls).toEqual([
|
|
95
|
+
{
|
|
96
|
+
runId: "run_1",
|
|
97
|
+
callId: "call_1",
|
|
98
|
+
maxToolCalls: 3,
|
|
99
|
+
maxToolRounds: 2,
|
|
100
|
+
maxConcurrentProcesses: 1,
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
await expect(
|
|
104
|
+
guard.admit({ ...fixture.invocation, language: "node" }),
|
|
105
|
+
).rejects.toMatchObject({ code: "EXECUTION_DENIED" });
|
|
106
|
+
|
|
107
|
+
const denied = fixture.guard({
|
|
108
|
+
state: { status: async () => "terminal" },
|
|
109
|
+
budget: {
|
|
110
|
+
reserve: async () => {
|
|
111
|
+
throw new Error("terminal Run must not reserve");
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
available: true,
|
|
115
|
+
});
|
|
116
|
+
await expect(denied.admit(fixture.invocation)).rejects.toMatchObject({
|
|
117
|
+
code: "EXECUTION_DENIED",
|
|
118
|
+
});
|
|
119
|
+
fixture.actor.permissionKeys = [];
|
|
120
|
+
await expect(guard.admit(fixture.invocation)).rejects.toMatchObject({
|
|
121
|
+
code: "EXECUTION_DENIED",
|
|
122
|
+
});
|
|
123
|
+
expect(calls).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
async function setup() {
|
|
128
|
+
const actor = {
|
|
129
|
+
id: "actor-1",
|
|
130
|
+
type: "user" as const,
|
|
131
|
+
name: "测试用户",
|
|
132
|
+
roleCodes: ["user"],
|
|
133
|
+
permissionKeys: ["business.ai.execute"],
|
|
134
|
+
scopeContext: buildScopeContext("actor-1", undefined, [
|
|
135
|
+
{ id: "role-user", code: "user", dataScope: "self", departmentIds: [] },
|
|
136
|
+
]),
|
|
137
|
+
};
|
|
138
|
+
const runtime = createServerRuntime(
|
|
139
|
+
{},
|
|
140
|
+
{
|
|
141
|
+
featureCatalog: module.features,
|
|
142
|
+
licenseResolver: { resolve: () => ({ status: "valid" }) },
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
const context = createRequestGuardContext(
|
|
146
|
+
new Request("http://localhost/api/agent/runs"),
|
|
147
|
+
{
|
|
148
|
+
resolveActor: () => actor,
|
|
149
|
+
resolveLicense: () => ({ status: "valid" }),
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const authorizations = createTestRunAuthorizationStore();
|
|
153
|
+
const grant = await authorizations.issue({
|
|
154
|
+
operationKey: definition.key,
|
|
155
|
+
releaseId: buildAgentRuntimeRelease([module], module, definition, undefined)
|
|
156
|
+
.releaseId,
|
|
157
|
+
idempotencyKey: "isolated-execution-test",
|
|
158
|
+
input: { question: "analyze" },
|
|
159
|
+
authorizationSnapshot: {
|
|
160
|
+
permissionKey: definition.permissionKey,
|
|
161
|
+
featureKey: definition.featureKey,
|
|
162
|
+
allowedToolKeys: [],
|
|
163
|
+
toolNetworkMode: "forbidden",
|
|
164
|
+
},
|
|
165
|
+
context,
|
|
166
|
+
});
|
|
167
|
+
await authorizations.bind(grant, "run_1");
|
|
168
|
+
return {
|
|
169
|
+
actor,
|
|
170
|
+
invocation: {
|
|
171
|
+
executionGrant: grant.token,
|
|
172
|
+
runId: "run_1",
|
|
173
|
+
callId: "call_1",
|
|
174
|
+
language: "python" as const,
|
|
175
|
+
},
|
|
176
|
+
guard: (
|
|
177
|
+
ports: {
|
|
178
|
+
state?: ConstructorParameters<typeof IsolatedExecutionGuard>[5];
|
|
179
|
+
budget?: ConstructorParameters<typeof IsolatedExecutionGuard>[6];
|
|
180
|
+
available?: boolean;
|
|
181
|
+
} = {},
|
|
182
|
+
) =>
|
|
183
|
+
new IsolatedExecutionGuard(
|
|
184
|
+
[module],
|
|
185
|
+
hosts,
|
|
186
|
+
runtime,
|
|
187
|
+
authorizations,
|
|
188
|
+
async () => actor,
|
|
189
|
+
ports.state,
|
|
190
|
+
ports.budget,
|
|
191
|
+
ports.available,
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
}
|