create-windy 0.8.0 → 0.8.1
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 +46 -41
- 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/runtime_logging.py +57 -0
- package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +134 -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 +105 -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 +79 -0
- package/template/docs/architecture/log-governance-plan.md +39 -0
- package/template/docs/operations/observability.md +36 -4
- 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,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import TextIO, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from southwind_agent_server.app import create_app
|
|
14
|
+
from southwind_agent_server.deterministic import DeterministicEngineAdapter
|
|
15
|
+
from southwind_agent_server.runtime_logging import AgentOperationalAttempt, logger
|
|
16
|
+
from southwind_agent_server.service import AgentRunService
|
|
17
|
+
from southwind_agent_server.store import RunRecord
|
|
18
|
+
from southwind_agent_server.structured_logging import AgentStructuredLogHandler
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def operational_log_level() -> Iterator[None]:
|
|
23
|
+
previous = logger.level
|
|
24
|
+
logger.setLevel(logging.INFO)
|
|
25
|
+
try:
|
|
26
|
+
yield
|
|
27
|
+
finally:
|
|
28
|
+
logger.setLevel(previous)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("broken_sink", [False, True])
|
|
32
|
+
async def test_real_run_completion_and_replay_with_operational_logs(
|
|
33
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
34
|
+
broken_sink: bool,
|
|
35
|
+
) -> None:
|
|
36
|
+
class Stream(io.StringIO):
|
|
37
|
+
def write(self, value: str) -> int:
|
|
38
|
+
written = super().write(value)
|
|
39
|
+
if broken_sink:
|
|
40
|
+
raise OSError("sink-secret")
|
|
41
|
+
return written
|
|
42
|
+
|
|
43
|
+
stream = Stream()
|
|
44
|
+
monkeypatch.setattr(logger, "handlers", [AgentStructuredLogHandler(stream)])
|
|
45
|
+
monkeypatch.setattr(logger, "propagate", False)
|
|
46
|
+
app = create_app(DeterministicEngineAdapter(), heartbeat_seconds=0.01)
|
|
47
|
+
service = cast(AgentRunService, app.state.run_service)
|
|
48
|
+
async with httpx.AsyncClient(
|
|
49
|
+
transport=httpx.ASGITransport(app=app),
|
|
50
|
+
base_url="http://agent.test",
|
|
51
|
+
) as client:
|
|
52
|
+
|
|
53
|
+
async def start() -> httpx.Response:
|
|
54
|
+
return await client.post(
|
|
55
|
+
"/api/agent/runs",
|
|
56
|
+
headers={"Idempotency-Key": "operational-replay"},
|
|
57
|
+
json={
|
|
58
|
+
"operationKey": "test.echo",
|
|
59
|
+
"input": {"text": "input-secret"},
|
|
60
|
+
"traceId": "1" * 32,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
response = await start()
|
|
65
|
+
assert response.status_code == 200
|
|
66
|
+
run_id = str(response.json()["runId"])
|
|
67
|
+
await asyncio.gather(*service._tasks)
|
|
68
|
+
replay = await start()
|
|
69
|
+
assert replay.json()["runId"] == run_id
|
|
70
|
+
events = await client.get(f"/api/agent/runs/{run_id}/events")
|
|
71
|
+
result = await client.get(f"/api/agent/runs/{run_id}/result")
|
|
72
|
+
assert result.json()["status"] == "completed"
|
|
73
|
+
assert result.json()["output"] == {"text": "input-secret"}
|
|
74
|
+
assert "run.failed" not in events.text
|
|
75
|
+
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
76
|
+
assert [record["event"] for record in records] == [
|
|
77
|
+
"agent.run.attempt.started",
|
|
78
|
+
"agent.run.attempt.completed",
|
|
79
|
+
]
|
|
80
|
+
assert all(record["runId"] == run_id for record in records)
|
|
81
|
+
assert all(record["traceId"] == "1" * 32 for record in records)
|
|
82
|
+
assert records[-1]["outcome"] == "success"
|
|
83
|
+
assert records[-1]["durationMs"] >= 0
|
|
84
|
+
assert "input-secret" not in stream.getvalue()
|
|
85
|
+
assert "sink-secret" not in stream.getvalue()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("status", ["failed", "cancelled", "running"])
|
|
89
|
+
def test_attempt_terminal_mapping_and_duplicate_finish_are_safe(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
status: str,
|
|
92
|
+
) -> None:
|
|
93
|
+
stream = io.StringIO()
|
|
94
|
+
monkeypatch.setattr(logger, "handlers", [AgentStructuredLogHandler(stream)])
|
|
95
|
+
monkeypatch.setattr(logger, "propagate", False)
|
|
96
|
+
record = RunRecord("run-1", "test.echo", {"secret": "input"}, "key", "digest")
|
|
97
|
+
attempt = AgentOperationalAttempt(record)
|
|
98
|
+
attempt.start()
|
|
99
|
+
attempt.start()
|
|
100
|
+
attempt.finish(status)
|
|
101
|
+
attempt.finish(status)
|
|
102
|
+
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
103
|
+
assert len(records) == 2
|
|
104
|
+
assert records[-1]["outcome"] == "failure"
|
|
105
|
+
expected = status if status in {"failed", "cancelled"} else "interrupted"
|
|
106
|
+
assert records[-1]["errorCode"] == f"AGENT_RUN_{expected.upper()}"
|
|
107
|
+
assert "secret" not in stream.getvalue()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_unknown_info_message_does_not_expand_log_content() -> None:
|
|
111
|
+
stream = io.StringIO()
|
|
112
|
+
handler = AgentStructuredLogHandler(cast(TextIO, stream))
|
|
113
|
+
handler.emit(logging.makeLogRecord({"levelno": logging.INFO, "msg": "secret"}))
|
|
114
|
+
assert stream.getvalue() == ""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from typing import TextIO, cast
|
|
6
|
+
|
|
7
|
+
from southwind_agent_server.structured_logging import (
|
|
8
|
+
MAX_LOG_BYTES,
|
|
9
|
+
AgentStructuredLogHandler,
|
|
10
|
+
agent_log_record,
|
|
11
|
+
configure_agent_logging,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_agent_report_failure_keeps_only_safe_correlation() -> None:
|
|
16
|
+
stream = io.StringIO()
|
|
17
|
+
handler = AgentStructuredLogHandler(stream)
|
|
18
|
+
logger = logging.getLogger("southwind_agent_server.test_structured_logging")
|
|
19
|
+
logger.addHandler(handler)
|
|
20
|
+
logger.setLevel(logging.WARNING)
|
|
21
|
+
logger.propagate = False
|
|
22
|
+
try:
|
|
23
|
+
try:
|
|
24
|
+
raise ValueError("secret-error")
|
|
25
|
+
except ValueError:
|
|
26
|
+
logger.exception(
|
|
27
|
+
"Agent Runtime 报表写入失败",
|
|
28
|
+
extra={
|
|
29
|
+
"run_id": "run-1",
|
|
30
|
+
"report_stage": "run_terminal",
|
|
31
|
+
"request_id": "bad\nrequest-secret",
|
|
32
|
+
"body": "secret-body",
|
|
33
|
+
"authorization": "Bearer secret-token",
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
finally:
|
|
37
|
+
logger.removeHandler(handler)
|
|
38
|
+
line = stream.getvalue().strip()
|
|
39
|
+
parsed = json.loads(line)
|
|
40
|
+
assert parsed["event"] == "agent.report.write.failed"
|
|
41
|
+
assert parsed["errorCode"] == "AGENT_REPORT_WRITE_FAILED"
|
|
42
|
+
assert parsed["runId"] == "run-1"
|
|
43
|
+
assert parsed["stageKey"] == "run_terminal"
|
|
44
|
+
assert parsed["retentionClass"] == "operational"
|
|
45
|
+
assert len(line.encode("utf-8")) <= MAX_LOG_BYTES
|
|
46
|
+
assert all(
|
|
47
|
+
secret not in line
|
|
48
|
+
for secret in (
|
|
49
|
+
"request-secret",
|
|
50
|
+
"secret-body",
|
|
51
|
+
"secret-token",
|
|
52
|
+
"secret-error",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_unknown_message_and_long_fields_are_bounded() -> None:
|
|
58
|
+
record = logging.makeLogRecord(
|
|
59
|
+
{
|
|
60
|
+
"name": "southwind_agent_server.service",
|
|
61
|
+
"levelno": logging.ERROR,
|
|
62
|
+
"msg": "secret-message",
|
|
63
|
+
"run_id": "x" * 500,
|
|
64
|
+
"report_stage": "payload secret",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
safe = agent_log_record(record)
|
|
68
|
+
assert safe["event"] == "agent.runtime.failed"
|
|
69
|
+
assert "runId" not in safe
|
|
70
|
+
assert "stageKey" not in safe
|
|
71
|
+
assert "secret-message" not in json.dumps(safe)
|
|
72
|
+
assert len(json.dumps(safe).encode("utf-8")) <= MAX_LOG_BYTES
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_asgi_entrypoint_installs_handler_without_changing_app() -> None:
|
|
76
|
+
from southwind_agent_server.app import app
|
|
77
|
+
|
|
78
|
+
asgi = import_module("southwind_agent_server.asgi")
|
|
79
|
+
configure_agent_logging()
|
|
80
|
+
handlers = [
|
|
81
|
+
handler
|
|
82
|
+
for handler in logging.getLogger("southwind_agent_server").handlers
|
|
83
|
+
if isinstance(handler, AgentStructuredLogHandler)
|
|
84
|
+
]
|
|
85
|
+
assert asgi.app is app
|
|
86
|
+
assert len(handlers) == 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_stdout_failure_does_not_interrupt_runtime() -> None:
|
|
90
|
+
class BrokenStream:
|
|
91
|
+
def write(self, _value: str) -> None:
|
|
92
|
+
raise OSError("stdout unavailable")
|
|
93
|
+
|
|
94
|
+
def flush(self) -> None:
|
|
95
|
+
raise OSError("stdout unavailable")
|
|
96
|
+
|
|
97
|
+
handler = AgentStructuredLogHandler(cast(TextIO, BrokenStream()))
|
|
98
|
+
record = logging.makeLogRecord(
|
|
99
|
+
{
|
|
100
|
+
"name": "southwind_agent_server.service",
|
|
101
|
+
"levelno": logging.ERROR,
|
|
102
|
+
"msg": "Agent Run 恢复轮询失败,将继续重试",
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
handler.emit(record)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AiIsolatedExecutionPolicyV1,
|
|
3
|
+
ModuleManifest,
|
|
4
|
+
} from "@southwind-ai/modules";
|
|
5
|
+
import { evaluateAccessGuards } from "../access-guards.js";
|
|
6
|
+
import type { RequestActor } from "../guards.js";
|
|
7
|
+
import { findFeature } from "../route-guards.js";
|
|
8
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
9
|
+
import type { InitializedModuleHosts } from "../module-host.js";
|
|
10
|
+
import type { AgentRunAuthorizationStore } from "./run-authorization.js";
|
|
11
|
+
import { buildAgentRuntimeRelease } from "./run-release.js";
|
|
12
|
+
|
|
13
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
14
|
+
|
|
15
|
+
/** Must reserve atomically and durably by Run and call ID; absent storage denies admission. */
|
|
16
|
+
export interface IsolatedExecutionBudgetPort {
|
|
17
|
+
reserve(input: {
|
|
18
|
+
readonly runId: string;
|
|
19
|
+
readonly callId: string;
|
|
20
|
+
readonly maxToolCalls: number;
|
|
21
|
+
readonly maxToolRounds: number;
|
|
22
|
+
readonly maxConcurrentProcesses: number;
|
|
23
|
+
}): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IsolatedExecutionRunStatePort {
|
|
27
|
+
status(runId: string): Promise<"accepted" | "running" | "terminal">;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class IsolatedExecutionAdmissionError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly code:
|
|
33
|
+
| "EXECUTION_DENIED"
|
|
34
|
+
| "BUDGET_EXCEEDED"
|
|
35
|
+
| "RUNTIME_UNAVAILABLE",
|
|
36
|
+
message: string,
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Revalidates the authoritative Run and current governance facts for every process call. */
|
|
43
|
+
export class IsolatedExecutionGuard {
|
|
44
|
+
readonly #operations: Map<
|
|
45
|
+
string,
|
|
46
|
+
{
|
|
47
|
+
definition: NonNullable<ModuleManifest["aiOperations"]>[number];
|
|
48
|
+
releaseId: string;
|
|
49
|
+
}
|
|
50
|
+
>;
|
|
51
|
+
readonly #features: ModuleManifest["features"];
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
modules: readonly ModuleManifest[],
|
|
55
|
+
hosts: InitializedModuleHosts,
|
|
56
|
+
private readonly runtime: ServerRuntime,
|
|
57
|
+
private readonly authorizations: AgentRunAuthorizationStore,
|
|
58
|
+
private readonly resolveActor: (
|
|
59
|
+
actorId: string,
|
|
60
|
+
) => Promise<RequestActor | undefined>,
|
|
61
|
+
private readonly state?: IsolatedExecutionRunStatePort,
|
|
62
|
+
private readonly budget?: IsolatedExecutionBudgetPort,
|
|
63
|
+
private readonly executionRuntimeAvailable = false,
|
|
64
|
+
) {
|
|
65
|
+
this.#features = modules.flatMap((module) => module.features);
|
|
66
|
+
const handlers = new Map(
|
|
67
|
+
hosts.aiOperationHandlers.map(({ registration }) => [
|
|
68
|
+
registration.operationKey,
|
|
69
|
+
registration,
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
this.#operations = new Map(
|
|
73
|
+
modules.flatMap((module) =>
|
|
74
|
+
(module.aiOperations ?? [])
|
|
75
|
+
.filter((definition) => definition.kind === "agent")
|
|
76
|
+
.map(
|
|
77
|
+
(definition) =>
|
|
78
|
+
[
|
|
79
|
+
definition.key,
|
|
80
|
+
{
|
|
81
|
+
definition,
|
|
82
|
+
releaseId: buildAgentRuntimeRelease(
|
|
83
|
+
modules,
|
|
84
|
+
module,
|
|
85
|
+
definition,
|
|
86
|
+
handlers.get(definition.key),
|
|
87
|
+
).releaseId,
|
|
88
|
+
},
|
|
89
|
+
] as const,
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async admit(input: {
|
|
96
|
+
readonly executionGrant: string;
|
|
97
|
+
readonly runId: string;
|
|
98
|
+
readonly callId: string;
|
|
99
|
+
readonly language: "python" | "node";
|
|
100
|
+
}): Promise<AiIsolatedExecutionPolicyV1> {
|
|
101
|
+
if (!input.callId || !input.runId) throw denied("执行调用坐标缺失");
|
|
102
|
+
let binding;
|
|
103
|
+
try {
|
|
104
|
+
binding = await this.authorizations.authorizeTool(
|
|
105
|
+
input.executionGrant,
|
|
106
|
+
input.runId,
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
throw denied("Agent Run 执行授权无效");
|
|
110
|
+
}
|
|
111
|
+
const current = this.#operations.get(binding.operationKey);
|
|
112
|
+
const policy = current?.definition.isolatedExecution;
|
|
113
|
+
if (
|
|
114
|
+
!current ||
|
|
115
|
+
!policy ||
|
|
116
|
+
current.releaseId !== binding.releaseId ||
|
|
117
|
+
!policy.languages.includes(input.language) ||
|
|
118
|
+
binding.authorizationSnapshot.toolNetworkMode !== "forbidden" ||
|
|
119
|
+
binding.authorizationSnapshot.delegation !== undefined ||
|
|
120
|
+
binding.authorizationSnapshot.runtimeBudgetDigest !== undefined ||
|
|
121
|
+
current.definition.featureKey !==
|
|
122
|
+
binding.authorizationSnapshot.featureKey ||
|
|
123
|
+
current.definition.permissionKey !==
|
|
124
|
+
binding.authorizationSnapshot.permissionKey
|
|
125
|
+
) {
|
|
126
|
+
throw denied("Operation 未授权隔离执行或版本已变化");
|
|
127
|
+
}
|
|
128
|
+
let actor: RequestActor | undefined;
|
|
129
|
+
try {
|
|
130
|
+
actor = await this.resolveActor(binding.actorId);
|
|
131
|
+
} catch {
|
|
132
|
+
throw denied("Agent Run Actor 当前不可用");
|
|
133
|
+
}
|
|
134
|
+
if (!actor || actor.id !== binding.actorId || actor.type === "anonymous") {
|
|
135
|
+
throw denied("Agent Run Actor 当前不可用");
|
|
136
|
+
}
|
|
137
|
+
let context: Awaited<
|
|
138
|
+
ReturnType<ServerRuntime["createGuardContextForActor"]>
|
|
139
|
+
>;
|
|
140
|
+
try {
|
|
141
|
+
context = await this.runtime.createGuardContextForActor(
|
|
142
|
+
new Request("http://agent-execution.internal"),
|
|
143
|
+
actor,
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
throw denied("Agent Run 当前授权无法复核");
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
this.authorizations.assertCurrentScope(binding, context);
|
|
150
|
+
} catch {
|
|
151
|
+
throw denied("Agent Run 当前数据范围已变化");
|
|
152
|
+
}
|
|
153
|
+
let access: Awaited<ReturnType<typeof evaluateAccessGuards>>;
|
|
154
|
+
try {
|
|
155
|
+
access = await evaluateAccessGuards(this.runtime, context, {
|
|
156
|
+
permission: { permissionKey: current.definition.permissionKey },
|
|
157
|
+
feature: findFeature(this.#features, current.definition.featureKey),
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
throw denied("Agent Operation 当前授权无法复核");
|
|
161
|
+
}
|
|
162
|
+
if (!access.decision.allowed) throw denied("Agent Operation 当前无权执行");
|
|
163
|
+
if (!this.executionRuntimeAvailable || !this.state || !this.budget) {
|
|
164
|
+
throw new IsolatedExecutionAdmissionError(
|
|
165
|
+
"RUNTIME_UNAVAILABLE",
|
|
166
|
+
"隔离执行 Runtime 或预算存储未装配",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
let status: "accepted" | "running" | "terminal";
|
|
170
|
+
try {
|
|
171
|
+
status = await this.state.status(input.runId);
|
|
172
|
+
} catch {
|
|
173
|
+
throw denied("Agent Run 状态无法复核");
|
|
174
|
+
}
|
|
175
|
+
if (status !== "running") throw denied("Agent Run 当前不可执行");
|
|
176
|
+
let reserved: boolean;
|
|
177
|
+
try {
|
|
178
|
+
reserved = await this.budget.reserve({
|
|
179
|
+
runId: input.runId,
|
|
180
|
+
callId: input.callId,
|
|
181
|
+
maxToolCalls: policy.limits.maxToolCalls,
|
|
182
|
+
maxToolRounds: policy.limits.maxToolRounds,
|
|
183
|
+
maxConcurrentProcesses: policy.limits.maxConcurrentProcesses,
|
|
184
|
+
});
|
|
185
|
+
} catch {
|
|
186
|
+
throw new IsolatedExecutionAdmissionError(
|
|
187
|
+
"RUNTIME_UNAVAILABLE",
|
|
188
|
+
"执行预算无法原子预留",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (!reserved)
|
|
192
|
+
throw new IsolatedExecutionAdmissionError(
|
|
193
|
+
"BUDGET_EXCEEDED",
|
|
194
|
+
"隔离执行预算已耗尽",
|
|
195
|
+
);
|
|
196
|
+
return policy;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function denied(message: string): IsolatedExecutionAdmissionError {
|
|
201
|
+
return new IsolatedExecutionAdmissionError("EXECUTION_DENIED", message);
|
|
202
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
2
2
|
import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
|
|
3
|
-
import
|
|
3
|
+
import { AgentRuntimeError } from "./run-contracts.js";
|
|
4
4
|
import { AgentRunFacadeError } from "./run-errors.js";
|
|
5
5
|
|
|
6
6
|
export function toolDefinitions(
|
|
@@ -56,3 +56,13 @@ export function publicRunPath(
|
|
|
56
56
|
) {
|
|
57
57
|
return `/api/agent/runs/${encodeURIComponent(runId)}/${resource}?operationKey=${encodeURIComponent(operationKey)}`;
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
export function mapAgentRuntimeError(error: unknown) {
|
|
61
|
+
return error instanceof AgentRuntimeError
|
|
62
|
+
? new AgentRunFacadeError(
|
|
63
|
+
error.code,
|
|
64
|
+
error.message,
|
|
65
|
+
runtimeErrorStatus(error.code),
|
|
66
|
+
)
|
|
67
|
+
: error;
|
|
68
|
+
}
|
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
AgentRuntimePort,
|
|
18
18
|
AgentRuntimeStartResponse,
|
|
19
19
|
} from "./run-contracts.js";
|
|
20
|
-
import { AgentRuntimeError } from "./run-contracts.js";
|
|
21
20
|
import {
|
|
22
21
|
rotateVisionArtifacts,
|
|
23
22
|
validateVisionArtifacts,
|
|
@@ -33,7 +32,7 @@ import { attachAgentRun } from "./run-attach.js";
|
|
|
33
32
|
import {
|
|
34
33
|
digest,
|
|
35
34
|
publicRunPath,
|
|
36
|
-
|
|
35
|
+
mapAgentRuntimeError,
|
|
37
36
|
} from "./run-definition-support.js";
|
|
38
37
|
import { AgentRunFacadeError } from "./run-errors.js";
|
|
39
38
|
import { scopedRuntimeIdempotencyKey } from "./idempotency-key.js";
|
|
@@ -63,7 +62,10 @@ import {
|
|
|
63
62
|
resolveAgentRunExecutionPolicy,
|
|
64
63
|
type AgentRunDelegationPolicy,
|
|
65
64
|
} from "./agent-run-delegation-policy.js";
|
|
66
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
assertAgentRuntimeAssembled,
|
|
67
|
+
publicAgentOperationPolicy,
|
|
68
|
+
} from "./run-operation-policy.js";
|
|
67
69
|
export { AgentRunFacadeError } from "./run-errors.js";
|
|
68
70
|
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
69
71
|
export class AgentRunFacade {
|
|
@@ -124,6 +126,7 @@ export class AgentRunFacade {
|
|
|
124
126
|
? { sessionBinding: this.#sessionBindingAuthorization }
|
|
125
127
|
: {}),
|
|
126
128
|
});
|
|
129
|
+
assertAgentRuntimeAssembled(operation);
|
|
127
130
|
const executionPolicy = resolveAgentRunExecutionPolicy(
|
|
128
131
|
operation,
|
|
129
132
|
context,
|
|
@@ -265,14 +268,7 @@ export class AgentRunFacade {
|
|
|
265
268
|
sessionTurn,
|
|
266
269
|
sessionLinked,
|
|
267
270
|
});
|
|
268
|
-
|
|
269
|
-
throw new AgentRunFacadeError(
|
|
270
|
-
error.code,
|
|
271
|
-
error.message,
|
|
272
|
-
runtimeErrorStatus(error.code),
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
throw error;
|
|
271
|
+
throw mapAgentRuntimeError(error);
|
|
276
272
|
}
|
|
277
273
|
}
|
|
278
274
|
async authorizeRun(
|