Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c349ccef5 | ||
|
|
910a16a998 | ||
|
|
60ae6db60c | ||
|
|
e2d0c6aaf3 | ||
|
|
c21a326e81 |
@@ -0,0 +1,3 @@
|
||||
"""Canonical AIOS V2R1 runtime package."""
|
||||
|
||||
__version__ = "2.1.0-rc.1"
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import sys
|
||||
|
||||
from .cell_manager import CellManager
|
||||
from .ingress_service import serve
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -29,8 +30,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def build_serve_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="aios-cell serve")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=18792)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
values = list(sys.argv[1:] if argv is None else argv)
|
||||
service_mode = (
|
||||
bool(values)
|
||||
and values[0] == "serve"
|
||||
and (len(values) == 1 or values[1].startswith("-"))
|
||||
)
|
||||
if service_mode:
|
||||
serve_args = build_serve_parser().parse_args(values[1:])
|
||||
serve(serve_args.host, serve_args.port)
|
||||
return 0
|
||||
args = build_parser().parse_args(values)
|
||||
manager = CellManager(args.registry)
|
||||
if args.command == "create":
|
||||
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Fail-closed loopback HTTP shell for V2R1 ingress processors."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 1024 * 1024
|
||||
MAX_RESPONSE_BYTES = 4096
|
||||
MUTATION_ROUTES = frozenset(
|
||||
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
|
||||
)
|
||||
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
|
||||
|
||||
|
||||
class IngressHTTPServer(ThreadingHTTPServer):
|
||||
"""Threaded loopback server with an optional externally supplied processor."""
|
||||
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
handler: type[BaseHTTPRequestHandler],
|
||||
processor: Processor | None,
|
||||
) -> None:
|
||||
self.processor = processor
|
||||
super().__init__(address, handler)
|
||||
|
||||
|
||||
class IngressRequestHandler(BaseHTTPRequestHandler):
|
||||
"""Bounded JSON transport; application processing is injected later."""
|
||||
|
||||
server: IngressHTTPServer
|
||||
|
||||
def send_error(
|
||||
self,
|
||||
code: int,
|
||||
message: str | None = None,
|
||||
explain: str | None = None,
|
||||
) -> None:
|
||||
"""Replace BaseHTTPRequestHandler HTML errors with bounded JSON."""
|
||||
if code == 501:
|
||||
self._method_not_allowed()
|
||||
return
|
||||
self._send(int(code), {"status": "HTTP_ERROR"})
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _path(self) -> str:
|
||||
return urlsplit(self.path).path
|
||||
|
||||
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||
if type(status) is not int or not 100 <= status <= 599:
|
||||
status = 500
|
||||
payload = {"status": "INTERNAL_ERROR"}
|
||||
body = json.dumps(
|
||||
dict(payload), ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
||||
).encode("utf-8")
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
status = 500
|
||||
body = b'{"status":"INTERNAL_ERROR"}'
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
if getattr(self, "command", None) != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _method_not_allowed(self) -> None:
|
||||
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = self._path()
|
||||
if path == "/health/live":
|
||||
self._send(200, {"status": "LIVE"})
|
||||
elif path == "/health/ready":
|
||||
if self.server.processor is None:
|
||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||
else:
|
||||
self._send(200, {"status": "READY"})
|
||||
elif path in MUTATION_ROUTES:
|
||||
self._method_not_allowed()
|
||||
else:
|
||||
self._send(404, {"status": "NOT_FOUND"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = self._path()
|
||||
if path not in MUTATION_ROUTES:
|
||||
if path in {"/health/live", "/health/ready"}:
|
||||
self._method_not_allowed()
|
||||
else:
|
||||
self._send(404, {"status": "NOT_FOUND"})
|
||||
return
|
||||
processor = self.server.processor
|
||||
if processor is None:
|
||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length < 0 or length > MAX_REQUEST_BYTES:
|
||||
self._send(413, {"status": "REQUEST_TOO_LARGE"})
|
||||
return
|
||||
body = self.rfile.read(length)
|
||||
status, payload = processor(path, body)
|
||||
if (
|
||||
type(status) is not int
|
||||
or not 100 <= status <= 599
|
||||
or not isinstance(payload, Mapping)
|
||||
):
|
||||
raise TypeError("invalid processor response")
|
||||
self._send(status, payload)
|
||||
except Exception:
|
||||
self._send(500, {"status": "INTERNAL_ERROR"})
|
||||
|
||||
do_DELETE = _method_not_allowed
|
||||
do_HEAD = _method_not_allowed
|
||||
do_OPTIONS = _method_not_allowed
|
||||
do_PATCH = _method_not_allowed
|
||||
do_PUT = _method_not_allowed
|
||||
|
||||
|
||||
def build_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 18792,
|
||||
*,
|
||||
processor: Processor | None = None,
|
||||
) -> IngressHTTPServer:
|
||||
"""Build a loopback-only HTTP server without starting it."""
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError as exc:
|
||||
raise ValueError("host must be a loopback IP address") from exc
|
||||
if address.version != 4 or not address.is_loopback:
|
||||
raise ValueError("host must be an IPv4 loopback address")
|
||||
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
|
||||
|
||||
|
||||
def serve(host: str = "127.0.0.1", port: int = 18792) -> None:
|
||||
"""Run the unconfigured loopback shell until interrupted."""
|
||||
server = build_server(host, port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
finally:
|
||||
server.server_close()
|
||||
@@ -0,0 +1,17 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-v2r1-runtime"
|
||||
version = "2.1.0rc1"
|
||||
description = "AIOS V2R1 canonical runtime package"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
aios-cell = "aios_v2r1.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "app"}
|
||||
packages = ["aios_v2r1"]
|
||||
+35
-10
@@ -1,31 +1,48 @@
|
||||
import {
|
||||
classifyRun,
|
||||
decideToolCall,
|
||||
resolveSenderId,
|
||||
runKey,
|
||||
} from "./policy.js";
|
||||
|
||||
const activeRuns = new Map();
|
||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
function prune(now = Date.now()) {
|
||||
function prune(activeRuns, now = Date.now()) {
|
||||
for (const [key, value] of activeRuns.entries()) {
|
||||
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function approvalTimeout(config) {
|
||||
const value = config.approvalTimeoutMs;
|
||||
return Number.isInteger(value) && value >= 1000 && value <= 300000
|
||||
? value
|
||||
: 60000;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "aios-tenant-boundary",
|
||||
name: "AIOS Tenant Boundary",
|
||||
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
|
||||
|
||||
register(api) {
|
||||
const activeRuns = new Map();
|
||||
|
||||
api.on(
|
||||
"before_agent_run",
|
||||
async (event, ctx) => {
|
||||
prune();
|
||||
prune(activeRuns);
|
||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
const trust = classifyRun(ctx, config);
|
||||
const key = runKey(event, ctx);
|
||||
const existing = key ? activeRuns.get(key) : undefined;
|
||||
if (existing && existing.senderId !== trust.senderId) {
|
||||
return {
|
||||
outcome: "block",
|
||||
reason: "run_scope_sender_conflict",
|
||||
message: "This run scope is already bound to a different authenticated sender.",
|
||||
};
|
||||
}
|
||||
if (key) {
|
||||
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
||||
}
|
||||
@@ -49,10 +66,14 @@ export default {
|
||||
api.on(
|
||||
"before_tool_call",
|
||||
async (event, ctx) => {
|
||||
prune();
|
||||
prune(activeRuns);
|
||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
const key = runKey(event, ctx);
|
||||
const trust = key ? activeRuns.get(key) : undefined;
|
||||
let trust = key ? activeRuns.get(key) : undefined;
|
||||
const senderId = resolveSenderId(ctx);
|
||||
if (trust && senderId && trust.senderId !== senderId) {
|
||||
trust = { trusted: false, reason: "run_scope_sender_conflict" };
|
||||
}
|
||||
const decision = decideToolCall(event.toolName, trust, config);
|
||||
|
||||
if (decision.action === "block") {
|
||||
@@ -67,7 +88,7 @@ export default {
|
||||
title: `Approve ${String(event.toolName)}`,
|
||||
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
|
||||
severity: "warning",
|
||||
timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
||||
timeoutMs: approvalTimeout(config),
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
pluginId: "aios-tenant-boundary",
|
||||
},
|
||||
@@ -78,9 +99,13 @@ export default {
|
||||
{ priority: 100 },
|
||||
);
|
||||
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.delete(key);
|
||||
});
|
||||
api.on(
|
||||
"agent_end",
|
||||
async (event, ctx) => {
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.delete(key);
|
||||
},
|
||||
{ priority: 100 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
+22
-2
@@ -66,6 +66,26 @@ export function decideToolCall(toolName, runTrust, config = {}) {
|
||||
}
|
||||
|
||||
export function runKey(event = {}, ctx = {}) {
|
||||
const key = event.runId || ctx.runId;
|
||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
||||
const normalize = (value) => typeof value === "string" && value.trim()
|
||||
? value.trim()
|
||||
: undefined;
|
||||
const sameField = (eventValue, contextValue) => {
|
||||
const fromEvent = normalize(eventValue);
|
||||
const fromContext = normalize(contextValue);
|
||||
return {
|
||||
valid: !(fromEvent && fromContext && fromEvent !== fromContext),
|
||||
value: fromEvent || fromContext,
|
||||
};
|
||||
};
|
||||
const runId = sameField(event.runId, ctx.runId);
|
||||
const agentId = sameField(event.agentId, ctx.agentId);
|
||||
const sessionId = sameField(event.sessionId, ctx.sessionId);
|
||||
const sessionKey = sameField(event.sessionKey, ctx.sessionKey);
|
||||
if (!runId.valid || !agentId.valid || !sessionId.valid || !sessionKey.valid) {
|
||||
return undefined;
|
||||
}
|
||||
const selectedSession = sessionId.value || sessionKey.value;
|
||||
return runId.value && agentId.value && selectedSession
|
||||
? JSON.stringify([runId.value, agentId.value, selectedSession])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "aios-tenant-boundary",
|
||||
"name": "AIOS Tenant Boundary",
|
||||
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
|
||||
"version": "2.1.0-rc1",
|
||||
"version": "2.1.0-rc.1",
|
||||
"entry": "dist/index.js",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
@@ -12,7 +12,14 @@
|
||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true }
|
||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
|
||||
"blockUnknownAgentRuns": { "type": "boolean", "default": false },
|
||||
"approvalTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"default": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import plugin from "../dist/index.js";
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
function registrations(config = {}) {
|
||||
const seen = [];
|
||||
plugin.register({
|
||||
pluginConfig: config,
|
||||
on(name, handler, options) {
|
||||
seen.push({ name, handler, options });
|
||||
},
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
test("package, manifest, and executable test command are release compatible", () => {
|
||||
assert.equal(packageJson.version, "2.1.0-rc.1");
|
||||
assert.equal(manifest.version, "2.1.0-rc.1");
|
||||
assert.equal(packageJson.scripts.test, "node --test test/*.test.js");
|
||||
});
|
||||
|
||||
test("schema declares every runtime-read property and exact B1 bounds", () => {
|
||||
const properties = manifest.configSchema.properties;
|
||||
const runtimeKeys = [
|
||||
"approvalTimeoutMs",
|
||||
"blockUnknownAgentRuns",
|
||||
"blockedSenderIds",
|
||||
"readOnlyTools",
|
||||
"requireApprovalForHumanSideEffects",
|
||||
"requireExplicitTrustedSenders",
|
||||
"trustedSenderIds",
|
||||
];
|
||||
assert.deepEqual(Object.keys(properties).sort(), runtimeKeys);
|
||||
assert.deepEqual(properties.blockUnknownAgentRuns, {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
assert.deepEqual(properties.approvalTimeoutMs, {
|
||||
type: "integer",
|
||||
minimum: 1000,
|
||||
maximum: 300000,
|
||||
default: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
test("all three hooks register exactly once at priority 100", () => {
|
||||
const seen = registrations();
|
||||
assert.deepEqual(
|
||||
seen.map(({ name }) => name).sort(),
|
||||
["agent_end", "before_agent_run", "before_tool_call"],
|
||||
);
|
||||
for (const registration of seen) {
|
||||
assert.deepEqual(registration.options, { priority: 100 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
const [mode, inspectPath] = process.argv.slice(2);
|
||||
assert.ok(["default", "opt-in"].includes(mode), `unsupported mode: ${mode}`);
|
||||
assert.ok(inspectPath, "inspect JSON path is required");
|
||||
|
||||
const data = JSON.parse(await readFile(inspectPath, "utf8"));
|
||||
const plugin = data.plugin;
|
||||
assert.equal(plugin.id, "aios-tenant-boundary");
|
||||
assert.equal(plugin.version, "2.1.0-rc.1");
|
||||
assert.equal(plugin.status, "loaded");
|
||||
assert.equal(plugin.activated, true);
|
||||
assert.equal(await realpath(plugin.rootDir), await realpath(new URL("..", import.meta.url)));
|
||||
assert.equal(await realpath(plugin.source), await realpath(new URL("../dist/index.js", import.meta.url)));
|
||||
assert.equal(data.install.source, "path");
|
||||
assert.equal(await realpath(data.install.installPath), await realpath(plugin.rootDir));
|
||||
|
||||
const hooks = data.typedHooks
|
||||
.map(({ name, priority }) => ({ name, priority }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const expectedNames = mode === "default"
|
||||
? ["before_tool_call"]
|
||||
: ["agent_end", "before_agent_run", "before_tool_call"];
|
||||
assert.deepEqual(hooks.map(({ name }) => name), expectedNames);
|
||||
assert.ok(hooks.every(({ priority }) => priority === 100));
|
||||
|
||||
const diagnostics = data.diagnostics ?? [];
|
||||
assert.equal(
|
||||
diagnostics.filter(({ level, severity }) =>
|
||||
["error", "fatal"].includes(String(level ?? severity).toLowerCase())).length,
|
||||
0,
|
||||
);
|
||||
if (mode === "default") {
|
||||
assert.deepEqual(
|
||||
diagnostics.map(({ level, message }) => ({ level, message })).sort((a, b) => a.message.localeCompare(b.message)),
|
||||
["agent_end", "before_agent_run"].map((hook) => ({
|
||||
level: "warn",
|
||||
message: `typed hook "${hook}" blocked because non-bundled plugins must set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`,
|
||||
})).sort((a, b) => a.message.localeCompare(b.message)),
|
||||
);
|
||||
} else {
|
||||
assert.deepEqual(diagnostics, []);
|
||||
}
|
||||
|
||||
const properties = plugin.configJsonSchema?.properties ?? {};
|
||||
for (const key of ["approvalTimeoutMs", "blockUnknownAgentRuns"]) {
|
||||
assert.ok(Object.hasOwn(properties, key), `missing runtime schema property: ${key}`);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import plugin from "../dist/index.js";
|
||||
import {
|
||||
classifyRun,
|
||||
decideToolCall,
|
||||
resolveSenderId,
|
||||
runKey,
|
||||
} from "../dist/policy.js";
|
||||
|
||||
function registration(config = {}) {
|
||||
const handlers = new Map();
|
||||
plugin.register({
|
||||
pluginConfig: config,
|
||||
on(name, handler) {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
});
|
||||
return {
|
||||
beforeAgentRun: handlers.get("before_agent_run"),
|
||||
beforeToolCall: handlers.get("before_tool_call"),
|
||||
agentEnd: handlers.get("agent_end"),
|
||||
};
|
||||
}
|
||||
|
||||
function agentContext(id, senderId) {
|
||||
return {
|
||||
runId: `run-${id}`,
|
||||
agentId: "agent-main",
|
||||
sessionId: "session-uuid-123",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
...(senderId ? { senderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolContext(id, extra = {}) {
|
||||
return {
|
||||
runId: `run-${id}`,
|
||||
agentId: "agent-main",
|
||||
sessionId: "session-uuid-123",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("sender classification covers missing, blocked, allowlisted, and automation", () => {
|
||||
assert.equal(resolveSenderId({ channelContext: { sender: { id: " nested " } } }), "nested");
|
||||
assert.deepEqual(classifyRun({}, {}), {
|
||||
trusted: false,
|
||||
origin: "system_or_unknown",
|
||||
reason: "sender_id_missing",
|
||||
});
|
||||
assert.equal(
|
||||
classifyRun({ senderId: "blocked" }, { blockedSenderIds: ["blocked"] }).reason,
|
||||
"sender_blocked",
|
||||
);
|
||||
assert.equal(
|
||||
classifyRun(
|
||||
{ senderId: "alice" },
|
||||
{ requireExplicitTrustedSenders: true, trustedSenderIds: ["alice"] },
|
||||
).trusted,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(classifyRun({ jobId: "job-1" }, {}), {
|
||||
trusted: false,
|
||||
origin: "automation",
|
||||
reason: "job_id_present",
|
||||
});
|
||||
});
|
||||
|
||||
test("unknown agent runs default to pass but fail closed when explicitly enabled", async () => {
|
||||
const defaultHandlers = registration();
|
||||
assert.deepEqual(
|
||||
await defaultHandlers.beforeAgentRun({}, agentContext("default")),
|
||||
{ outcome: "pass" },
|
||||
);
|
||||
|
||||
const enforced = registration({ blockUnknownAgentRuns: true });
|
||||
const denied = await enforced.beforeAgentRun({}, agentContext("denied"));
|
||||
assert.equal(denied.outcome, "block");
|
||||
assert.equal(denied.reason, "sender_id_missing");
|
||||
assert.doesNotMatch(denied.reason + denied.message, /undefined|\r|\n/);
|
||||
|
||||
assert.deepEqual(
|
||||
await enforced.beforeAgentRun({}, { ...agentContext("job"), jobId: "job-1" }),
|
||||
{ outcome: "pass" },
|
||||
);
|
||||
});
|
||||
|
||||
test("read-only tools pass without provenance and side effects fail with a stable reason", async () => {
|
||||
assert.deepEqual(decideToolCall("read", undefined, {}), {
|
||||
action: "allow",
|
||||
code: "read_only",
|
||||
});
|
||||
assert.deepEqual(decideToolCall("shell", undefined, {}), {
|
||||
action: "block",
|
||||
code: "run_provenance_missing",
|
||||
});
|
||||
|
||||
const handlers = registration();
|
||||
assert.equal(await handlers.beforeToolCall({ toolName: "read" }, {}), undefined);
|
||||
const blocked = await handlers.beforeToolCall({ toolName: "shell" }, {});
|
||||
assert.equal(blocked.block, true);
|
||||
assert.equal(
|
||||
blocked.blockReason,
|
||||
"AIOS V2R1 origin policy denied tool call: run_provenance_missing",
|
||||
);
|
||||
});
|
||||
|
||||
test("trusted human side effects request approval with exact timeout validation", async () => {
|
||||
for (const [configured, expected] of [
|
||||
[undefined, 60000],
|
||||
[1000, 1000],
|
||||
[300000, 300000],
|
||||
[999, 60000],
|
||||
[300001, 60000],
|
||||
[60000.5, 60000],
|
||||
["1000", 60000],
|
||||
[null, 60000],
|
||||
]) {
|
||||
const config = {
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice"],
|
||||
};
|
||||
if (configured !== undefined) config.approvalTimeoutMs = configured;
|
||||
const handlers = registration(config);
|
||||
const id = `approval-${String(configured)}`;
|
||||
await handlers.beforeAgentRun({}, agentContext(id, "alice"));
|
||||
const result = await handlers.beforeToolCall({ toolName: "shell" }, toolContext(id));
|
||||
assert.equal(result.requireApproval.timeoutMs, expected, String(configured));
|
||||
assert.deepEqual(result.requireApproval.allowedDecisions, ["allow-once", "deny"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("active run provenance is isolated by registration, run, and sender", async () => {
|
||||
const config = {
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice", "bob"],
|
||||
};
|
||||
const first = registration(config);
|
||||
const second = registration(config);
|
||||
await first.beforeAgentRun({}, agentContext("shared", "alice"));
|
||||
|
||||
const otherSender = await first.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
toolContext("shared", { senderId: "bob" }),
|
||||
);
|
||||
assert.equal(otherSender.block, true);
|
||||
assert.match(otherSender.blockReason, /run_scope_sender_conflict$/);
|
||||
|
||||
const otherRegistration = await second.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
toolContext("shared"),
|
||||
);
|
||||
assert.equal(otherRegistration.block, true);
|
||||
assert.match(otherRegistration.blockReason, /run_provenance_missing$/);
|
||||
});
|
||||
|
||||
test("agent_end cleanup removes only the matching shared scope", async () => {
|
||||
const handlers = registration({
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice", "bob"],
|
||||
});
|
||||
await handlers.beforeAgentRun({}, agentContext("alice-scope", "alice"));
|
||||
await handlers.beforeAgentRun({}, agentContext("bob-scope", "bob"));
|
||||
await handlers.agentEnd({}, toolContext("alice-scope"));
|
||||
|
||||
const alice = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
toolContext("alice-scope"),
|
||||
);
|
||||
assert.equal(alice.block, true);
|
||||
|
||||
const bob = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
toolContext("bob-scope"),
|
||||
);
|
||||
assert.ok(bob.requireApproval);
|
||||
});
|
||||
|
||||
test("run provenance expires after the bounded age", async (t) => {
|
||||
const originalNow = Date.now;
|
||||
t.after(() => { Date.now = originalNow; });
|
||||
let now = 1_000_000;
|
||||
Date.now = () => now;
|
||||
const handlers = registration({
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice"],
|
||||
});
|
||||
await handlers.beforeAgentRun({}, agentContext("expiring", "alice"));
|
||||
now += 60 * 60 * 1000 + 1;
|
||||
const result = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
toolContext("expiring"),
|
||||
);
|
||||
assert.equal(result.block, true);
|
||||
assert.match(result.blockReason, /run_provenance_missing$/);
|
||||
});
|
||||
|
||||
test("host-shaped lifecycle keeps distinct sessionId and sessionKey across all hooks", async () => {
|
||||
const handlers = registration({
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice"],
|
||||
});
|
||||
const before = agentContext("host-shape", "alice");
|
||||
const tool = toolContext("host-shape");
|
||||
const end = toolContext("host-shape");
|
||||
for (const ctx of [before, tool, end]) {
|
||||
assert.equal(ctx.sessionId, "session-uuid-123");
|
||||
assert.equal(ctx.sessionKey, "agent:main:weixin:direct:user");
|
||||
assert.equal(ctx.runId, "run-host-shape");
|
||||
assert.equal(ctx.agentId, "agent-main");
|
||||
}
|
||||
|
||||
await handlers.beforeAgentRun({}, before);
|
||||
const result = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
tool,
|
||||
);
|
||||
assert.ok(result.requireApproval);
|
||||
assert.equal(result.block, undefined);
|
||||
await handlers.agentEnd({}, end);
|
||||
const cleaned = await handlers.beforeToolCall({ toolName: "shell" }, tool);
|
||||
assert.equal(cleaned.block, true);
|
||||
assert.match(cleaned.blockReason, /run_provenance_missing$/);
|
||||
});
|
||||
|
||||
test("shared scope collision fails closed without overwriting original sender", async () => {
|
||||
const handlers = registration({
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice", "bob"],
|
||||
});
|
||||
assert.deepEqual(
|
||||
await handlers.beforeAgentRun({}, agentContext("collision", "alice")),
|
||||
{ outcome: "pass" },
|
||||
);
|
||||
const collision = await handlers.beforeAgentRun(
|
||||
{},
|
||||
agentContext("collision", "bob"),
|
||||
);
|
||||
assert.equal(collision.outcome, "block");
|
||||
assert.equal(collision.reason, "run_scope_sender_conflict");
|
||||
assert.doesNotMatch(collision.message, /alice|bob/);
|
||||
|
||||
const original = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
toolContext("collision"),
|
||||
);
|
||||
assert.ok(original.requireApproval);
|
||||
});
|
||||
|
||||
test("shared scope normalization is exact and incomplete scope fails closed", async () => {
|
||||
const bothAliases = runKey({}, {
|
||||
runId: " run-1 ",
|
||||
agentId: " agent-1 ",
|
||||
sessionId: " session-uuid-123 ",
|
||||
sessionKey: " agent:main:weixin:direct:user ",
|
||||
});
|
||||
assert.equal(
|
||||
bothAliases,
|
||||
JSON.stringify(["run-1", "agent-1", "session-uuid-123"]),
|
||||
);
|
||||
|
||||
const fallback = runKey(
|
||||
{ sessionKey: " agent:main:weixin:direct:user " },
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
fallback,
|
||||
JSON.stringify(["run-1", "agent-1", "agent:main:weixin:direct:user"]),
|
||||
);
|
||||
assert.equal(runKey(
|
||||
{ sessionId: "event-session" },
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
sessionId: "context-session",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
},
|
||||
), undefined);
|
||||
assert.equal(runKey({}, {
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
sessionId: "session-uuid-123",
|
||||
}), bothAliases);
|
||||
assert.equal(runKey({}, {
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
}), fallback);
|
||||
assert.equal(runKey({}, {
|
||||
agentId: "agent-1", sessionId: "session-uuid-123",
|
||||
}), undefined);
|
||||
|
||||
const handlers = registration();
|
||||
const missing = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
{ agentId: "agent-1", sessionId: "session-uuid-123" },
|
||||
);
|
||||
assert.equal(missing.block, true);
|
||||
assert.match(missing.blockReason, /run_provenance_missing$/);
|
||||
});
|
||||
|
||||
test("sessionKey fallback requires sessionId absent from both hook contexts", async () => {
|
||||
const handlers = registration({
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice"],
|
||||
});
|
||||
const before = {
|
||||
runId: "run-fallback",
|
||||
agentId: "agent-main",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
senderId: "alice",
|
||||
};
|
||||
const tool = {
|
||||
runId: "run-fallback",
|
||||
agentId: "agent-main",
|
||||
sessionKey: "agent:main:weixin:direct:user",
|
||||
};
|
||||
await handlers.beforeAgentRun({}, before);
|
||||
assert.ok((await handlers.beforeToolCall({ toolName: "shell" }, tool)).requireApproval);
|
||||
|
||||
const mismatchedSelection = await handlers.beforeToolCall(
|
||||
{ toolName: "shell" },
|
||||
{ ...tool, sessionId: "session-uuid-123" },
|
||||
);
|
||||
assert.equal(mismatchedSelection.block, true);
|
||||
assert.match(mismatchedSelection.blockReason, /run_provenance_missing$/);
|
||||
});
|
||||
|
||||
test("same-named event and context sessionId mismatch fails closed", async () => {
|
||||
const handlers = registration({
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["alice"],
|
||||
});
|
||||
await handlers.beforeAgentRun({}, agentContext("event-mismatch", "alice"));
|
||||
const result = await handlers.beforeToolCall(
|
||||
{ toolName: "shell", sessionId: "different-session" },
|
||||
toolContext("event-mismatch"),
|
||||
);
|
||||
assert.equal(result.block, true);
|
||||
assert.match(result.blockReason, /run_provenance_missing$/);
|
||||
});
|
||||
Executable
+652
@@ -0,0 +1,652 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardened WeChat outbound backend for AIOS.
|
||||
|
||||
Only the dedicated UCC adapter is a supported send backend. OpenClaw channel
|
||||
discovery is retained for diagnostics, but generic OpenClaw chat or agent
|
||||
delivery is never promoted to WeChat delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
OPENCLAW_BIN = os.environ.get("AIOS_OPENCLAW_BIN", "openclaw")
|
||||
WECHAT_ACCOUNT_ID = os.environ.get("AIOS_WECHAT_ACCOUNT_ID", "")
|
||||
WECHAT_CHANNEL = os.environ.get("AIOS_WECHAT_CHANNEL", "")
|
||||
SEND_BACKEND = os.environ.get("AIOS_WECHAT_SEND_BACKEND", "")
|
||||
UCC_ADAPTER_URL = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_URL", "http://127.0.0.1:18800")
|
||||
UCC_ADAPTER_TOKEN = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_TOKEN") or os.environ.get(
|
||||
"AIOS_WECHAT_QR_ADAPTER_TOKEN", ""
|
||||
)
|
||||
UCC_SEND_PATH = os.environ.get("AIOS_UCC_WECHAT_SEND_PATH", "/api/internal/wechat/send")
|
||||
_allowed_origins = os.environ.get("AIOS_UCC_WECHAT_ALLOWED_ORIGINS", "").strip()
|
||||
UCC_ADAPTER_ALLOWED_ORIGINS = tuple(
|
||||
origin.strip() for origin in _allowed_origins.split(",") if origin.strip()
|
||||
) or (
|
||||
"http://127.0.0.1:18800",
|
||||
"http://[::1]:18800",
|
||||
)
|
||||
|
||||
MAX_TARGET_BYTES = 512
|
||||
MAX_CONTENT_BYTES = 32 * 1024
|
||||
MAX_IDENTIFIER_BYTES = 512
|
||||
MAX_METADATA_BYTES = 8 * 1024
|
||||
MAX_METADATA_DEPTH = 32
|
||||
MAX_METADATA_NODES = 1024
|
||||
MAX_RESPONSE_BYTES = 64 * 1024
|
||||
MAX_ENDPOINT_BYTES = 256
|
||||
_DEFAULT_TIMEOUT_SECONDS = 10
|
||||
_MAX_TIMEOUT_SECONDS = 30
|
||||
_LEGACY_SEND_PATH = "/internal/channels/wechat/send"
|
||||
_SAFE_PATH = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$")
|
||||
_MAX_PATH_DECODE_ROUNDS = 4
|
||||
_OPENCLAW_TIMEOUT_RETURN_CODE = 124
|
||||
_OPENCLAW_OUTPUT_LIMIT_RETURN_CODE = 125
|
||||
_OPENCLAW_SPAWN_ERROR_RETURN_CODE = 126
|
||||
|
||||
|
||||
def _bounded_timeout(value: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return _DEFAULT_TIMEOUT_SECONDS
|
||||
return min(max(parsed, 1), _MAX_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = _bounded_timeout(os.environ.get("AIOS_UCC_WECHAT_TIMEOUT_SECONDS", "10"))
|
||||
|
||||
|
||||
class BackendResult:
|
||||
def __init__(self, ok: bool, backend: str, status: str, data: Dict[str, Any]) -> None:
|
||||
self.ok = ok
|
||||
self.backend = backend
|
||||
self.status = status
|
||||
self.data = data
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"ok": self.ok, "backend": self.backend, "status": self.status, **self.data}
|
||||
|
||||
|
||||
class _BackendProtocolError(Exception):
|
||||
"""Internal marker whose details must never cross the module boundary."""
|
||||
|
||||
|
||||
def _openclaw_base_args() -> List[str]:
|
||||
"""Return the OpenClaw 2026.6.11-compatible command prefix.
|
||||
|
||||
Gateway/auth global flags are not supported by the installed CLI and would
|
||||
also expose credentials through the process argument vector.
|
||||
"""
|
||||
|
||||
return [OPENCLAW_BIN]
|
||||
|
||||
|
||||
def _terminate_process_tree(process: Any) -> None:
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=0.5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=0.5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return
|
||||
if process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=1)
|
||||
return
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
def _run_openclaw(
|
||||
args: List[str], timeout: int = 15
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
bounded_timeout = min(max(int(timeout), 1), _MAX_TIMEOUT_SECONDS)
|
||||
popen_options: Dict[str, Any] = {
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.PIPE,
|
||||
}
|
||||
if os.name == "posix":
|
||||
popen_options["start_new_session"] = True
|
||||
elif hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||
popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
try:
|
||||
process = subprocess.Popen(args, **popen_options)
|
||||
except (OSError, ValueError):
|
||||
return subprocess.CompletedProcess(args, _OPENCLAW_SPAWN_ERROR_RETURN_CODE, "", "")
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
streams = {"stdout": process.stdout, "stderr": process.stderr}
|
||||
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
||||
try:
|
||||
for name, stream in streams.items():
|
||||
if stream is not None:
|
||||
selector.register(stream, selectors.EVENT_READ, name)
|
||||
except (OSError, ValueError):
|
||||
selector.close()
|
||||
_terminate_process_tree(process)
|
||||
for stream in streams.values():
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
return subprocess.CompletedProcess(args, _OPENCLAW_SPAWN_ERROR_RETURN_CODE, "", "")
|
||||
|
||||
deadline = time.monotonic() + bounded_timeout
|
||||
total_bytes = 0
|
||||
return_code: Optional[int] = None
|
||||
try:
|
||||
while selector.get_map():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return_code = _OPENCLAW_TIMEOUT_RETURN_CODE
|
||||
break
|
||||
events = selector.select(min(remaining, 0.1))
|
||||
for key, _mask in events:
|
||||
allowance = MAX_RESPONSE_BYTES - total_bytes
|
||||
try:
|
||||
chunk = os.read(key.fileobj.fileno(), min(8192, allowance + 1))
|
||||
except OSError:
|
||||
return_code = _OPENCLAW_SPAWN_ERROR_RETURN_CODE
|
||||
break
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
key.fileobj.close()
|
||||
continue
|
||||
if len(chunk) > allowance:
|
||||
return_code = _OPENCLAW_OUTPUT_LIMIT_RETURN_CODE
|
||||
break
|
||||
buffers[key.data].extend(chunk)
|
||||
total_bytes += len(chunk)
|
||||
if return_code is not None:
|
||||
break
|
||||
finally:
|
||||
selector.close()
|
||||
|
||||
if return_code is not None:
|
||||
_terminate_process_tree(process)
|
||||
for stream in streams.values():
|
||||
if stream is not None and not stream.closed:
|
||||
stream.close()
|
||||
return subprocess.CompletedProcess(args, return_code, "", "")
|
||||
try:
|
||||
completed_return_code = process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
_terminate_process_tree(process)
|
||||
return subprocess.CompletedProcess(args, _OPENCLAW_TIMEOUT_RETURN_CODE, "", "")
|
||||
stdout = buffers["stdout"].decode("utf-8", errors="replace")
|
||||
stderr = buffers["stderr"].decode("utf-8", errors="replace")
|
||||
return subprocess.CompletedProcess(args, completed_return_code, stdout, stderr)
|
||||
|
||||
|
||||
def _json_dict(text: str) -> Dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(text or "{}")
|
||||
except (RecursionError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def discover_openclaw_wechat_channel() -> Optional[str]:
|
||||
"""Discover an explicitly typed WeChat/Weixin channel for diagnostics."""
|
||||
|
||||
if WECHAT_CHANNEL:
|
||||
return WECHAT_CHANNEL
|
||||
try:
|
||||
proc = _run_openclaw(_openclaw_base_args() + ["channels", "list", "--json"], timeout=15)
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
data = _json_dict(proc.stdout[:MAX_RESPONSE_BYTES])
|
||||
candidates: List[str] = []
|
||||
|
||||
def walk(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
name = value.get("name") or value.get("channel") or value.get("id")
|
||||
kind = value.get("type") or value.get("kind")
|
||||
if isinstance(name, str) and isinstance(kind, str):
|
||||
label = f"{name} {kind}".lower()
|
||||
if "wechat" in label or "weixin" in label:
|
||||
candidates.append(name)
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
|
||||
walk(data)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def _auth_headers() -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if UCC_ADAPTER_TOKEN:
|
||||
headers["Authorization"] = "Bearer " + UCC_ADAPTER_TOKEN
|
||||
return headers
|
||||
|
||||
|
||||
def _safe_path(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
path = value if value.startswith("/") else "/" + value
|
||||
try:
|
||||
encoded_path = path.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
return None
|
||||
if len(encoded_path) > MAX_ENDPOINT_BYTES:
|
||||
return None
|
||||
if not _SAFE_PATH.fullmatch(path):
|
||||
return None
|
||||
decoded = path
|
||||
for _ in range(_MAX_PATH_DECODE_ROUNDS):
|
||||
try:
|
||||
next_decoded = urllib.parse.unquote_to_bytes(decoded).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if next_decoded == decoded:
|
||||
break
|
||||
decoded = next_decoded
|
||||
if "%" in decoded or "\\" in decoded or "\x00" in decoded:
|
||||
return None
|
||||
if not _SAFE_PATH.fullmatch(decoded):
|
||||
return None
|
||||
if any(segment in {".", ".."} for segment in decoded.split("/")):
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _unique_paths(paths: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for candidate in paths:
|
||||
path = _safe_path(candidate)
|
||||
if path and path not in out:
|
||||
out.append(path)
|
||||
if len(out) == 2:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _ucc_send_paths() -> List[str]:
|
||||
canonical = _safe_path(UCC_SEND_PATH) or "/api/internal/wechat/send"
|
||||
return _unique_paths([canonical, _LEGACY_SEND_PATH])
|
||||
|
||||
|
||||
def _canonical_origin(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str) or not value or len(value) > 2048:
|
||||
return None
|
||||
if "\\" in value or any(ord(character) < 32 for character in value):
|
||||
return None
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
port = parsed.port
|
||||
except (UnicodeError, ValueError):
|
||||
return None
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in {"http", "https"} or not parsed.hostname:
|
||||
return None
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
|
||||
return None
|
||||
try:
|
||||
host = parsed.hostname.rstrip(".").encode("idna").decode("ascii").lower()
|
||||
except UnicodeError:
|
||||
return None
|
||||
if not host:
|
||||
return None
|
||||
if port is None:
|
||||
port = 80 if scheme == "http" else 443
|
||||
rendered_host = f"[{host}]" if ":" in host else host
|
||||
return f"{scheme}://{rendered_host}:{port}"
|
||||
|
||||
|
||||
def _adapter_base_url() -> Optional[str]:
|
||||
origin = _canonical_origin(UCC_ADAPTER_URL)
|
||||
allowed = {
|
||||
canonical
|
||||
for canonical in (_canonical_origin(value) for value in UCC_ADAPTER_ALLOWED_ORIGINS)
|
||||
if canonical
|
||||
}
|
||||
return origin if origin and origin in allowed else None
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(
|
||||
self,
|
||||
req: Any,
|
||||
fp: Any,
|
||||
code: int,
|
||||
msg: str,
|
||||
headers: Any,
|
||||
newurl: str,
|
||||
) -> None:
|
||||
del req, fp, code, msg, headers, newurl
|
||||
return None
|
||||
|
||||
|
||||
def _open_no_redirect(request: urllib.request.Request, timeout: int) -> Any:
|
||||
opener = urllib.request.build_opener(_NoRedirectHandler())
|
||||
return opener.open(request, timeout=timeout)
|
||||
|
||||
|
||||
def ucc_adapter_alive() -> bool:
|
||||
base_url = _adapter_base_url()
|
||||
if not base_url:
|
||||
return False
|
||||
request = urllib.request.Request(
|
||||
base_url + "/health", headers=_auth_headers(), method="GET"
|
||||
)
|
||||
try:
|
||||
with _open_no_redirect(request, timeout=min(5, REQUEST_TIMEOUT_SECONDS)) as response:
|
||||
return 200 <= int(response.status) < 300
|
||||
except (OSError, ValueError, urllib.error.URLError):
|
||||
return False
|
||||
|
||||
|
||||
def select_backend() -> Dict[str, Any]:
|
||||
explicit = SEND_BACKEND.strip().lower()
|
||||
if explicit:
|
||||
if explicit == "ucc-adapter":
|
||||
return {"backend": "ucc-adapter", "reason": "explicit"}
|
||||
return {"backend": "unavailable", "reason": "unsupported_backend"}
|
||||
if ucc_adapter_alive():
|
||||
return {"backend": "ucc-adapter", "reason": "ucc_adapter_alive"}
|
||||
return {"backend": "unavailable", "reason": "ucc_adapter_unavailable"}
|
||||
|
||||
|
||||
def _post_ucc(path: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]:
|
||||
base_url = _adapter_base_url()
|
||||
safe_path = _safe_path(path)
|
||||
if not base_url or not safe_path:
|
||||
raise _BackendProtocolError("invalid adapter endpoint")
|
||||
try:
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise _BackendProtocolError("invalid request payload") from exc
|
||||
request = urllib.request.Request(
|
||||
base_url + safe_path,
|
||||
data=body,
|
||||
headers=_auth_headers(),
|
||||
method="POST",
|
||||
)
|
||||
with _open_no_redirect(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(raw) > MAX_RESPONSE_BYTES:
|
||||
raise _BackendProtocolError("adapter response too large")
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _BackendProtocolError("adapter response encoding invalid") from exc
|
||||
data = _json_dict(text)
|
||||
if not data:
|
||||
raise _BackendProtocolError("adapter response invalid")
|
||||
return int(response.status), data
|
||||
|
||||
|
||||
def _ucc_response_sent(data: Dict[str, Any]) -> bool:
|
||||
return isinstance(data, dict) and data.get("ok") is True and data.get("status") == "sent"
|
||||
|
||||
|
||||
def _ucc_result_is_ok(data: Dict[str, Any], dry_run: bool = False) -> bool:
|
||||
expected_status = "dry_run" if dry_run else "sent"
|
||||
return isinstance(data, dict) and data.get("ok") is True and data.get("status") == expected_status
|
||||
|
||||
|
||||
def _utf8_size(value: str) -> Optional[int]:
|
||||
try:
|
||||
return len(value.encode("utf-8"))
|
||||
except UnicodeEncodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _valid_identifier(value: Any, required: bool = False) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if required and not value.strip():
|
||||
return False
|
||||
if not value:
|
||||
return not required
|
||||
if len(value) > MAX_IDENTIFIER_BYTES:
|
||||
return False
|
||||
size = _utf8_size(value)
|
||||
return size is not None and size <= MAX_IDENTIFIER_BYTES and not any(
|
||||
ord(character) < 32 or ord(character) == 127 for character in value
|
||||
)
|
||||
|
||||
|
||||
def _validate_metadata(metadata: Any) -> Optional[str]:
|
||||
if not isinstance(metadata, dict):
|
||||
return "invalid_input"
|
||||
nodes = 0
|
||||
stack: List[Tuple[Any, int]] = [(metadata, 0)]
|
||||
while stack:
|
||||
value, depth = stack.pop()
|
||||
nodes += 1
|
||||
if depth > MAX_METADATA_DEPTH or nodes > MAX_METADATA_NODES:
|
||||
return "input_too_large"
|
||||
if isinstance(value, dict):
|
||||
if len(value) + nodes + len(stack) > MAX_METADATA_NODES:
|
||||
return "input_too_large"
|
||||
for key, child in value.items():
|
||||
if (
|
||||
not isinstance(key, str)
|
||||
or len(key) > MAX_METADATA_BYTES
|
||||
or _utf8_size(key) is None
|
||||
):
|
||||
return "invalid_input"
|
||||
stack.append((child, depth + 1))
|
||||
elif isinstance(value, list):
|
||||
if len(value) + nodes + len(stack) > MAX_METADATA_NODES:
|
||||
return "input_too_large"
|
||||
stack.extend((child, depth + 1) for child in value)
|
||||
elif isinstance(value, str):
|
||||
if len(value) > MAX_METADATA_BYTES:
|
||||
return "input_too_large"
|
||||
if _utf8_size(value) is None:
|
||||
return "invalid_input"
|
||||
elif value is not None and not isinstance(value, (bool, int, float)):
|
||||
return "invalid_input"
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
metadata,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
except (RecursionError, TypeError, UnicodeEncodeError, ValueError):
|
||||
return "invalid_input"
|
||||
return "input_too_large" if len(encoded) > MAX_METADATA_BYTES else None
|
||||
|
||||
|
||||
def _validate_payload(
|
||||
target: Any,
|
||||
content: Any,
|
||||
account_id: Any,
|
||||
message_id: Any,
|
||||
metadata: Any,
|
||||
dry_run: Any,
|
||||
) -> Optional[str]:
|
||||
if not isinstance(target, str) or not isinstance(content, str):
|
||||
return "invalid_input"
|
||||
if not target.strip() or not content.strip() or not isinstance(dry_run, bool):
|
||||
return "invalid_input"
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in target):
|
||||
return "invalid_input"
|
||||
if "\x00" in content or not _valid_identifier(account_id) or not _valid_identifier(message_id):
|
||||
return "invalid_input"
|
||||
if len(target) > MAX_TARGET_BYTES or len(content) > MAX_CONTENT_BYTES:
|
||||
return "input_too_large"
|
||||
target_size = _utf8_size(target)
|
||||
content_size = _utf8_size(content)
|
||||
if target_size is None or content_size is None:
|
||||
return "invalid_input"
|
||||
if target_size > MAX_TARGET_BYTES or content_size > MAX_CONTENT_BYTES:
|
||||
return "input_too_large"
|
||||
return _validate_metadata(metadata)
|
||||
|
||||
|
||||
def _safe_provider_message_id(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if len(value) > MAX_IDENTIFIER_BYTES:
|
||||
return None
|
||||
size = _utf8_size(value)
|
||||
if size is None or size > MAX_IDENTIFIER_BYTES:
|
||||
return None
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def send_ucc_adapter(
|
||||
target: str,
|
||||
content: str,
|
||||
account_id: str = "",
|
||||
message_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False,
|
||||
) -> BackendResult:
|
||||
effective_account = account_id or WECHAT_ACCOUNT_ID
|
||||
effective_metadata: Any = {} if metadata is None else metadata
|
||||
error_code = _validate_payload(
|
||||
target,
|
||||
content,
|
||||
effective_account,
|
||||
message_id,
|
||||
effective_metadata,
|
||||
dry_run,
|
||||
)
|
||||
if error_code:
|
||||
return BackendResult(False, "ucc-adapter", "failed", {"code": error_code})
|
||||
|
||||
payload = {
|
||||
"target": target,
|
||||
"content": content,
|
||||
"account_id": effective_account,
|
||||
"message_id": message_id,
|
||||
"metadata": effective_metadata,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
last_endpoint: Optional[str] = None
|
||||
for path in _ucc_send_paths():
|
||||
last_endpoint = path
|
||||
try:
|
||||
http_status, data = _post_ucc(path, payload)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in {404, 405}:
|
||||
continue
|
||||
return BackendResult(
|
||||
False,
|
||||
"ucc-adapter",
|
||||
"failed",
|
||||
{"code": "ucc_adapter_http_error", "http_status": int(exc.code), "endpoint": path},
|
||||
)
|
||||
except (OSError, ValueError, urllib.error.URLError, _BackendProtocolError):
|
||||
return BackendResult(
|
||||
False,
|
||||
"ucc-adapter",
|
||||
"failed",
|
||||
{"code": "ucc_adapter_unavailable", "endpoint": path},
|
||||
)
|
||||
except Exception:
|
||||
# Fail closed without returning exception text, type, or chained cause.
|
||||
return BackendResult(
|
||||
False,
|
||||
"ucc-adapter",
|
||||
"failed",
|
||||
{"code": "ucc_adapter_unavailable", "endpoint": path},
|
||||
)
|
||||
|
||||
if not (200 <= http_status < 300):
|
||||
return BackendResult(
|
||||
False,
|
||||
"ucc-adapter",
|
||||
"failed",
|
||||
{"code": "ucc_adapter_http_error", "http_status": http_status, "endpoint": path},
|
||||
)
|
||||
if not _ucc_result_is_ok(data, dry_run):
|
||||
return BackendResult(
|
||||
False,
|
||||
"ucc-adapter",
|
||||
"failed",
|
||||
{"code": "ucc_adapter_rejected", "http_status": http_status, "endpoint": path},
|
||||
)
|
||||
confirmed_status = "dry_run" if dry_run else "sent"
|
||||
result_data: Dict[str, Any] = {
|
||||
"code": confirmed_status,
|
||||
"http_status": http_status,
|
||||
"endpoint": path,
|
||||
}
|
||||
provider_message_id = _safe_provider_message_id(data.get("provider_message_id"))
|
||||
if provider_message_id:
|
||||
result_data["provider_message_id"] = provider_message_id
|
||||
return BackendResult(True, "ucc-adapter", confirmed_status, result_data)
|
||||
|
||||
data: Dict[str, Any] = {"code": "ucc_adapter_unavailable"}
|
||||
if last_endpoint:
|
||||
data["endpoint"] = last_endpoint
|
||||
return BackendResult(False, "ucc-adapter", "failed", data)
|
||||
|
||||
|
||||
def send_openclaw_channel(
|
||||
target: str,
|
||||
content: str,
|
||||
account_id: str = "",
|
||||
message_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False,
|
||||
) -> BackendResult:
|
||||
del target, content, account_id, message_id, metadata, dry_run
|
||||
return BackendResult(
|
||||
False,
|
||||
"openclaw-channel",
|
||||
"failed",
|
||||
{"code": "unsupported_backend"},
|
||||
)
|
||||
|
||||
|
||||
def send_message(
|
||||
target: str,
|
||||
content: str,
|
||||
account_id: str = "",
|
||||
message_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
selected = select_backend()
|
||||
if selected.get("backend") == "ucc-adapter":
|
||||
return send_ucc_adapter(
|
||||
target, content, account_id, message_id, metadata, dry_run
|
||||
).to_dict()
|
||||
code = "unsupported_backend" if selected.get("reason") == "unsupported_backend" else "wechat_outbound_backend_unavailable"
|
||||
return BackendResult(False, "unavailable", "failed", {"code": code}).to_dict()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(select_backend(), ensure_ascii=False, separators=(",", ":")))
|
||||
@@ -1,5 +1,5 @@
|
||||
from active_runtime.app.agent_event_bus import AgentEventBus
|
||||
from active_runtime.app.agent_event_dashboard import AgentEventDashboard
|
||||
from app.agent_event_bus import AgentEventBus
|
||||
from app.agent_event_dashboard import AgentEventDashboard
|
||||
|
||||
|
||||
def test_unified_event_flow():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.agent_observability_hooks import AgentObservabilityHooks
|
||||
from app.agent_observability_hooks import AgentObservabilityHooks
|
||||
|
||||
|
||||
def test_agent_observability_closed_loop():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from active_runtime.app.architect_governance_agent import ArchitectGovernanceAgent
|
||||
from active_runtime.app.architect_governance_dashboard import ArchitectGovernanceDashboard
|
||||
from app.architect_governance_agent import ArchitectGovernanceAgent
|
||||
from app.architect_governance_dashboard import ArchitectGovernanceDashboard
|
||||
|
||||
|
||||
def test_architect_agent_decides_release_level():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.architect_research_timeline import ArchitectResearchTimeline
|
||||
from app.architect_research_timeline import ArchitectResearchTimeline
|
||||
|
||||
|
||||
def test_research_timeline_tracks_version_decision():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.architect_weekly_research_agent import ArchitectWeeklyResearchAgent
|
||||
from app.architect_weekly_research_agent import ArchitectWeeklyResearchAgent
|
||||
|
||||
|
||||
def test_architect_research_decides_release_type():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.architecture_change_classifier import ArchitectureChangeClassifier
|
||||
from app.architecture_change_classifier import ArchitectureChangeClassifier
|
||||
|
||||
|
||||
def test_small_change_can_auto_process():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.architecture_owner_gate import ArchitectureOwnerGate
|
||||
from app.architecture_owner_gate import ArchitectureOwnerGate
|
||||
|
||||
|
||||
def test_architecture_change_blocks_without_owner():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.change_governance_dashboard import ChangeGovernanceDashboard
|
||||
from app.change_governance_dashboard import ChangeGovernanceDashboard
|
||||
|
||||
|
||||
def test_change_governance_blocks_missing_evidence():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.governance_event_bridge import GovernanceEventBridge
|
||||
from app.governance_event_bridge import GovernanceEventBridge
|
||||
|
||||
|
||||
def test_governance_events_are_published():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.governance_timeline_dashboard import GovernanceTimelineDashboard
|
||||
from app.governance_timeline_dashboard import GovernanceTimelineDashboard
|
||||
|
||||
|
||||
def test_governance_timeline_detects_ready_flow():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.search_agent_adapter import AgentSearchContext, OpenClawSearchClient
|
||||
from app.search_agent_adapter import AgentSearchContext, OpenClawSearchClient
|
||||
|
||||
|
||||
class FakeOrchestrator:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.search_architect_integration import SearchArchitectIntegration
|
||||
from app.search_architect_integration import SearchArchitectIntegration
|
||||
|
||||
|
||||
def test_search_architect_recommends_release_for_new_capability():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.search_gateway import (
|
||||
from app.search_gateway import (
|
||||
SearchEvidence,
|
||||
SearchGateway,
|
||||
SearchProviderAttempt,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.search_gateway_dashboard import SearchGatewayDashboard
|
||||
from app.search_gateway_dashboard import SearchGatewayDashboard
|
||||
|
||||
|
||||
def test_search_gateway_dashboard_reports_provider_failures():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from active_runtime.app.search_gateway import SearchEvidence, SearchGateway, SearchProviderAttempt, SearchRequest
|
||||
from active_runtime.app.search_profile_resolver import SearchProfileResolver
|
||||
from app.search_gateway import SearchEvidence, SearchGateway, SearchProviderAttempt, SearchRequest
|
||||
from app.search_profile_resolver import SearchProfileResolver
|
||||
|
||||
|
||||
class FailingProvider:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||
from app.search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||
|
||||
|
||||
def test_search_orchestrator_has_audit_registry_event_layers():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from active_runtime.app.search_provider_registry import SearchProviderRegistry, StaticArchiveProvider
|
||||
from active_runtime.app.search_gateway import SearchGateway, SearchRequest
|
||||
from app.search_provider_registry import SearchProviderRegistry, StaticArchiveProvider
|
||||
from app.search_gateway import SearchGateway, SearchRequest
|
||||
|
||||
|
||||
def test_provider_registry_has_default_layers():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.task_center import AIOSTaskRecord, TaskCenter, TaskNotificationStatus, TaskProgress
|
||||
from app.task_center import AIOSTaskRecord, TaskCenter, TaskNotificationStatus, TaskProgress
|
||||
|
||||
|
||||
def test_task_center_tracks_progress_and_notification():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.task_dashboard_view import TaskDashboardView
|
||||
from app.task_dashboard_view import TaskDashboardView
|
||||
|
||||
|
||||
def test_task_dashboard_contains_operations_views():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from active_runtime.app.task_event_bridge import TaskEventBridge
|
||||
from active_runtime.app.task_statistics import TaskStatistics
|
||||
from app.task_event_bridge import TaskEventBridge
|
||||
from app.task_statistics import TaskStatistics
|
||||
|
||||
|
||||
def test_task_event_bridge_creates_events():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.task_lifecycle import TaskLifecycle
|
||||
from app.task_lifecycle import TaskLifecycle
|
||||
|
||||
|
||||
def test_owner_approval_button_appears_for_waiting_task():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.task_orchestrator import TaskOrchestrator
|
||||
from app.task_orchestrator import TaskOrchestrator
|
||||
|
||||
|
||||
def test_command_creates_task():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from active_runtime.app.git_repo_locator import GitRepoLocator
|
||||
from app.git_repo_locator import GitRepoLocator
|
||||
|
||||
|
||||
def test_repo_locator_uses_configurable_root():
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import aios_v2r1
|
||||
from aios_v2r1 import cli
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_canonical_package_has_one_identity_and_no_compatibility_shim() -> None:
|
||||
assert aios_v2r1.__name__ == "aios_v2r1"
|
||||
assert aios_v2r1.__package__ == "aios_v2r1"
|
||||
assert aios_v2r1.__version__ == "2.1.0-rc.1"
|
||||
assert importlib.util.find_spec("active_runtime") is None
|
||||
assert not (ROOT / "active_runtime").exists()
|
||||
|
||||
|
||||
def test_packaging_metadata_declares_only_the_canonical_app_package() -> None:
|
||||
metadata = (ROOT / "active-runtime" / "pyproject.toml").read_text()
|
||||
assert 'packages = ["aios_v2r1"]' in metadata
|
||||
assert 'package-dir = {"" = "app"}' in metadata
|
||||
assert "active_runtime" not in metadata
|
||||
assert 'aios-cell = "aios_v2r1.cli:main"' in metadata
|
||||
|
||||
|
||||
def test_registry_path_named_serve_keeps_legacy_status_dispatch(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
tenant_id: str
|
||||
state: str
|
||||
|
||||
class FakeManager:
|
||||
def __init__(self, registry: str) -> None:
|
||||
calls.append(("registry", registry))
|
||||
|
||||
def status(self, tenant_id: str) -> Result:
|
||||
calls.append(("status", tenant_id))
|
||||
return Result(tenant_id=tenant_id, state="healthy")
|
||||
|
||||
monkeypatch.setattr(cli, "CellManager", FakeManager)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"serve",
|
||||
lambda host, port: pytest.fail("legacy registry command entered service mode"),
|
||||
)
|
||||
|
||||
assert cli.main(["serve", "status", "tenant-a"]) == 0
|
||||
assert calls == [("registry", "serve"), ("status", "tenant-a")]
|
||||
assert '"tenant_id": "tenant-a"' in capsys.readouterr().out
|
||||
@@ -1,11 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
|
||||
BACKEND_PATH = Path(__file__).resolve().parents[1] / "scripts" / "wechat_send_backend.py"
|
||||
@@ -80,6 +89,450 @@ class WechatSendBackendTests(TestCase):
|
||||
self.assertEqual(args[1]["content"], "hello")
|
||||
self.assertFalse(args[1]["dry_run"])
|
||||
|
||||
def test_ucc_auth_is_authorization_header_not_argv_or_output(self):
|
||||
secret = "ucc-super-secret-token"
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, _limit=-1):
|
||||
return b'{"ok":true,"status":"sent"}'
|
||||
|
||||
urlopen = Mock(return_value=Response())
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.object(self.backend, "UCC_ADAPTER_TOKEN", secret),
|
||||
patch.object(self.backend, "_open_no_redirect", urlopen),
|
||||
redirect_stdout(output),
|
||||
redirect_stderr(output),
|
||||
):
|
||||
status, payload = self.backend._post_ucc(
|
||||
"/api/internal/wechat/send", {"target": "chat-001", "content": "hello"}
|
||||
)
|
||||
|
||||
request = urlopen.call_args.args[0]
|
||||
self.assertEqual(request.get_header("Authorization"), f"Bearer {secret}")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload, {"ok": True, "status": "sent"})
|
||||
self.assertNotIn(secret, " ".join(self.backend._openclaw_base_args()))
|
||||
self.assertNotIn(secret, output.getvalue())
|
||||
self.assertFalse({"--gateway", "--token", "--password"} & set(self.backend._openclaw_base_args()))
|
||||
|
||||
def test_ucc_paths_are_canonical_first_unique_and_bounded(self):
|
||||
with patch.object(self.backend, "UCC_SEND_PATH", "/api/internal/wechat/send"):
|
||||
paths = self.backend._ucc_send_paths()
|
||||
|
||||
self.assertEqual(paths[0], "/api/internal/wechat/send")
|
||||
self.assertLessEqual(len(paths), 2)
|
||||
self.assertEqual(len(paths), len(set(paths)))
|
||||
self.assertTrue(all(path.startswith("/") for path in paths))
|
||||
|
||||
def test_ucc_accepts_only_explicit_true_and_sent_when_not_dry_run(self):
|
||||
self.assertTrue(self.backend._ucc_response_sent({"ok": True, "status": "sent"}))
|
||||
for response in (
|
||||
{"ok": 1, "status": "sent"},
|
||||
{"ok": True, "status": "accepted"},
|
||||
{"ok": False, "status": "sent"},
|
||||
{"status": "sent"},
|
||||
):
|
||||
with self.subTest(response=response):
|
||||
self.assertFalse(self.backend._ucc_response_sent(response))
|
||||
|
||||
def test_ambiguous_canonical_failures_never_fallback(self):
|
||||
secret = "raw-upstream-secret"
|
||||
failures = (
|
||||
TimeoutError(secret),
|
||||
ConnectionError(secret),
|
||||
self.backend._BackendProtocolError(secret),
|
||||
RuntimeError(secret),
|
||||
)
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__):
|
||||
with (
|
||||
patch.object(self.backend, "UCC_SEND_PATH", "/api/internal/wechat/send"),
|
||||
patch.object(self.backend, "_post_ucc", side_effect=failure) as post,
|
||||
):
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello", message_id="msg-001"
|
||||
).to_dict()
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["code"], "ucc_adapter_unavailable")
|
||||
self.assertEqual(post.call_count, 1)
|
||||
self.assertNotIn(secret, repr(result))
|
||||
|
||||
def test_only_explicit_http_404_or_405_uses_legacy_fallback(self):
|
||||
for http_status in (404, 405):
|
||||
with self.subTest(http_status=http_status):
|
||||
missing = urllib.error.HTTPError(
|
||||
"http://127.0.0.1/api/internal/wechat/send",
|
||||
http_status,
|
||||
"not found",
|
||||
{},
|
||||
None,
|
||||
)
|
||||
with (
|
||||
patch.object(self.backend, "UCC_SEND_PATH", "/api/internal/wechat/send"),
|
||||
patch.object(
|
||||
self.backend,
|
||||
"_post_ucc",
|
||||
side_effect=[missing, (200, {"ok": True, "status": "sent"})],
|
||||
) as post,
|
||||
):
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello", message_id="msg-001"
|
||||
).to_dict()
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["endpoint"], "/internal/channels/wechat/send")
|
||||
self.assertEqual(post.call_count, 2)
|
||||
|
||||
def test_non_not_found_http_error_never_falls_back(self):
|
||||
denied = urllib.error.HTTPError(
|
||||
"http://127.0.0.1/api/internal/wechat/send", 401, "denied", {}, None
|
||||
)
|
||||
with patch.object(self.backend, "_post_ucc", side_effect=denied) as post:
|
||||
result = self.backend.send_ucc_adapter(target="chat-001", content="hello").to_dict()
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["code"], "ucc_adapter_http_error")
|
||||
post.assert_called_once()
|
||||
|
||||
def test_ucc_rejection_does_not_expose_raw_error_or_secret(self):
|
||||
secret = "provider-password=secret-value"
|
||||
with patch.object(
|
||||
self.backend,
|
||||
"_post_ucc",
|
||||
return_value=(200, {"ok": False, "status": "failed", "error": secret}),
|
||||
):
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello", message_id="msg-001"
|
||||
).to_dict()
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["code"], "ucc_adapter_rejected")
|
||||
self.assertNotIn(secret, repr(result))
|
||||
|
||||
def test_dry_run_requires_dry_run_status_and_never_reports_sent(self):
|
||||
with patch.object(
|
||||
self.backend,
|
||||
"_post_ucc",
|
||||
return_value=(200, {"ok": True, "status": "dry_run"}),
|
||||
):
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello", dry_run=True
|
||||
).to_dict()
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["status"], "dry_run")
|
||||
self.assertNotEqual(result["status"], "sent")
|
||||
|
||||
for dry_run, upstream_status in ((True, "sent"), (False, "dry_run")):
|
||||
with self.subTest(dry_run=dry_run, upstream_status=upstream_status):
|
||||
with patch.object(
|
||||
self.backend,
|
||||
"_post_ucc",
|
||||
return_value=(200, {"ok": True, "status": upstream_status}),
|
||||
):
|
||||
rejected = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello", dry_run=dry_run
|
||||
).to_dict()
|
||||
self.assertFalse(rejected["ok"])
|
||||
self.assertEqual(rejected["status"], "failed")
|
||||
|
||||
def test_invalid_provider_message_id_never_reverses_confirmed_send(self):
|
||||
invalid_values = (
|
||||
"\ud800",
|
||||
"contains\x00control",
|
||||
"x" * (self.backend.MAX_IDENTIFIER_BYTES + 1),
|
||||
{"not": "a string"},
|
||||
)
|
||||
for provider_message_id in invalid_values:
|
||||
with self.subTest(value=repr(provider_message_id)[:40]):
|
||||
with patch.object(
|
||||
self.backend,
|
||||
"_post_ucc",
|
||||
return_value=(
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"status": "sent",
|
||||
"provider_message_id": provider_message_id,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello"
|
||||
).to_dict()
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertNotIn("provider_message_id", result)
|
||||
|
||||
def test_openclaw_2026_6_11_commands_have_no_unsupported_global_gateway(self):
|
||||
completed = SimpleNamespace(returncode=0, stdout='{"channels": []}')
|
||||
with (
|
||||
patch.object(self.backend, "OPENCLAW_BIN", "/usr/local/bin/openclaw"),
|
||||
patch.object(self.backend, "_run_openclaw", return_value=completed) as run,
|
||||
):
|
||||
self.assertIsNone(self.backend.discover_openclaw_wechat_channel())
|
||||
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(command, ["/usr/local/bin/openclaw", "channels", "list", "--json"])
|
||||
self.assertFalse({"--gateway", "--token", "--password"} & set(command))
|
||||
|
||||
def test_openclaw_output_overflow_terminates_while_process_is_running(self):
|
||||
writer = (
|
||||
"import os,time;"
|
||||
f"os.write(1,b'x'*{self.backend.MAX_RESPONSE_BYTES + 4096});"
|
||||
"time.sleep(10)"
|
||||
)
|
||||
started = time.monotonic()
|
||||
completed = self.backend._run_openclaw([sys.executable, "-c", writer], timeout=4)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
self.assertLess(elapsed, 2.0)
|
||||
self.assertNotEqual(completed.returncode, 0)
|
||||
self.assertEqual(completed.stdout, "")
|
||||
self.assertEqual(completed.stderr, "")
|
||||
|
||||
def test_openclaw_timeout_returns_failure_and_terminates_promptly(self):
|
||||
started = time.monotonic()
|
||||
completed = self.backend._run_openclaw(
|
||||
[sys.executable, "-c", "import time;time.sleep(10)"], timeout=1
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
self.assertLess(elapsed, 3.0)
|
||||
self.assertNotEqual(completed.returncode, 0)
|
||||
self.assertEqual(completed.stdout, "")
|
||||
self.assertEqual(completed.stderr, "")
|
||||
|
||||
def test_timeout_kills_inherited_pipe_descendant_after_parent_exits(self):
|
||||
if os.name != "posix" or not Path("/proc").is_dir():
|
||||
self.skipTest("POSIX process-group and procfs contract")
|
||||
|
||||
def process_is_running(pid):
|
||||
try:
|
||||
state = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()[2]
|
||||
except (FileNotFoundError, IndexError, OSError):
|
||||
return False
|
||||
return state not in {"X", "Z"}
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pid_path = Path(directory) / "descendant.pid"
|
||||
descendant = "import time;time.sleep(30)"
|
||||
launcher = (
|
||||
"import pathlib,subprocess,sys;"
|
||||
f"child=subprocess.Popen([sys.executable,'-c',{descendant!r}]);"
|
||||
f"pathlib.Path({str(pid_path)!r}).write_text(str(child.pid),encoding='utf-8')"
|
||||
)
|
||||
started = time.monotonic()
|
||||
completed = self.backend._run_openclaw(
|
||||
[sys.executable, "-c", launcher], timeout=1
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
self.assertTrue(pid_path.is_file())
|
||||
descendant_pid = int(pid_path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
deadline = time.monotonic() + 2
|
||||
while process_is_running(descendant_pid) and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertFalse(process_is_running(descendant_pid))
|
||||
finally:
|
||||
if process_is_running(descendant_pid):
|
||||
os.kill(descendant_pid, signal.SIGKILL)
|
||||
|
||||
self.assertLess(elapsed, 3.0)
|
||||
self.assertNotEqual(completed.returncode, 0)
|
||||
|
||||
def test_process_tree_termination_uses_posix_process_group(self):
|
||||
if os.name != "posix":
|
||||
self.skipTest("POSIX process-group contract")
|
||||
|
||||
class Process:
|
||||
pid = 43210
|
||||
|
||||
def __init__(self):
|
||||
self.wait_count = 0
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def wait(self, timeout):
|
||||
self.wait_count += 1
|
||||
if self.wait_count == 1:
|
||||
raise subprocess.TimeoutExpired(["openclaw"], timeout)
|
||||
return -signal.SIGKILL
|
||||
|
||||
process = Process()
|
||||
with patch.object(self.backend.os, "killpg") as killpg:
|
||||
self.backend._terminate_process_tree(process)
|
||||
|
||||
self.assertEqual(
|
||||
killpg.call_args_list,
|
||||
[
|
||||
call(process.pid, signal.SIGTERM),
|
||||
call(process.pid, signal.SIGKILL),
|
||||
],
|
||||
)
|
||||
|
||||
def test_unsupported_and_generic_chat_backends_fail_closed(self):
|
||||
with (
|
||||
patch.object(self.backend, "SEND_BACKEND", "openclaw-chat"),
|
||||
patch.object(self.backend, "ucc_adapter_alive", return_value=False),
|
||||
):
|
||||
selected = self.backend.select_backend()
|
||||
result = self.backend.send_message(target="chat-001", content="hello")
|
||||
|
||||
self.assertEqual(selected["backend"], "unavailable")
|
||||
self.assertEqual(selected["reason"], "unsupported_backend")
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["code"], "unsupported_backend")
|
||||
|
||||
def test_invalid_or_oversized_input_is_rejected_before_network(self):
|
||||
with patch.object(self.backend, "_post_ucc") as post:
|
||||
empty = self.backend.send_ucc_adapter(target="", content="hello").to_dict()
|
||||
oversized = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="x" * (self.backend.MAX_CONTENT_BYTES + 1)
|
||||
).to_dict()
|
||||
|
||||
self.assertEqual(empty["code"], "invalid_input")
|
||||
self.assertEqual(oversized["code"], "input_too_large")
|
||||
post.assert_not_called()
|
||||
|
||||
def test_unpaired_surrogate_inputs_fail_closed_before_network(self):
|
||||
cases = (
|
||||
{"target": "\ud800", "content": "hello"},
|
||||
{"target": "chat-001", "content": "\ud800"},
|
||||
{"target": "chat-001", "content": "hello", "account_id": "\ud800"},
|
||||
{"target": "chat-001", "content": "hello", "message_id": "\ud800"},
|
||||
{"target": "chat-001", "content": "hello", "metadata": {"bad": "\ud800"}},
|
||||
)
|
||||
with patch.object(self.backend, "_post_ucc") as post:
|
||||
for kwargs in cases:
|
||||
with self.subTest(field=next(reversed(kwargs))):
|
||||
result = self.backend.send_ucc_adapter(**kwargs).to_dict()
|
||||
self.assertEqual(result["code"], "invalid_input")
|
||||
post.assert_not_called()
|
||||
|
||||
def test_deeply_nested_metadata_is_bounded_before_network(self):
|
||||
metadata = {}
|
||||
for _ in range(1200):
|
||||
metadata = {"nested": metadata}
|
||||
|
||||
with patch.object(self.backend, "_post_ucc") as post:
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello", metadata=metadata
|
||||
).to_dict()
|
||||
|
||||
self.assertEqual(result["code"], "input_too_large")
|
||||
post.assert_not_called()
|
||||
|
||||
def test_adapter_origin_must_be_allowlisted(self):
|
||||
with (
|
||||
patch.object(self.backend, "UCC_ADAPTER_URL", "https://evil.example:8443"),
|
||||
patch.object(
|
||||
self.backend,
|
||||
"UCC_ADAPTER_ALLOWED_ORIGINS",
|
||||
("http://127.0.0.1:18800",),
|
||||
),
|
||||
):
|
||||
self.assertIsNone(self.backend._adapter_base_url())
|
||||
|
||||
with (
|
||||
patch.object(self.backend, "UCC_ADAPTER_URL", "https://adapter.example:8443"),
|
||||
patch.object(
|
||||
self.backend,
|
||||
"UCC_ADAPTER_ALLOWED_ORIGINS",
|
||||
("https://adapter.example:8443",),
|
||||
),
|
||||
):
|
||||
self.assertEqual(
|
||||
self.backend._adapter_base_url(), "https://adapter.example:8443"
|
||||
)
|
||||
|
||||
def test_adapter_origin_rejects_scheme_userinfo_fragment_and_path(self):
|
||||
invalid_origins = (
|
||||
"ftp://127.0.0.1:18800",
|
||||
"http://user:password@127.0.0.1:18800",
|
||||
"http://127.0.0.1:18800#fragment",
|
||||
"http://127.0.0.1:18800/base-path",
|
||||
)
|
||||
for origin in invalid_origins:
|
||||
with self.subTest(origin=origin):
|
||||
with (
|
||||
patch.object(self.backend, "UCC_ADAPTER_URL", origin),
|
||||
patch.object(
|
||||
self.backend,
|
||||
"UCC_ADAPTER_ALLOWED_ORIGINS",
|
||||
("http://127.0.0.1:18800",),
|
||||
),
|
||||
):
|
||||
self.assertIsNone(self.backend._adapter_base_url())
|
||||
|
||||
def test_adapter_path_rejects_decoded_traversal(self):
|
||||
for path in (
|
||||
"/api/%2e%2e/send",
|
||||
"/api/%2E%2E/send",
|
||||
"/api/%2525252e%2525252e/send",
|
||||
"/api/%252525252e%252525252e/send",
|
||||
"/api/../send",
|
||||
"/api/%zz/send",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertIsNone(self.backend._safe_path(path))
|
||||
|
||||
def test_adapter_path_keeps_intended_safe_canonical_paths(self):
|
||||
for path in (
|
||||
"/api/internal/wechat/send",
|
||||
"/internal/channels/wechat/send",
|
||||
"/api/%7Ehealth/status",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.backend._safe_path(path), path)
|
||||
|
||||
def test_redirects_are_refused_and_authorization_cannot_cross_origin(self):
|
||||
handler = self.backend._NoRedirectHandler()
|
||||
self.assertIsNone(
|
||||
handler.redirect_request(
|
||||
None,
|
||||
None,
|
||||
302,
|
||||
"redirect",
|
||||
{"Location": "https://evil.example/steal"},
|
||||
"https://evil.example/steal",
|
||||
)
|
||||
)
|
||||
|
||||
redirected = urllib.error.HTTPError(
|
||||
"http://127.0.0.1:18800/api/internal/wechat/send",
|
||||
302,
|
||||
"redirect",
|
||||
{"Location": "https://evil.example/steal"},
|
||||
None,
|
||||
)
|
||||
with (
|
||||
patch.object(self.backend, "UCC_ADAPTER_TOKEN", "header-secret"),
|
||||
patch.object(self.backend, "_open_no_redirect", side_effect=redirected) as opened,
|
||||
):
|
||||
result = self.backend.send_ucc_adapter(
|
||||
target="chat-001", content="hello"
|
||||
).to_dict()
|
||||
|
||||
request = opened.call_args.args[0]
|
||||
self.assertEqual(urllib.parse.urlsplit(request.full_url).netloc, "127.0.0.1:18800")
|
||||
self.assertEqual(request.get_header("Authorization"), "Bearer header-secret")
|
||||
opened.assert_called_once()
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["code"], "ucc_adapter_http_error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_v2r1 import cli
|
||||
from aios_v2r1.ingress_service import build_server
|
||||
|
||||
|
||||
@contextmanager
|
||||
def running_server(processor: Callable[[str, bytes], tuple[int, dict[str, object]]] | None = None) -> Iterator[tuple[str, int]]:
|
||||
server = build_server("127.0.0.1", 0, processor=processor)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = server.server_address
|
||||
yield str(host), int(port)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def request(
|
||||
address: tuple[str, int],
|
||||
method: str,
|
||||
path: str,
|
||||
body: bytes | None = None,
|
||||
) -> tuple[int, bytes, dict[str, str]]:
|
||||
connection = http.client.HTTPConnection(*address, timeout=5)
|
||||
try:
|
||||
connection.request(method, path, body=body)
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
return response.status, payload, {key.lower(): value for key, value in response.getheaders()}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def raw_request(
|
||||
address: tuple[str, int],
|
||||
method: bytes,
|
||||
path: bytes = b"/health/live",
|
||||
) -> bytes:
|
||||
request_bytes = (
|
||||
method + b" " + path + b" HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
with socket.create_connection(address, timeout=5) as connection:
|
||||
connection.sendall(request_bytes)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = connection.recv(8192)
|
||||
if not chunk:
|
||||
return b"".join(chunks)
|
||||
chunks.append(chunk)
|
||||
|
||||
|
||||
def wait_until_listening(process: subprocess.Popen[str], address: tuple[str, int]) -> None:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
_, stderr = process.communicate()
|
||||
pytest.fail(f"server exited before listening: {stderr}")
|
||||
try:
|
||||
with socket.create_connection(address, timeout=0.1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
pytest.fail("server did not start listening")
|
||||
|
||||
|
||||
def test_live_and_unconfigured_readiness_are_exact_and_bounded() -> None:
|
||||
with running_server() as address:
|
||||
live = request(address, "GET", "/health/live")
|
||||
ready = request(address, "GET", "/health/ready")
|
||||
|
||||
assert live[:2] == (200, b'{"status":"LIVE"}')
|
||||
assert ready[:2] == (503, b'{"status":"SERVICE_NOT_CONFIGURED"}')
|
||||
assert live[2]["content-type"] == "application/json"
|
||||
assert ready[2]["content-length"] == str(len(ready[1]))
|
||||
assert len(live[1]) <= 4096
|
||||
assert len(ready[1]) <= 4096
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
["/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"],
|
||||
)
|
||||
def test_mutation_routes_fail_closed_without_reading_or_mutating(route: str) -> None:
|
||||
with running_server() as address:
|
||||
status, payload, _ = request(
|
||||
address,
|
||||
"POST",
|
||||
route,
|
||||
b'{"sender":"do-not-log","body":"do-not-log"}',
|
||||
)
|
||||
assert status == 503
|
||||
assert payload == b'{"status":"SERVICE_NOT_CONFIGURED"}'
|
||||
|
||||
|
||||
def test_unknown_routes_and_wrong_methods_use_bounded_sanitized_codes() -> None:
|
||||
with running_server() as address:
|
||||
missing = request(address, "GET", "/missing")
|
||||
wrong_method = request(address, "GET", "/v1/ingress")
|
||||
unsupported = request(address, "PUT", "/health/live")
|
||||
|
||||
assert missing[:2] == (404, b'{"status":"NOT_FOUND"}')
|
||||
assert wrong_method[:2] == (405, b'{"status":"METHOD_NOT_ALLOWED"}')
|
||||
assert unsupported[:2] == (405, b'{"status":"METHOD_NOT_ALLOWED"}')
|
||||
assert all(len(result[1]) <= 4096 for result in (missing, wrong_method, unsupported))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "expected_status"),
|
||||
[
|
||||
(b"METHOD_SECRET_REFLECTION_TOKEN", b"405"),
|
||||
(b"METHOD_SECRET_REFLECTION_TOKEN" + b"X" * 66_000, b"414"),
|
||||
],
|
||||
ids=["arbitrary", "overlong"],
|
||||
)
|
||||
def test_arbitrary_and_overlong_methods_use_bounded_json_without_reflection(
|
||||
method: bytes, expected_status: bytes
|
||||
) -> None:
|
||||
with running_server() as address:
|
||||
response = raw_request(address, method)
|
||||
|
||||
headers, body = response.split(b"\r\n\r\n", 1)
|
||||
status_line = headers.split(b"\r\n", 1)[0]
|
||||
assert status_line.split(b" ", 2)[1] == expected_status
|
||||
assert b"Content-Type: application/json" in headers
|
||||
assert len(body) <= 4096
|
||||
assert b"METHOD_SECRET_REFLECTION_TOKEN" not in response
|
||||
assert b"<html" not in response.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_status", [True, False, 99, 600, 999, "200", None])
|
||||
def test_invalid_processor_status_is_sanitized_before_writing_status_line(
|
||||
invalid_status: object,
|
||||
) -> None:
|
||||
def processor(route: str, body: bytes) -> tuple[int, dict[str, object]]:
|
||||
return invalid_status, {"status": "MUST_NOT_ESCAPE"} # type: ignore[return-value]
|
||||
|
||||
with running_server(processor) as address:
|
||||
response = raw_request(address, b"POST", b"/v1/ingress")
|
||||
|
||||
headers, body = response.split(b"\r\n\r\n", 1)
|
||||
assert headers.split(b"\r\n", 1)[0].startswith(b"HTTP/1.0 500 ")
|
||||
assert body == b'{"status":"INTERNAL_ERROR"}'
|
||||
assert b"MUST_NOT_ESCAPE" not in response
|
||||
|
||||
|
||||
def test_processor_is_injected_and_exceptions_are_not_disclosed(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
secret = "sender-secret-body-token"
|
||||
|
||||
def exploding_processor(route: str, body: bytes) -> tuple[int, dict[str, object]]:
|
||||
assert route == "/v1/ingress"
|
||||
assert json.loads(body) == {"value": secret}
|
||||
raise RuntimeError(secret)
|
||||
|
||||
with running_server(exploding_processor) as address:
|
||||
ready = request(address, "GET", "/health/ready")
|
||||
failed = request(
|
||||
address,
|
||||
"POST",
|
||||
"/v1/ingress",
|
||||
json.dumps({"value": secret}).encode(),
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert ready[:2] == (200, b'{"status":"READY"}')
|
||||
assert failed[:2] == (500, b'{"status":"INTERNAL_ERROR"}')
|
||||
assert secret not in failed[1].decode()
|
||||
assert secret not in captured.out
|
||||
assert secret not in captured.err
|
||||
|
||||
|
||||
def test_server_rejects_non_loopback_bind() -> None:
|
||||
with pytest.raises(ValueError, match="loopback"):
|
||||
build_server("0.0.0.0", 0)
|
||||
|
||||
|
||||
def test_af_inet_server_rejects_ipv6_loopback_explicitly() -> None:
|
||||
with pytest.raises(ValueError, match="IPv4 loopback"):
|
||||
build_server("::1", 0)
|
||||
|
||||
|
||||
def test_sigint_shutdown_exits_zero_without_traceback_and_releases_port() -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
|
||||
reservation.bind(("127.0.0.1", 0))
|
||||
port = int(reservation.getsockname()[1])
|
||||
|
||||
env = os.environ.copy()
|
||||
package_root = str(Path(__file__).resolve().parents[2] / "active-runtime" / "app")
|
||||
env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "")
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"aios_v2r1.cli",
|
||||
"serve",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
wait_until_listening(process, ("127.0.0.1", port))
|
||||
process.send_signal(signal.SIGINT)
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
|
||||
assert process.returncode == 0
|
||||
assert "Traceback" not in stdout
|
||||
assert "Traceback" not in stderr
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as rebound:
|
||||
rebound.bind(("127.0.0.1", port))
|
||||
|
||||
|
||||
def test_cli_preserves_registry_commands_and_dispatches_top_level_serve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
parsed = cli.build_parser().parse_args(["registry.sqlite", "status", "tenant-a"])
|
||||
assert parsed.registry == "registry.sqlite"
|
||||
assert parsed.command == "status"
|
||||
|
||||
called: list[tuple[str, int]] = []
|
||||
monkeypatch.setattr(cli, "serve", lambda host, port: called.append((host, port)))
|
||||
assert cli.main(["serve", "--host", "127.0.0.1", "--port", "19001"]) == 0
|
||||
assert called == [("127.0.0.1", 19001)]
|
||||
Reference in New Issue
Block a user