Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int:

try:
settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings
payload = cast(dict[str, Any], json.load(sys.stdin))
stdin = sys.stdin
if hasattr(stdin, "buffer"):
payload = cast(dict[str, Any], json.loads(stdin.buffer.read().decode("utf-8")))
else:
payload = cast(dict[str, Any], json.load(stdin))
if not _is_user_prompt_submit(payload.get("hook_event_name")):
return 0
prompt = _prompt(payload)
Expand Down
6 changes: 5 additions & 1 deletion integrations/codex/plugins/powercontext/hooks/recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ def main(settings: CodexPluginSettings | None = None) -> int:
try:
settings = CodexPluginSettings() if settings is None else settings
http_deadline = monotonic() + settings.http_budget_seconds
payload = cast(dict[str, Any], json.load(sys.stdin))
stdin = sys.stdin
if hasattr(stdin, "buffer"):
payload = cast(dict[str, Any], json.loads(stdin.buffer.read().decode("utf-8")))
else:
payload = cast(dict[str, Any], json.load(stdin))
if not _is_user_prompt_submit(payload.get("hook_event_name")):
return 0
prompt = payload.get("prompt")
Expand Down
12 changes: 11 additions & 1 deletion src/powercontext/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ async def invalid_request(request: Request, error: RequestValidationError) -> JS
status.HTTP_422_UNPROCESSABLE_CONTENT,
code="invalid_request",
message="The request violates the API contract.",
details={"errors": error.errors()},
details={"errors": _validation_error_details(error)},
)

@app.exception_handler(_RuntimeNotReadyError)
Expand Down Expand Up @@ -1465,6 +1465,16 @@ def _error_response(
return JSONResponse(status_code=response_status, content=error.model_dump(mode="json"))


def _validation_error_details(error: RequestValidationError) -> list[Any]:
details: list[Any] = []
for item in error.errors():
if isinstance(item, dict):
details.append({key: value for key, value in item.items() if key != "input"})
else:
details.append(item)
return details


def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]:
if isinstance(error, _RuntimeNotReadyError):
return status.HTTP_503_SERVICE_UNAVAILABLE, "runtime_not_ready", "The Runtime is not ready.", None
Expand Down
42 changes: 42 additions & 0 deletions tests/claude_code_plugin/test_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,48 @@ def test_user_prompt_submit_injects_prepared_context_and_captures_prompt(
assert captured == [("What decisions apply?", "git:github.com/oceanbase/powercontext")]


def test_user_prompt_submit_reads_utf8_stdin_on_windows_encodings(
hook_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
) -> None:
prepared_content = "prepared context"
captured: list[str] = []
monkeypatch.setattr(
hook_module,
"_prepare_context",
lambda _query, _scope, *, settings, deadline: _prepared(prepared_content),
)
monkeypatch.setattr(
hook_module,
"resolve_scope_id",
lambda _cwd, *, configured_scope_id: "git:github.com/oceanbase/powercontext",
)
monkeypatch.setattr(
hook_module,
"_capture_prompt",
lambda _payload, *, prompt, cwd, scope_id, settings, deadline: (
captured.append(prompt) or {"position": 1}
),
)

payload = {
"hook_event_name": "UserPromptSubmit",
"cwd": "/workspace/project",
"prompt": "查看当前记忆",
}
stdin = io.TextIOWrapper(
io.BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")),
encoding="cp1252",
)
monkeypatch.setattr(sys, "stdin", stdin)
output = io.StringIO()
monkeypatch.setattr(sys, "stdout", output)

assert hook_module.main() == 0
assert captured == ["查看当前记忆"]
assert json.loads(output.getvalue())["hookSpecificOutput"]["additionalContext"] == prepared_content


def test_user_prompt_compatibility_fallback_is_supported(
hook_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
Expand Down
42 changes: 42 additions & 0 deletions tests/codex_plugin/test_recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,48 @@ def test_recall_emits_bounded_untrusted_context(
assert captured == [("What decisions apply?", "project:test")]


def test_recall_reads_utf8_stdin_on_windows_encodings(
recall_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
) -> None:
prepared_content = "prepared context"
captured: list[str] = []
monkeypatch.setattr(
recall_module,
"_prepare_context",
lambda _query, _scope, *, settings, deadline: _prepared(prepared_content),
)
monkeypatch.setattr(
recall_module,
"resolve_scope_id",
lambda _cwd, *, configured_scope_id: "project:test",
)
monkeypatch.setattr(
recall_module,
"_capture_prompt",
lambda _payload, *, prompt, cwd, scope_id, settings, deadline: (
captured.append(prompt) or {"position": 1}
),
)

payload = {
"hook_event_name": "UserPromptSubmit",
"cwd": "/workspace/project",
"prompt": "查看当前记忆",
}
stdin = io.TextIOWrapper(
io.BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")),
encoding="cp1252",
)
monkeypatch.setattr(sys, "stdin", stdin)
output = io.StringIO()
monkeypatch.setattr(sys, "stdout", output)

assert recall_module.main() == 0
assert captured == ["查看当前记忆"]
assert json.loads(output.getvalue())["hookSpecificOutput"]["additionalContext"] == prepared_content


def test_recall_failure_is_non_blocking(
recall_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
Expand Down
49 changes: 49 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,55 @@ def test_prepare_context_rejects_memory_specific_tuning_fields(tmp_path) -> None
assert response.json()["error"]["code"] == "invalid_request"


def test_prepare_context_rejects_unicode_surrogates_without_crashing(tmp_path) -> None:
app = create_server_app(
settings=ServerSettings(
database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"),
mcp=McpConfig(enabled=False),
)
)

body = '{"scope_id":"project:test","query":"\\udcaa"}'.encode(
"utf-8",
"surrogatepass",
)
with TestClient(app) as client:
response = client.post(
"/v1/context/prepare",
content=body,
headers={"content-type": "application/json"},
)

assert response.status_code == 422
error = response.json()["error"]
assert error["code"] == "invalid_request"
assert "input" not in error["details"]["errors"][0]


def test_prepare_context_rejects_invalid_request_without_input_field(tmp_path) -> None:
app = create_server_app(
settings=ServerSettings(
database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"),
mcp=McpConfig(enabled=False),
)
)

with TestClient(app) as client:
response = client.post(
"/v1/context/prepare",
json={
"scope_id": "project:test",
"query": "query",
"candidate_limit": 2,
},
)

assert response.status_code == 422
error = response.json()["error"]
assert error["code"] == "invalid_request"
assert "input" not in error["details"]["errors"][0]


def test_stats_returns_inclusive_utc_periods_for_empty_scope(tmp_path) -> None:
app = create_server_app(
settings=ServerSettings(
Expand Down