From 7237e152c5ce364360179f63d77fd904cb8e54b1 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 05:04:11 +0100 Subject: [PATCH] taicpp: port CAP and MCP (wire layer, drivers, fleet, CLI, client, OAuth, bridge, vault tables, tools, server) Co-Authored-By: Claude Sonnet 5.5 --- taicpp/docs/cap.md | 92 +++ taicpp/docs/mcp.md | 64 ++ taicpp/src/cap/cap_a2a_driver.cpp | 511 +++++++++++++ taicpp/src/cap/cap_a2a_driver.h | 84 +++ taicpp/src/cap/cap_acp_driver.cpp | 979 +++++++++++++++++++++++++ taicpp/src/cap/cap_acp_driver.h | 138 ++++ taicpp/src/cap/cap_agent_manifest.cpp | 67 ++ taicpp/src/cap/cap_agent_manifest.h | 14 + taicpp/src/cap/cap_agent_port.h | 73 ++ taicpp/src/cap/cap_answerer.cpp | 159 ++++ taicpp/src/cap/cap_answerer.h | 60 ++ taicpp/src/cap/cap_binding.cpp | 53 ++ taicpp/src/cap/cap_binding.h | 31 + taicpp/src/cap/cap_cli.cpp | 457 ++++++++++++ taicpp/src/cap/cap_cli.h | 60 ++ taicpp/src/cap/cap_constants.h | 75 ++ taicpp/src/cap/cap_driver.cpp | 198 +++++ taicpp/src/cap/cap_driver.h | 123 ++++ taicpp/src/cap/cap_driver_factory.cpp | 24 + taicpp/src/cap/cap_error.h | 29 + taicpp/src/cap/cap_event.cpp | 118 +++ taicpp/src/cap/cap_event.h | 40 + taicpp/src/cap/cap_fleet.cpp | 378 ++++++++++ taicpp/src/cap/cap_fleet.h | 95 +++ taicpp/src/cap/cap_interrupt.h | 28 + taicpp/src/cap/cap_manifest.cpp | 146 ++++ taicpp/src/cap/cap_manifest.h | 44 ++ taicpp/src/cap/cap_pipe_reader.cpp | 67 ++ taicpp/src/cap/cap_pipe_reader.h | 33 + taicpp/src/cap/cap_proc_util.cpp | 79 ++ taicpp/src/cap/cap_proc_util.h | 48 ++ taicpp/src/cap/cap_pty_driver.cpp | 430 +++++++++++ taicpp/src/cap/cap_pty_driver.h | 75 ++ taicpp/src/cap/cap_py.cpp | 94 +++ taicpp/src/cap/cap_py.h | 28 + taicpp/src/cap/cap_registry.cpp | 85 +++ taicpp/src/cap/cap_registry.h | 37 + taicpp/src/cap/cap_screen.h | 26 + taicpp/src/cap/cap_session.cpp | 167 +++++ taicpp/src/cap/cap_session.h | 82 +++ taicpp/src/cap/cap_session_config.cpp | 126 ++++ taicpp/src/cap/cap_session_config.h | 38 + taicpp/src/cap/cap_speak_main.cpp | 95 +++ taicpp/src/cap/cap_speak_main.h | 15 + taicpp/src/cap/cap_speak_session.cpp | 286 ++++++++ taicpp/src/cap/cap_speak_session.h | 61 ++ taicpp/src/cap/cap_stream_driver.cpp | 301 ++++++++ taicpp/src/cap/cap_stream_driver.h | 49 ++ taicpp/src/cap/cap_stream_json.cpp | 117 +++ taicpp/src/cap/cap_stream_json.h | 31 + taicpp/src/cap/cap_turn_result.cpp | 14 + taicpp/src/cap/cap_turn_result.h | 36 + taicpp/src/cap/cap_types.h | 16 + taicpp/src/cap/cap_vt_screen.cpp | 635 ++++++++++++++++ taicpp/src/cap/cap_vt_screen.h | 105 +++ taicpp/src/cap/wire_app.h | 33 + taicpp/src/cap/wire_argparse.cpp | 648 ++++++++++++++++ taicpp/src/cap/wire_argparse.h | 66 ++ taicpp/src/cap/wire_difflib.cpp | 184 +++++ taicpp/src/cap/wire_difflib.h | 34 + taicpp/src/cap/wire_hash.cpp | 260 +++++++ taicpp/src/cap/wire_hash.h | 31 + taicpp/src/cap/wire_http.cpp | 845 +++++++++++++++++++++ taicpp/src/cap/wire_http.h | 153 ++++ taicpp/src/cap/wire_json.cpp | 539 ++++++++++++++ taicpp/src/cap/wire_json.h | 134 ++++ taicpp/src/cap/wire_log.h | 44 ++ taicpp/src/cap/wire_os.cpp | 259 +++++++ taicpp/src/cap/wire_os.h | 83 +++ taicpp/src/cap/wire_proc.cpp | 552 ++++++++++++++ taicpp/src/cap/wire_proc.h | 109 +++ taicpp/src/cap/wire_regex.cpp | 292 ++++++++ taicpp/src/cap/wire_regex.h | 78 ++ taicpp/src/cap/wire_shlex.cpp | 94 +++ taicpp/src/cap/wire_shlex.h | 15 + taicpp/src/cap/wire_text.cpp | 547 ++++++++++++++ taicpp/src/cap/wire_text.h | 74 ++ taicpp/src/cap/wire_toml.cpp | 818 +++++++++++++++++++++ taicpp/src/cap/wire_toml.h | 25 + taicpp/src/mcp/mcp_bridge.cpp | 331 +++++++++ taicpp/src/mcp/mcp_bridge.h | 54 ++ taicpp/src/mcp/mcp_client.cpp | 92 +++ taicpp/src/mcp/mcp_client.h | 40 + taicpp/src/mcp/mcp_constants.h | 51 ++ taicpp/src/mcp/mcp_error.h | 38 + taicpp/src/mcp/mcp_host.h | 33 + taicpp/src/mcp/mcp_http_transport.cpp | 101 +++ taicpp/src/mcp/mcp_http_transport.h | 34 + taicpp/src/mcp/mcp_normalizer.cpp | 257 +++++++ taicpp/src/mcp/mcp_normalizer.h | 46 ++ taicpp/src/mcp/mcp_oauth.cpp | 427 +++++++++++ taicpp/src/mcp/mcp_oauth.h | 82 +++ taicpp/src/mcp/mcp_oauth_callback.cpp | 41 ++ taicpp/src/mcp/mcp_oauth_callback.h | 26 + taicpp/src/mcp/mcp_serve_backend.h | 56 ++ taicpp/src/mcp/mcp_serve_context.cpp | 73 ++ taicpp/src/mcp/mcp_serve_context.h | 48 ++ taicpp/src/mcp/mcp_serve_handler.cpp | 291 ++++++++ taicpp/src/mcp/mcp_serve_handler.h | 41 ++ taicpp/src/mcp/mcp_serve_main.cpp | 104 +++ taicpp/src/mcp/mcp_serve_main.h | 31 + taicpp/src/mcp/mcp_sqlite_store.cpp | 252 +++++++ taicpp/src/mcp/mcp_sqlite_store.h | 53 ++ taicpp/src/mcp/mcp_stdio_transport.cpp | 106 +++ taicpp/src/mcp/mcp_stdio_transport.h | 32 + taicpp/src/mcp/mcp_store.h | 77 ++ taicpp/src/mcp/mcp_tools.cpp | 438 +++++++++++ taicpp/src/mcp/mcp_tools.h | 49 ++ taicpp/src/mcp/mcp_transport.h | 19 + taicpp/tests/capmcp_cli.cpp | 30 + taicpp/tests/capmcp_cli_diff.py | 326 ++++++++ taicpp/tests/capmcp_diff.py | 182 +++++ taicpp/tests/capmcp_probe.cpp | 72 ++ taicpp/tests/capmcp_unit.cpp | 196 +++++ 114 files changed, 17362 insertions(+) create mode 100644 taicpp/docs/cap.md create mode 100644 taicpp/docs/mcp.md create mode 100644 taicpp/src/cap/cap_a2a_driver.cpp create mode 100644 taicpp/src/cap/cap_a2a_driver.h create mode 100644 taicpp/src/cap/cap_acp_driver.cpp create mode 100644 taicpp/src/cap/cap_acp_driver.h create mode 100644 taicpp/src/cap/cap_agent_manifest.cpp create mode 100644 taicpp/src/cap/cap_agent_manifest.h create mode 100644 taicpp/src/cap/cap_agent_port.h create mode 100644 taicpp/src/cap/cap_answerer.cpp create mode 100644 taicpp/src/cap/cap_answerer.h create mode 100644 taicpp/src/cap/cap_binding.cpp create mode 100644 taicpp/src/cap/cap_binding.h create mode 100644 taicpp/src/cap/cap_cli.cpp create mode 100644 taicpp/src/cap/cap_cli.h create mode 100644 taicpp/src/cap/cap_constants.h create mode 100644 taicpp/src/cap/cap_driver.cpp create mode 100644 taicpp/src/cap/cap_driver.h create mode 100644 taicpp/src/cap/cap_driver_factory.cpp create mode 100644 taicpp/src/cap/cap_error.h create mode 100644 taicpp/src/cap/cap_event.cpp create mode 100644 taicpp/src/cap/cap_event.h create mode 100644 taicpp/src/cap/cap_fleet.cpp create mode 100644 taicpp/src/cap/cap_fleet.h create mode 100644 taicpp/src/cap/cap_interrupt.h create mode 100644 taicpp/src/cap/cap_manifest.cpp create mode 100644 taicpp/src/cap/cap_manifest.h create mode 100644 taicpp/src/cap/cap_pipe_reader.cpp create mode 100644 taicpp/src/cap/cap_pipe_reader.h create mode 100644 taicpp/src/cap/cap_proc_util.cpp create mode 100644 taicpp/src/cap/cap_proc_util.h create mode 100644 taicpp/src/cap/cap_pty_driver.cpp create mode 100644 taicpp/src/cap/cap_pty_driver.h create mode 100644 taicpp/src/cap/cap_py.cpp create mode 100644 taicpp/src/cap/cap_py.h create mode 100644 taicpp/src/cap/cap_registry.cpp create mode 100644 taicpp/src/cap/cap_registry.h create mode 100644 taicpp/src/cap/cap_screen.h create mode 100644 taicpp/src/cap/cap_session.cpp create mode 100644 taicpp/src/cap/cap_session.h create mode 100644 taicpp/src/cap/cap_session_config.cpp create mode 100644 taicpp/src/cap/cap_session_config.h create mode 100644 taicpp/src/cap/cap_speak_main.cpp create mode 100644 taicpp/src/cap/cap_speak_main.h create mode 100644 taicpp/src/cap/cap_speak_session.cpp create mode 100644 taicpp/src/cap/cap_speak_session.h create mode 100644 taicpp/src/cap/cap_stream_driver.cpp create mode 100644 taicpp/src/cap/cap_stream_driver.h create mode 100644 taicpp/src/cap/cap_stream_json.cpp create mode 100644 taicpp/src/cap/cap_stream_json.h create mode 100644 taicpp/src/cap/cap_turn_result.cpp create mode 100644 taicpp/src/cap/cap_turn_result.h create mode 100644 taicpp/src/cap/cap_types.h create mode 100644 taicpp/src/cap/cap_vt_screen.cpp create mode 100644 taicpp/src/cap/cap_vt_screen.h create mode 100644 taicpp/src/cap/wire_app.h create mode 100644 taicpp/src/cap/wire_argparse.cpp create mode 100644 taicpp/src/cap/wire_argparse.h create mode 100644 taicpp/src/cap/wire_difflib.cpp create mode 100644 taicpp/src/cap/wire_difflib.h create mode 100644 taicpp/src/cap/wire_hash.cpp create mode 100644 taicpp/src/cap/wire_hash.h create mode 100644 taicpp/src/cap/wire_http.cpp create mode 100644 taicpp/src/cap/wire_http.h create mode 100644 taicpp/src/cap/wire_json.cpp create mode 100644 taicpp/src/cap/wire_json.h create mode 100644 taicpp/src/cap/wire_log.h create mode 100644 taicpp/src/cap/wire_os.cpp create mode 100644 taicpp/src/cap/wire_os.h create mode 100644 taicpp/src/cap/wire_proc.cpp create mode 100644 taicpp/src/cap/wire_proc.h create mode 100644 taicpp/src/cap/wire_regex.cpp create mode 100644 taicpp/src/cap/wire_regex.h create mode 100644 taicpp/src/cap/wire_shlex.cpp create mode 100644 taicpp/src/cap/wire_shlex.h create mode 100644 taicpp/src/cap/wire_text.cpp create mode 100644 taicpp/src/cap/wire_text.h create mode 100644 taicpp/src/cap/wire_toml.cpp create mode 100644 taicpp/src/cap/wire_toml.h create mode 100644 taicpp/src/mcp/mcp_bridge.cpp create mode 100644 taicpp/src/mcp/mcp_bridge.h create mode 100644 taicpp/src/mcp/mcp_client.cpp create mode 100644 taicpp/src/mcp/mcp_client.h create mode 100644 taicpp/src/mcp/mcp_constants.h create mode 100644 taicpp/src/mcp/mcp_error.h create mode 100644 taicpp/src/mcp/mcp_host.h create mode 100644 taicpp/src/mcp/mcp_http_transport.cpp create mode 100644 taicpp/src/mcp/mcp_http_transport.h create mode 100644 taicpp/src/mcp/mcp_normalizer.cpp create mode 100644 taicpp/src/mcp/mcp_normalizer.h create mode 100644 taicpp/src/mcp/mcp_oauth.cpp create mode 100644 taicpp/src/mcp/mcp_oauth.h create mode 100644 taicpp/src/mcp/mcp_oauth_callback.cpp create mode 100644 taicpp/src/mcp/mcp_oauth_callback.h create mode 100644 taicpp/src/mcp/mcp_serve_backend.h create mode 100644 taicpp/src/mcp/mcp_serve_context.cpp create mode 100644 taicpp/src/mcp/mcp_serve_context.h create mode 100644 taicpp/src/mcp/mcp_serve_handler.cpp create mode 100644 taicpp/src/mcp/mcp_serve_handler.h create mode 100644 taicpp/src/mcp/mcp_serve_main.cpp create mode 100644 taicpp/src/mcp/mcp_serve_main.h create mode 100644 taicpp/src/mcp/mcp_sqlite_store.cpp create mode 100644 taicpp/src/mcp/mcp_sqlite_store.h create mode 100644 taicpp/src/mcp/mcp_stdio_transport.cpp create mode 100644 taicpp/src/mcp/mcp_stdio_transport.h create mode 100644 taicpp/src/mcp/mcp_store.h create mode 100644 taicpp/src/mcp/mcp_tools.cpp create mode 100644 taicpp/src/mcp/mcp_tools.h create mode 100644 taicpp/src/mcp/mcp_transport.h create mode 100644 taicpp/tests/capmcp_cli.cpp create mode 100644 taicpp/tests/capmcp_cli_diff.py create mode 100644 taicpp/tests/capmcp_diff.py create mode 100644 taicpp/tests/capmcp_probe.cpp create mode 100644 taicpp/tests/capmcp_unit.cpp diff --git a/taicpp/docs/cap.md b/taicpp/docs/cap.md new file mode 100644 index 0000000..248d9ce --- /dev/null +++ b/taicpp/docs/cap.md @@ -0,0 +1,92 @@ +# cap (taicpp) + +Port of the CAP section of `../tai.py` (`CAP_VERSION` ... `TAI_CAP_MANIFEST`), +normative text in `../cap.md`. Namespace `detz::cap`, sources in `src/cap/`, +one class per `.h`/`.cpp` pair. The C++ names are the Python names in +camelCase; behavior is verified by running both programs (see Verification). + +## Spec delta (this milestone) + +- Real `TAI_CAP_MANIFEST` replaces the one-line placeholder + (`CapAgentManifest::TAI_CAP_MANIFEST`, `--cap-manifest` prints it plus + the `print()` newline; byte-identical to `tai.py --cap-manifest`). +- All Cap classes ported: events, errors, bindings, manifest, registry, + session config, stream-json, speak session, the four drivers, answerer, + session, fleet, CLI. The old `Orchestrator` grant/check placeholder is gone. +- Layer `wire_*` (namespace `detz::wire`) supplies what Python's stdlib gave + tai.py: `Json` (json), `WireToml` (tomllib), `WireRegex` (re), `WireProc` / + `WirePty` (subprocess, PtyProc), `WireHttp` (urllib, http.client, http.server), + `WireArgParse` (argparse), `WireShlex`, `WireDifflib`, `WireHash`, `WireText`, + `WireOs`. + +## Parity table (Python -> C++) + +| Python | C++ | +|---|---| +| `CAP_VERSION` `CAP_MANIFEST_SCHEMA` `CAP_PROFILE_CODING` `CAP_MANIFEST_REVISION` `CAP_PROBE_TIMEOUT` `CAP_ERROR_CODES` `CAP_ERROR_STRINGS` `CAP_SESSION_CONFIG_ENV` `CAP_SPEAK_MAX_TURNS` `CAP_SPEAK_DIFF_MAX` `CAP_PTY_SETTLE_MS` `CAP_PTY_IDLE_MS` `CAP_PTY_SUBMIT_GAP_MS` `CAP_PTY_RESUBMITS_MAX` `CAP_PTY_SCROLLBACK` `CAP_PTY_INIT_TIMEOUT` `CAP_STREAM_LINE_MAX` `CAP_STREAM_STDERR_KEEP` `CAP_SESSION_POLL` `CAP_TURN_TIMEOUT` `CAP_WORKTREE_DIR` `CAP_ROUTE_QUEUE_MAX` `CAP_FLEET_DIFF_MAX` `CAP_MENTION_RE` `CAP_ACP_VERSION` `CAP_ACP_TERMINALS_MAX` `CAP_ACP_TERM_BUF` `CAP_ACP_TERM_WAIT_MAX` `CAP_ACP_FS_MAX` `CAP_A2A_TIMEOUT` `CAP_ANSWER_TIMEOUT` `CAP_ANSWER_PARALLEL` `CAP_ANSWER_MARKER` `CAP_ANSWER_RISKS` | `cap_constants.h` (same names) | +| `CAP_ERROR_SYMBOLS` | `capErrorNumeric()` in `cap_error.h` | +| `TAI_CAP_MANIFEST` | `CapAgentManifest::TAI_CAP_MANIFEST()` | +| `CapError` | `cap_error.h` | +| `CapEvent` | `CapEvent` (`KINDS`, `DIRECTIONS`, `REQUIRED`, `ENUMS`, `make`, `validate`, `direction`) | +| `CapBinding` | `CapBinding` (`ORDER`, `UNSUPPORTED`, `declared`, `select`) | +| `CapManifest` | `CapManifest` | +| `CapRegistry` | `CapRegistry` | +| `CapSessionConfig` | `CapSessionConfig` | +| `CapStreamJson` | `CapStreamJson` | +| `CapSpeakSession`, `cap_interrupt`, `cap_speak_main` | `CapSpeakSession`, `CapInterrupt`, `CapSpeakMain` | +| `CapDriver` | `CapDriver` (`CapDriver::create` in `cap_driver_factory.cpp`) | +| `CapPtyDriver` | `CapPtyDriver` over `WirePty` and `CapVtScreen` (port of `TuiVtScreen`, `CapScreen` seam) | +| `CapPipeReader`, `cap_proc_kill`, `cap_proc_reap`, `cap_stderr_drain` | `CapPipeReader`, `CapProcUtil`, `CapStderrDrain` | +| `CapStreamDriver` `CapAcpDriver` `CapA2aDriver` | same names | +| `CapAnswerer` | `CapAnswerer` | +| `CapTurnResult` `CapSession` `CapFleet` | same names | +| `cap_cli_main` `cap_cli_help` `cap_resolve_main` `cap_chat_main` `cap_run_main` `cap_fleet_plan` `cap_print_result` `cap_build_answerer` `cap_answer_flags` | `CapCli` static methods | + +## Event kinds + +`cap.session.ready`, `cap.text_chunk`, `cap.tool_call.start`, +`cap.tool_call.delta`, `cap.tool_call.end`, `cap.plan`, `cap.thought`, +`cap.ask_user`, `cap.permission.request`, `cap.usage`, `cap.error`, +`cap.artifact`, `cap.fs.change`, `cap.pty.raw_bytes`, +`cap.user_input.inject`, `cap.cancel`, `cap.session.config`, +`cap.ask_user.answer`, `cap.permission.response`. + +## Errors + +Symbols: `cap_manifest_missing`, `cap_session_config_missing`, +`cap_unsupported_capability`, `cap_budget_exceeded`, `cap_invalid_answer`, +`cap_unknown_permission`, `cap_queued_input_unsupported`, +`cap_cancel_unsupported`, `cap_binding_unavailable`, +`cap_profile_unsupported`, `cap_scope_violation`, `cap_path_not_found`, +`cap_io_error`, `cap_terminal_unavailable`, `cap_terminal_killed`. +String codes: `rate_limit`, `auth_error`, `tool_timeout`, `context_overflow`, +`model_unavailable`, `pty_died`, `parse_failed`, `git_dirty`, `test_failed`, +`merge_conflict`, `language_server_down`. + +## Seams (host-provided) + +`CapAgentPort` / `CapSpeakHost` (the agent for `--cap-speak`), `WireLog` +(debug log sink), `WireApp::setVersion` (VERSION on the wire), +`CapPtyDriver::setScreenFactory` (swap the VT screen). Details and the exact +hooks: `docs/wiring/capmcp.md`. + +## Differences from Python (all declared) + +- `CapAnswerer` runs `[binary, --auto, prompt]` directly; Python ran + `[sys.executable, tai.py, ...]`. Default binary is `/proc/self/exe`. +- `CapVtScreen` parses SGR but does not store colors/attributes (text-only + consumer) and keeps no scrollback history. +- Regex: ECMAScript engine with Python syntax translation. Python-only syntax + beyond named groups, `\A`, `\Z`, `$`, bare `{`/`}`, leading `(?i)` is not + translated; compile error texts differ from `re.error`. +- argparse help is plain (no 3.14 color). +- Dates in TOML come back as strings. +- Terminal `terminal/output` keeps the `read(65536)` blocking-chunk behavior. +- Signals: SIGINT/SIGTERM set a flag polled by waits (`CapInterrupt`). + +## Verification + +`tests/capmcp_diff.py` (JSON, TOML, str(), float repr, regex vs CPython), +`tests/capmcp_cli_diff.py` (`--cap-manifest`, `--cap resolve|chat|run` incl. +stream-json, PTY, ACP and A2A fake agents, argparse errors at four widths vs +`tai.py`), `tests/capmcp_unit.cpp` (tables and drift check). diff --git a/taicpp/docs/mcp.md b/taicpp/docs/mcp.md new file mode 100644 index 0000000..8bf4833 --- /dev/null +++ b/taicpp/docs/mcp.md @@ -0,0 +1,64 @@ +# mcp (taicpp) + +Port of the MCP section of `../tai.py`, normative text in `../mcp.md`. +Namespace `detz::mcp`, sources in `src/mcp/`. Reuses the `detz::wire` layer +from `src/cap/` (see `docs/cap.md`). + +## Spec delta (this milestone) + +The placeholder `serve_list`/`serve_call` is replaced by the real client, +OAuth, bridge, vault tables, tool backends and server. + +## Parity table + +| Python | C++ | +|---|---| +| `MCP_PROTOCOL_VERSION` `MCP_CLIENT_NAME` `MCP_TOOL_PREFIX` `MCP_RESOLVER_TIMEOUT` `MCP_RESOLVER_STEPS` `MCP_OAUTH_TIMEOUT` `MCP_RESOLVER_BRIEF` `MCP_CONNECT_TIMEOUT` `MCP_CALL_TIMEOUT` `MCP_RESULT_MAX` `MCP_HTTP_MAX_BODY` `MCP_MAX_SERVERS` `MCP_MAX_TOOLS` `MCP_SESSION_IDLE` `MCP_CHAT_TIMEOUT` `MCP_DEFAULT_PORT` `MCP_SERVER_RE` `MCP_VAR_RE` `MCP_GITHUB_RE` | `mcp_constants.h` | +| `McpError` `McpJsonRpcError` `McpHttpError` | `mcp_error.h` | +| `mcp_sanitize_name` `mcp_github_target` `mcp_expand_vars` `mcp_rewrite_placeholders` `mcp_entry_from_mapping` `mcp_normalize` `mcp_tool_hash` `mcp_bridge_name` | `McpNormalizer` | +| `McpStdioTransport` `McpHttpTransport` `McpClient` | same names, `McpTransport` interface | +| `McpOAuthCallback` `McpOAuth` | same names | +| `McpBridge` | `McpBridge` over `McpHost` | +| `Store.mcp_*` | `McpStore` interface, `McpSqliteStore` (four tables) | +| `Tools.run_mcp_*`, `mcp_resolve_github`, `mcp_resolve_agent`, `mcp_schemas` | `McpTools` | +| `McpServeSession` `McpServeContext` `mcp_serve_tools` `mcp_serve_handler` `mcp_serve_main` | `McpServeSession` `McpServeContext` `McpServeHandler` `McpServeMain` | + +## Wire subset + +Methods: `initialize`, `notifications/initialized`, `ping`, `tools/list`, +`tools/call`, `notifications/cancelled`. Errors `-32601`, `-32602`, +`-32603`. Protocol `2025-11-25`. + +## Caps + +`MCP_CONNECT_TIMEOUT` 20, `MCP_CALL_TIMEOUT` 60, `MCP_RESULT_MAX` 65536, +`MCP_HTTP_MAX_BODY` 10485760, `MCP_MAX_SERVERS` 32, `MCP_MAX_TOOLS` 100, +`MCP_SESSION_IDLE` 3600, `MCP_CHAT_TIMEOUT` 600, `MCP_RESOLVER_TIMEOUT` 300, +`MCP_RESOLVER_STEPS` 12, `MCP_OAUTH_TIMEOUT` 300. + +## Vault tables + +`mcp_servers`, `mcp_server_profiles`, `mcp_tool_pins`, `mcp_server_tools` +(`McpSqliteStore::ensureSchema`). + +## Agent tools + +`mcp_add`, `mcp_list`, `mcp_enable`, `mcp_disable`, `mcp_remove`, +`mcp_tools`, `mcp_call`, `mcp_login` (`McpTools::dispatch`). Served extras: +`chat`, `completions`. + +## Differences from Python (declared) + +- `mcp_login` browser: opens `xdg-open` only when `DISPLAY`/`WAYLAND_DISPLAY` + is set; Python's `webbrowser` has a longer fallback list. +- Transport error texts come from libcurl mapped to urllib wording + (`[Errno 111] Connection refused`, `timed out`); exotic failures differ. +- HTTP `Server:` header omits the Python version suffix. +- `mcp_store_tools` runs in one transaction (Python leaves partial writes + pending on a duplicate tool name). +- stdio stderr is a pipe nobody drains, exactly as in tai.py (the doc says + drained, the code does not). + +## Verification + +`tests/capmcp_unit.cpp` (vault tables, normalizer, served endpoint in process). No differential run against tai.py yet for the client, OAuth, bridge and server (see docs/wiring/capmcp.md, Known gaps). diff --git a/taicpp/src/cap/cap_a2a_driver.cpp b/taicpp/src/cap/cap_a2a_driver.cpp new file mode 100644 index 0000000..e1f5873 --- /dev/null +++ b/taicpp/src/cap/cap_a2a_driver.cpp @@ -0,0 +1,511 @@ +#include "cap_a2a_driver.h" + +#include "cap_constants.h" +#include "cap_event.h" +#include "cap_py.h" +#include "wire_hash.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::HeaderList; +using detz::wire::HttpReply; +using detz::wire::UrlError; +using detz::wire::WireHash; +using detz::wire::WireHttp; +using detz::wire::WireHttpStream; +using detz::wire::WireOs; + +CapA2aDriver::CapA2aDriver(const CapManifest& manifest, std::string urn, std::string cwd, std::string model, + std::map env) + : CapDriver(manifest, std::move(urn), std::move(cwd), std::move(model), std::move(env)) { + Json served = manifest_.get("fast_path.a2a_serve_at"); + endpoint_ = WireText::rstripChars(served.truthy() ? CapPy::str(served) : "", "/"); +} + +CapA2aDriver::~CapA2aDriver() { + try { + stopStream(); + } catch (...) { + } +} + +std::string CapA2aDriver::flavor() const { + std::lock_guard lock(mu_); + return flavor_; +} + +int CapA2aDriver::ignoredFrames() const { + std::lock_guard lock(mu_); + return ignoredFrames_; +} + +CapA2aDriver::Target CapA2aDriver::splitEndpoint() const { + WireHttp::Url parsed = WireHttp::split(endpoint_); + if (parsed.scheme != "http" && parsed.scheme != "https") + throw CapError("cap_binding_unavailable", "a2a endpoint must be http(s): " + WireText::repr(endpoint_)); + Target t; + t.scheme = parsed.scheme; + t.base = parsed.scheme + "://" + parsed.netloc; + t.prefix = WireText::rstripChars(parsed.path, "/"); + return t; +} + +HttpReply CapA2aDriver::exchange(const std::string& method, const std::string& path, const Json* body) { + const Target t = splitEndpoint(); + HeaderList headers = {{"Content-Type", "application/json"}, {"Accept", "application/json"}}; + const std::string payload = body ? body->dumps() : std::string(); + try { + return WireHttp::exchange(method, t.base + t.prefix + path, headers, body ? &payload : nullptr, CAP_A2A_TIMEOUT); + } catch (const UrlError& exc) { + throw CapError("cap_binding_unavailable", + "a2a " + method + " " + path + " failed: " + CapPy::shortError(exc.what(), "OSError")); + } +} + +std::unique_ptr CapA2aDriver::openStream(const std::string& method, const std::string& path, const Json* body) { + const Target t = splitEndpoint(); + HeaderList headers = {{"Content-Type", "application/json"}, {"Accept", "text/event-stream, application/json"}}; + const std::string payload = body ? body->dumps() : std::string(); + try { + return WireHttpStream::open(method, t.base + t.prefix + path, headers, body ? &payload : nullptr, CAP_A2A_TIMEOUT); + } catch (const UrlError& exc) { + throw CapError("cap_binding_unavailable", + "a2a " + method + " " + path + " failed: " + CapPy::shortError(exc.what(), "OSError")); + } +} + +Json CapA2aDriver::getJson(const std::string& path) { + HttpReply reply = exchange("GET", path, nullptr); + if (reply.status != 200) + throw CapError("cap_binding_unavailable", "a2a GET " + path + " returned " + std::to_string(reply.status)); + std::string text, message; + if (!WireText::decodeStrict(reply.body, text, message)) + throw CapError("cap_binding_unavailable", "a2a GET " + path + " is not JSON: " + message); + try { + return Json::parse(text); + } catch (const detz::wire::JsonError& exc) { + throw CapError("cap_binding_unavailable", "a2a GET " + path + " is not JSON: " + exc.what()); + } +} + +void CapA2aDriver::spawn() { + if (endpoint_.empty()) throw CapError("cap_binding_unavailable", "manifest has no fast_path.a2a_serve_at"); + Json card = getJson("/.well-known/agent-card.json"); + if (!card.isObject()) throw CapError("cap_binding_unavailable", "A2A card is not a JSON object"); + Json extensions = card.get("extensions"); + bool found = false; + if (extensions.isArray()) + for (const Json& ext : extensions.items()) + if (ext.isObject() && ext.get("uri") == Json("cap-protocol/v1")) found = true; + if (!found) throw CapError("cap_binding_unavailable", "A2A card does not advertise cap-protocol/v1"); + std::lock_guard lock(mu_); + cardName_ = CapPy::orStr(card.get("name"), ""); + hasProc_ = true; + setState("ready"); + emitEvent(CapEvent::make("cap.session.ready", Json::object({{"version", Json(std::string(CAP_VERSION))}, + {"session_id", Json(urn_)}}))); +} + +Json CapA2aDriver::messageBody(const Json& parts) { + Json message = Json::object({{"role", Json("user")}, {"messageId", Json(WireHash::uuid4Hex())}, {"parts", parts}}); + Json body = Json::object(); + if (contextId_.truthy()) message.set("contextId", contextId_); + body.set("message", message); + if (contextId_.truthy()) { + // body keeps "message" first; contextId follows it, like the dict in tai.py + body.set("contextId", contextId_); + } + if (taskId_.truthy()) body.set("taskId", taskId_); + return body; +} + +void CapA2aDriver::sendText(const std::string& text) { + std::lock_guard lock(mu_); + preSend(); + ++msgSeq_; + turnMsgId_ = "msg_" + std::to_string(msgSeq_); + clearAsks(); + setState("working"); + postMessage(Json::array({Json::object({{"kind", Json("text")}, {"text", Json(text)}})})); +} + +void CapA2aDriver::postMessage(const Json& parts) { + const Json body = messageBody(parts); + std::vector attempts; + if (!flavor_.empty()) attempts.push_back(flavor_); + else attempts = {"draft", "stream", "send"}; + std::optional lastError; + for (const std::string& flavor : attempts) { + try { + if (flavor == "draft") startSse("/message/send", body); + else if (flavor == "stream") startSse("/message:stream", body); + else postBlocking("/message:send", body); + flavor_ = flavor; + return; + } catch (const CapError& exc) { + lastError = exc; + if (!flavor_.empty() || WireText::contains(exc.what(), " failed: ")) throw; + } + } + throw *lastError; +} + +void CapA2aDriver::startSse(const std::string& path, const Json& body) { + std::unique_ptr stream = openStream("POST", path, &body); + if (stream->status() == 404 || stream->status() == 405 || stream->status() == 410 || stream->status() != 200) { + const int status = stream->status(); + stream->close(); + throw CapError("cap_binding_unavailable", "a2a POST " + path + " returned " + std::to_string(status)); + } + stopStream(); + turnEvents_ = 0; + stream_ = std::move(stream); + WireHttpStream* raw = stream_.get(); + sseThread_ = std::thread([this, raw]() { ssePump(raw); }); +} + +void CapA2aDriver::postBlocking(const std::string& path, const Json& body) { + HttpReply reply = exchange("POST", path, &body); + if (reply.status == 404 || reply.status == 405 || reply.status == 410 || reply.status != 200) + throw CapError("cap_binding_unavailable", "a2a POST " + path + " returned " + std::to_string(reply.status)); + Json payload; + { + std::string text, message; + if (!WireText::decodeStrict(reply.body, text, message)) + throw CapError("cap_binding_unavailable", "a2a response is not JSON: " + message); + try { + payload = Json::parse(text); + } catch (const detz::wire::JsonError& exc) { + throw CapError("cap_binding_unavailable", std::string("a2a response is not JSON: ") + exc.what()); + } + } + if (payload.isObject() && payload.has("error")) + throw CapError("cap_binding_unavailable", "a2a error: " + WireText::head(CapPy::str(payload.get("error")), 500)); + Json result = payload.isObject() ? payload.get("result", payload) : Json::object(); + if (result.isObject()) { + captureIds(result); + mapPayload(result); + if (state() == "working") setState("done"); + } +} + +void CapA2aDriver::ssePump(WireHttpStream* stream) { + std::vector buf; + auto flush = [&]() { + std::lock_guard lock(qmu_); + sseLines_.emplace_back("data", WireText::join(buf, "\n")); + buf.clear(); + }; + std::string line; + while (stream->readLine(line)) { + std::string text = WireText::decodeReplace(line); + text = WireText::rstripChars(text, "\n"); + text = WireText::rstripChars(text, "\r"); + if (text.empty()) { + if (!buf.empty()) flush(); + continue; + } + if (text[0] == ':') continue; + if (WireText::startsWith(text, "data:")) buf.push_back(WireText::lstrip(text.substr(5))); + } + if (!buf.empty()) flush(); + std::lock_guard lock(qmu_); + sseLines_.emplace_back("end", ""); +} + +void CapA2aDriver::stopStream() { + if (stream_) stream_->close(); + if (sseThread_.joinable()) sseThread_.join(); + stream_.reset(); + std::lock_guard lock(qmu_); + sseLines_.clear(); +} + +void CapA2aDriver::drainQueue() { + while (true) { + std::pair item; + { + std::lock_guard lock(qmu_); + if (sseLines_.empty()) return; + item = sseLines_.front(); + sseLines_.pop_front(); + } + if (item.first == "data") mapSseData(item.second); + else onStreamEnd(); + } +} + +std::vector CapA2aDriver::poll(double timeout) { + { + std::lock_guard lock(mu_); + const std::string st = state(); + if (!hasProc_ || st == "failed" || st == "closed") return drain(); + drainQueue(); + if (!(timeout > 0 && state() == "working")) return drain(); + } + // Python sleeps min(remaining, 0.05) once, then polls again without waiting. + WireOs::sleep(std::min(timeout, 0.05)); + std::lock_guard lock(mu_); + const std::string st = state(); + if (!hasProc_ || st == "failed" || st == "closed") return drain(); + drainQueue(); + return drain(); +} + +void CapA2aDriver::mapSseData(const std::string& data) { + Json payload; + try { + payload = Json::parse(data); + } catch (const detz::wire::JsonError&) { + ++ignoredFrames_; + return; + } + if (payload.isObject()) mapPayload(payload); + else ++ignoredFrames_; +} + +void CapA2aDriver::captureIds(const Json& payload) { + Json task = payload.get("task"); + if (task.isObject()) { + Json id = task.get("id"); + if (!id.truthy()) id = task.get("taskId"); + if (!id.truthy()) id = taskId_; + taskId_ = id; + Json ctx = task.get("contextId"); + contextId_ = ctx.truthy() ? ctx : contextId_; + } + Json message = payload.get("message"); + if (message.isObject()) { + Json ctx = message.get("contextId"); + contextId_ = ctx.truthy() ? ctx : contextId_; + } +} + +void CapA2aDriver::mapPayload(const Json& payload) { + captureIds(payload); + Json kind = payload.get("kind"); + if (kind.isString() && WireText::startsWith(kind.str(), "cap.")) { + try { + CapEvent::validate(payload); + } catch (const ValueError&) { + ++ignoredFrames_; + return; + } + ++turnEvents_; + emitEvent(payload); + return; + } + if (mapDataParts(payload)) return; + Json task = payload.get("task"); + Json state; + bool haveState = false; + if (task.isObject()) { + Json status = task.get("status"); + if (!status.truthy()) status = Json::object(); + state = status.get("state"); + haveState = !state.isNull(); + } + if (haveState) mapTaskState(payload, state); + auto textAndId = payloadText(payload); + if (!textAndId.first.empty()) { + ++turnEvents_; + emitEvent(CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json(textAndId.second)}, + {"text", Json(textAndId.first)}, + {"channel", Json("assistant")}}))); + return; + } + Json artifact = payload.get("artifact"); + if (artifact.isObject()) { + mapPlainArtifact(artifact); + return; + } + if (!haveState) ++ignoredFrames_; +} + +bool CapA2aDriver::mapDataParts(const Json& payload) { + std::vector buckets; + Json message = payload.get("message"); + if (message.isObject() && message.get("parts").isArray()) + for (const Json& p : message.get("parts").items()) buckets.push_back(p); + Json task = payload.get("task"); + if (task.isObject()) { + Json status = task.get("status"); + if (!status.truthy()) status = Json::object(); + Json smsg = status.get("message"); + if (smsg.isObject() && smsg.get("parts").isArray()) + for (const Json& p : smsg.get("parts").items()) buckets.push_back(p); + } + Json artifact = payload.get("artifact"); + if (artifact.isObject() && artifact.get("parts").isArray()) + for (const Json& p : artifact.get("parts").items()) buckets.push_back(p); + for (const Json& part : buckets) { + if (!part.isObject()) continue; + Json meta = part.get("_meta"); + if (!meta.truthy()) meta = Json::object(); + Json capKind; + if (meta.isObject()) { + Json capObj = meta.get("cap"); + if (!capObj.truthy()) capObj = Json::object(); + if (capObj.isObject()) capKind = capObj.get("kind"); + } + if (!capKind.isString() || !WireText::startsWith(capKind.str(), "cap.")) continue; + Json data = part.get("data"); + if (!data.isObject()) continue; + Json event = data; + event.setDefault("kind", capKind); + try { + CapEvent::validate(event); + } catch (const ValueError&) { + continue; + } + ++turnEvents_; + emitEvent(event); + return true; + } + return false; +} + +std::pair CapA2aDriver::payloadText(const Json& payload) { + Json message = payload.get("message"); + if (!message.isObject()) { + Json task = payload.get("task"); + Json status = task.isObject() ? task.get("status") : Json(); + message = status.isObject() ? status.get("message") : Json(); + } + if (!message.isObject()) return {"", "a2a-message"}; + Json parts = message.get("parts"); + if (parts.isArray()) { + for (const Json& part : parts.items()) { + if (!part.isObject()) continue; + Json kind = part.has("kind") ? part.get("kind") : part.get("type"); + if (kind == Json("text") && part.get("text").truthy()) { + Json id = message.get("messageId"); + if (!id.truthy()) id = message.get("message_id"); + return {CapPy::str(part.get("text")), id.truthy() ? CapPy::str(id) : "a2a-message"}; + } + } + } + return {"", "a2a-message"}; +} + +void CapA2aDriver::mapTaskState(const Json& payload, const Json& state) { + if (state == Json("submitted") || state == Json("working")) return; + if (state == Json("input-required")) { + auto tx = payloadText(payload); + ++askSeq_; + const std::string askId = "ask_" + std::to_string(askSeq_); + OpenAsk ask; + ask.kind = "a2a_ask"; + putAsk(askId, ask); + emitEvent(CapEvent::make("cap.ask_user", Json::object({{"ask_id", Json(askId)}, + {"prompt", Json(tx.first.empty() ? "agent needs input" : tx.first)}, + {"form", Json::object({{"type", Json("string")}})}}))); + setState("input_required"); + return; + } + if (state == Json("completed") || state == Json("canceled") || state == Json("rejected")) { + setState("done"); + return; + } + if (state == Json("failed")) { + Json task = payload.get("task"); + Json message; + if (task.isObject()) { + Json status = task.get("status"); + message = status.isObject() ? status.get("message") : Json(); + } + std::string text; + if (message.isString()) { + text = message.str(); + } else { + auto tx = payloadText(payload); + text = tx.first.empty() ? "A2A task failed" : tx.first; + } + ++turnEvents_; + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json("error")}, + {"message", Json(WireText::head(text, 2000))}, + {"retryable", Json(false)}, + {"details", Json::object({{"task_state", state}})}}))); + setState("done"); + return; + } + ++ignoredFrames_; +} + +void CapA2aDriver::mapPlainArtifact(const Json& artifact) { + std::vector texts; + Json parts = artifact.get("parts"); + if (parts.isArray()) + for (const Json& part : parts.items()) + if (part.isObject()) { + Json kind = part.has("kind") ? part.get("kind") : part.get("type"); + if (kind == Json("text") && part.get("text").truthy()) texts.push_back(CapPy::str(part.get("text"))); + } + const std::string content = WireText::join(texts, "\n"); + ++askSeq_; + ++turnEvents_; + emitEvent(CapEvent::make( + "cap.artifact", + Json::object({{"artifact_id", Json(CapPy::orStr(artifact.get("artifactId"), "a2a_" + std::to_string(askSeq_)))}, + {"mime_type", Json(CapPy::orStr(artifact.get("mimeType"), "application/octet-stream"))}, + {"name", Json(CapPy::orStr(artifact.get("name"), "artifact"))}, + {"size", Json(static_cast(content.size()))}, + {"content", Json(content)}}))); +} + +void CapA2aDriver::onStreamEnd() { + if (state() != "working") return; + if (turnEvents_) { + setState("done"); + } else { + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json("error")}, + {"message", Json("a2a stream ended without events")}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + setState("failed"); + } +} + +void CapA2aDriver::answerOpen(const std::string& askId, const OpenAsk& ask, const Json& value) { + if (ask.kind != "a2a_ask") throw CapError("cap_invalid_answer", "unknown question kind"); + Json event = CapEvent::make("cap.ask_user.answer", Json::object({{"ask_id", Json(askId)}, {"value", value}})); + postMessage(Json::array({Json::object({{"kind", Json("data")}, + {"data", event}, + {"_meta", Json::object({{"cap", Json::object({{"kind", Json("cap.ask_user.answer")}})}})}})})); + if (openAskCount() == 0 && state() == "input_required") setState("working"); +} + +void CapA2aDriver::cancel(const std::string& scope) { + std::unique_lock lock(mu_); + if (!hasProc_) throw CapError("cap_binding_unavailable", "session never started"); + if (scope == "session") { + lock.unlock(); + close(); + return; + } + if (cancelMode() == "none") throw CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none"); + try { + exchange("POST", "/tasks/cancel", nullptr); + } catch (const CapError&) { + if (taskId_.truthy()) { + try { + exchange("POST", "/tasks/" + CapPy::str(taskId_) + ":cancel", nullptr); + } catch (const CapError&) { + } + } + } + stopStream(); + taskId_ = Json(); + const std::string st = state(); + if (st != "failed" && st != "closed") setState("ready"); +} + +void CapA2aDriver::close() { + std::lock_guard lock(mu_); + stopStream(); + hasProc_ = false; + if (state() != "failed") setState("closed"); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_a2a_driver.h b/taicpp/src/cap/cap_a2a_driver.h new file mode 100644 index 0000000..5b81d42 --- /dev/null +++ b/taicpp/src/cap/cap_a2a_driver.h @@ -0,0 +1,84 @@ +#pragma once +// CapA2aDriver: orchestrator-side A2A binding (spec section 6.5, 7.8, C.4): +// plain http(s) client. Tries draft endpoints first (what CAP peers speak), +// then current A2A spellings; the SSE reader is the one thread (blocking +// reads), everything else pumps through poll(). Answers travel back as data +// parts with the _meta.cap.kind marker, reference-literal. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cap_driver.h" +#include "wire_http.h" + +namespace detz::cap { + +class CapA2aDriver : public CapDriver { + public: + CapA2aDriver(const CapManifest& manifest, std::string urn = "cap://agent-1", std::string cwd = "", + std::string model = "", std::map env = {}); + ~CapA2aDriver() override; + + void sendText(const std::string& text) override; + std::vector poll(double timeout = 0.0) override; + void cancel(const std::string& scope = "current_turn") override; + void close() override; + + const std::string& endpoint() const { return endpoint_; } + const std::string& cardName() const { return cardName_; } + // "" until a post succeeded, then draft / stream / send (sticky). + std::string flavor() const; + int ignoredFrames() const; + + protected: + void spawn() override; + bool hasProc() const override { return hasProc_; } + void answerOpen(const std::string& askId, const OpenAsk& ask, const Json& value) override; + + private: + struct Target { + std::string scheme, prefix; + std::string base; // scheme://netloc + }; + Target splitEndpoint() const; + detz::wire::HttpReply exchange(const std::string& method, const std::string& path, const Json* body); + std::unique_ptr openStream(const std::string& method, const std::string& path, const Json* body); + Json getJson(const std::string& path); + Json messageBody(const Json& parts); + void postMessage(const Json& parts); + void startSse(const std::string& path, const Json& body); + void postBlocking(const std::string& path, const Json& body); + void ssePump(detz::wire::WireHttpStream* stream); + void stopStream(); + void mapSseData(const std::string& data); + void captureIds(const Json& payload); + void mapPayload(const Json& payload); + bool mapDataParts(const Json& payload); + static std::pair payloadText(const Json& payload); + void mapTaskState(const Json& payload, const Json& state); + void mapPlainArtifact(const Json& artifact); + void onStreamEnd(); + void drainQueue(); + + std::string endpoint_; + std::string cardName_; + std::string flavor_; + int ignoredFrames_{0}; + std::string turnMsgId_{"msg_boot"}; + Json contextId_; + Json taskId_; + bool hasProc_{false}; + int turnEvents_{0}; + + std::mutex qmu_; + std::deque> sseLines_; + std::unique_ptr stream_; + std::thread sseThread_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_acp_driver.cpp b/taicpp/src/cap/cap_acp_driver.cpp new file mode 100644 index 0000000..a1f5909 --- /dev/null +++ b/taicpp/src/cap/cap_acp_driver.cpp @@ -0,0 +1,979 @@ +#include "cap_acp_driver.h" + +#include + +#include +#include + +#include "cap_constants.h" +#include "cap_event.h" +#include "cap_py.h" +#include "wire_app.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::OsError; +using detz::wire::WireApp; +using detz::wire::WireOs; +using detz::wire::WireProc; + +namespace { + +std::string signalName(int signo) { + static const char* names[] = {nullptr, "SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGTRAP", "SIGABRT", + "SIGBUS", "SIGFPE", "SIGKILL", "SIGUSR1", "SIGSEGV", "SIGUSR2", "SIGPIPE", + "SIGALRM", "SIGTERM", "SIGSTKFLT", "SIGCHLD", "SIGCONT", "SIGSTOP", "SIGTSTP", + "SIGTTIN", "SIGTTOU", "SIGURG", "SIGXCPU", "SIGXFSZ", "SIGVTALRM", "SIGPROF", + "SIGWINCH", "SIGIO", "SIGPWR", "SIGSYS"}; + if (signo >= 1 && signo <= 31) return names[signo]; + if (signo == 34) return "SIGRTMIN"; + if (signo == 64) return "SIGRTMAX"; + return std::to_string(signo); +} + +std::string lowerStr(const Json& v) { return WireText::lower(v.truthy() ? CapPy::str(v) : ""); } + +// Python list slicing lines[start:end] with negative-index semantics. +std::vector pySlice(const std::vector& items, long long start, long long end, bool hasEnd) { + const long long n = static_cast(items.size()); + auto norm = [&](long long i) { + if (i < 0) i += n; + return std::max(0, std::min(i, n)); + }; + const long long a = norm(start); + const long long b = hasEnd ? norm(end) : n; + std::vector out; + for (long long i = a; i < b; ++i) out.push_back(items[static_cast(i)]); + return out; +} + +} // namespace + +CapAcpDriver::CapAcpDriver(const CapManifest& manifest, std::string urn, std::string cwd, std::string model, + std::map env, std::vector acpArgv, + std::string fsScope, bool terminal, std::function ioSink) + : CapDriver(manifest, std::move(urn), std::move(cwd), std::move(model), std::move(env)), + acpArgv_(std::move(acpArgv)), + fsScope_(std::move(fsScope)), + grantTerminal_(terminal), + ioSink_(std::move(ioSink)) {} + +CapAcpDriver::~CapAcpDriver() { + for (auto& entry : terminals_) { + entry.second->stop = true; + if (entry.second->pump.joinable()) entry.second->pump.join(); + } + if (drain_) drain_->stop(); +} + +int CapAcpDriver::ignoredFrames() const { + std::lock_guard lock(mu_); + return ignoredFrames_; +} + +std::vector CapAcpDriver::ioLog() const { + std::lock_guard lock(mu_); + return ioLog_; +} + +std::vector CapAcpDriver::stderrLines() const { + std::lock_guard lock(mu_); + return drain_ ? drain_->lines() : std::vector(); +} + +size_t CapAcpDriver::terminalCount() const { + std::lock_guard lock(mu_); + return terminals_.size(); +} + +// -------------------------------------------------------------------------- +// transport + +void CapAcpDriver::spawn() { + if (acpArgv_.empty()) + throw CapError("cap_binding_unavailable", "acp_stdio needs an explicit server argv (fleet argv or chat --argv)"); + WireProc::Options options; + options.argv = acpArgv_; + options.cwd = cwd_; + options.replaceEnv = true; + options.env = sessionEnv(); + options.in = options.out = options.err = WireProc::Stream::Pipe; + std::unique_ptr proc; + try { + proc = WireProc::spawn(options); + } catch (const OsError& exc) { + throw CapError("cap_binding_unavailable", "cannot spawn " + acpArgv_[0] + ": " + CapPy::shortError(exc.what(), "OSError")); + } + WireProc::setNonBlocking(proc->stdoutFd()); + std::lock_guard lock(mu_); + reader_ = std::make_unique(proc->stdoutFd()); + drain_ = std::make_unique(proc->stderrFd()); + proc_ = std::move(proc); + try { + handshake(); + } catch (...) { + CapProcUtil::capProcReap(*proc_, drain_.get()); + proc_.reset(); + reader_.reset(); + drain_.reset(); + throw; + } +} + +void CapAcpDriver::handshake() { + Json caps = Json::object(); + if (!fsScope_.empty()) caps.set("fs", Json::object({{"readTextFile", Json(true)}, {"writeTextFile", Json(true)}})); + if (grantTerminal_) caps.set("terminal", Json(true)); + caps.set("elicitation", Json::object({{"form", Json::object()}})); + Json initId = request("initialize", + Json::object({{"protocolVersion", Json(CAP_ACP_VERSION)}, + {"clientCapabilities", caps}, + {"clientInfo", Json::object({{"name", Json("tai")}, {"version", Json(WireApp::version())}})}})); + Json init = waitResponse(initId, "initialize"); + if (!init.isObject() || init.get("protocolVersion") != Json(CAP_ACP_VERSION)) + throw CapError("cap_binding_unavailable", + "agent speaks ACP v" + (init.isObject() ? CapPy::str(init.get("protocolVersion")) : std::string("?")) + + ", tai speaks v1"); + Json newId = request("session/new", Json::object({{"cwd", Json(cwd_)}, {"mcpServers", Json::array()}})); + Json created = waitResponse(newId, "session/new"); + if (!created.isObject() || !created.get("sessionId").truthy()) + throw CapError("cap_binding_unavailable", "agent created no session"); + acpSession_ = CapPy::str(created.get("sessionId")); + haveSession_ = true; + setState("ready"); + emitEvent(CapEvent::make("cap.session.ready", Json::object({{"version", Json(std::string(CAP_VERSION))}, + {"session_id", Json(urn_)}}))); +} + +Json CapAcpDriver::waitResponse(const Json& rpcId, const std::string& what) { + const double deadline = WireOs::now() + std::max(1.0, initTimeout()); + while (WireOs::now() < deadline) { + for (const std::string& line : reader_->readLines(0.1)) mapMessage(line); + Pending* entry = findPending(rpcId); + if (entry && entry->done) { + Pending copy = *entry; + erasePending(rpcId); + if (copy.hasError && !copy.error.isNull()) + throw CapError("cap_binding_unavailable", what + " failed: " + CapPy::str(copy.error)); + return copy.result; + } + if (state() == "failed") throw CapError("cap_binding_unavailable", "agent died during " + what); + } + throw CapError("cap_binding_unavailable", what + " timed out"); +} + +CapAcpDriver::Pending* CapAcpDriver::findPending(const Json& id) { + for (auto& entry : pending_) + if (entry.first == id) return &entry.second; + return nullptr; +} + +void CapAcpDriver::erasePending(const Json& id) { + for (auto it = pending_.begin(); it != pending_.end(); ++it) { + if (it->first == id) { + pending_.erase(it); + return; + } + } +} + +Json CapAcpDriver::request(const std::string& method, const Json& params) { + ++nextId_; + const Json rpcId(nextId_); + Pending pending; + pending.method = method; + pending_.emplace_back(rpcId, pending); + writeFrame(Json::object({{"jsonrpc", Json("2.0")}, {"id", rpcId}, {"method", Json(method)}, {"params", params}})); + return rpcId; +} + +void CapAcpDriver::notify(const std::string& method, const Json& params) { + writeFrame(Json::object({{"jsonrpc", Json("2.0")}, {"method", Json(method)}, {"params", params}})); +} + +void CapAcpDriver::writeFrame(const Json& frame) { + if (!proc_) throw CapError("cap_binding_unavailable", "agent input failed: session closed"); + try { + proc_->writeStdin(frame.dumps() + "\n"); + } catch (const OsError& exc) { + throw CapError("cap_binding_unavailable", "agent input failed: " + CapPy::shortError(exc.what(), "OSError")); + } +} + +void CapAcpDriver::respond(const Json& rpcId, const Json& result, const Json* error) { + Json frame = Json::object({{"jsonrpc", Json("2.0")}, {"id", rpcId}}); + if (!error) frame.set("result", result.isNull() ? Json::object() : result); + else frame.set("error", *error); + try { + writeFrame(frame); + } catch (const CapError&) { + } +} + +Json CapAcpDriver::rpcError(long long code, const std::string& message) { + return Json::object({{"code", Json(code)}, {"message", Json(WireText::head(message, 500))}}); +} + +void CapAcpDriver::sendText(const std::string& text) { + std::lock_guard lock(mu_); + preSend(); + ++msgSeq_; + turnMsgId_ = "msg_" + std::to_string(msgSeq_); + clearAsks(); + promptId_ = request("session/prompt", + Json::object({{"sessionId", Json(acpSession_)}, + {"prompt", Json::array({Json::object({{"type", Json("text")}, {"text", Json(text)}})})}})); + if (Pending* p = findPending(promptId_)) p->prompt = true; + setState("working"); +} + +std::vector CapAcpDriver::poll(double timeout) { + int fd = -1; + { + std::lock_guard lock(mu_); + const std::string st = state(); + if (!proc_ || st == "failed" || st == "closed") return drain(); + fd = reader_->fd(); + } + if (timeout > 0) WireProc::waitReadable(fd, timeout); + std::lock_guard lock(mu_); + { + const std::string st = state(); + if (!proc_ || st == "failed" || st == "closed") return drain(); + } + for (const std::string& line : reader_->readLines(0.0)) mapMessage(line); + if (!reader_) return drain(); + if (reader_->takeDropped()) + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json("parse_failed")}, + {"message", Json("line over " + std::to_string(CAP_STREAM_LINE_MAX) + " bytes dropped")}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + if (proc_ && reader_ && reader_->eof() && reader_->bufferEmpty() && proc_->poll()) onProcGone(); + return drain(); +} + +void CapAcpDriver::cancel(const std::string& scope) { + std::unique_lock lock(mu_); + if (!proc_) throw CapError("cap_binding_unavailable", "session never started"); + if (scope == "session") { + lock.unlock(); + close(); + return; + } + if (cancelMode() == "none") throw CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none"); + if (haveSession_) { + try { + request("session/cancel", Json::object({{"sessionId", Json(acpSession_)}})); + } catch (const CapError&) { + } + } + if (!promptId_.isNull()) { + try { + notify("$/cancel_request", Json::object({{"id", promptId_}})); + } catch (const CapError&) { + } + } + for (const auto& entry : openAsks()) { + if (entry.second.kind == "permission") { + respond(entry.second.rpcId, + Json::object({{"outcome", Json::object({{"outcome", Json("cancelled")}})}})); + eraseAsk(entry.first); + } + } +} + +void CapAcpDriver::close() { + std::lock_guard lock(mu_); + if (proc_) { + std::vector ids; + for (const auto& entry : terminals_) ids.push_back(entry.first); + for (const std::string& tid : ids) { + try { + terminalDrop(tid); + } catch (const CapError&) { + } + } + std::unique_ptr proc = std::move(proc_); + CapProcUtil::capProcReap(*proc, drain_.get()); + reader_.reset(); + } + if (state() != "failed") setState("closed"); +} + +void CapAcpDriver::onProcGone() { + if (state() == "done") { + setState("closed"); + return; + } + if (state() != "failed") { + std::string hint; + const std::vector lines = drain_ ? drain_->lines() : std::vector(); + if (!lines.empty()) hint = "; stderr: " + WireText::join(lines, " | "); + emitEvent(CapEvent::make("cap.error", + Json::object({{"code", Json("error")}, + {"message", Json(WireText::head("agent process exited unexpectedly" + hint, 2000))}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + } + setState("failed"); +} + +// -------------------------------------------------------------------------- +// frames from the agent + +void CapAcpDriver::mapMessage(const std::string& line) { + Json frame; + try { + frame = Json::parse(line); + } catch (const detz::wire::JsonError&) { + ++ignoredFrames_; + return; + } + if (!frame.isObject()) { + ++ignoredFrames_; + return; + } + if (frame.has("method")) mapIncoming(frame); + else if (frame.has("id")) mapResponse(frame); + else ++ignoredFrames_; +} + +void CapAcpDriver::mapResponse(const Json& frame) { + const Json rpcId = frame.get("id"); + Pending* entry = findPending(rpcId); + if (!entry) { + ++ignoredFrames_; + return; + } + entry->done = true; + if (frame.has("error")) { + entry->hasError = true; + entry->error = frame.get("error"); + } else { + entry->result = frame.get("result"); + } + if (entry->prompt) { + promptId_ = Json(); + if (frame.has("error")) + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json("error")}, + {"message", Json("prompt failed: " + WireText::head(CapPy::str(frame.get("error")), 1000))}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + setState("done"); + } +} + +void CapAcpDriver::mapIncoming(const Json& frame) { + const Json method = frame.get("method"); + Json params = frame.get("params"); + if (!params.truthy()) params = Json::object(); + const Json rpcId = frame.get("id"); + if (method == Json("session/update") && rpcId.isNull()) { + Json update = params.get("update"); + mapUpdate(update.truthy() ? update : Json::object()); + return; + } + if (rpcId.isNull()) return; + bool known = true; + Served served; + try { + served = serve(method.isString() ? method.str() : method.pyStr(), params, rpcId, known); + } catch (const CapError& exc) { + Json err = rpcError(exc.numeric ? exc.numeric : -32603, exc.what()); + respond(rpcId, Json(), &err); + return; + } + if (!known) { + Json err = rpcError(-32601, "unknown method " + CapPy::str(method)); + respond(rpcId, Json(), &err); + return; + } + if (!served.deferred) respond(rpcId, served.value); +} + +CapAcpDriver::Served CapAcpDriver::serve(const std::string& method, const Json& params, const Json& rpcId, bool& known) { + known = true; + if (method == "session/request_permission") return servePermission(params, rpcId); + if (method == "elicitation/create") return serveElicit(params, rpcId); + if (method == "fs/read_text_file") return serveFsRead(params); + if (method == "fs/write_text_file") return serveFsWrite(params); + if (method == "terminal/create") return serveTermCreate(params); + if (method == "terminal/output") return serveTermOutput(params); + if (method == "terminal/wait_for_exit") return serveTermWait(params); + if (method == "terminal/kill") return serveTermKill(params); + if (method == "terminal/release") return serveTermRelease(params); + if (method == "_cap/fs.list") return serveFsList(params); + if (method == "_cap/fs.exists") return serveFsExists(params); + if (method == "_cap/fs.delete") return serveFsDelete(params); + if (method == "_cap/user_io.show") return serveIoShow(params); + if (method == "_cap/user_io.input") return serveIoInput(params, rpcId); + if (method == "_cap/notify") return serveNotify(params); + known = false; + return Served(); +} + +void CapAcpDriver::mapUpdate(const Json& update) { + const Json kind = update.get("sessionUpdate"); + if (kind == Json("agent_message_chunk")) { + Json content = update.get("content"); + if (!content.truthy()) content = Json::object(); + if (content.isObject() && content.get("type") == Json("text") && content.get("text").truthy()) { + emitEvent(CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json(turnMsgId_)}, + {"text", content.get("text")}, + {"channel", Json("assistant")}}))); + } else { + ++ignoredFrames_; + } + } else if (kind == Json("agent_thought_chunk")) { + Json content = update.get("content"); + if (!content.truthy()) content = Json::object(); + Json text = content.isObject() ? content.get("text") : Json(); + if (text.truthy()) emitEvent(CapEvent::make("cap.thought", Json::object({{"message_id", Json(turnMsgId_)}, {"text", text}}))); + else ++ignoredFrames_; + } else if (kind == Json("tool_call")) { + Json id = update.get("toolCallId"); + Json title = update.get("title"); + Json k = update.get("kind"); + emitEvent(CapEvent::make( + "cap.tool_call.start", + Json::object({{"call_id", Json(id.truthy() ? CapPy::str(id) : "c" + std::to_string(callSeq_ + 1))}, + {"name", title.truthy() ? title : (k.truthy() ? k : Json("tool"))}, + {"input", Json::object()}}))); + } else if (kind == Json("tool_call_update")) { + Json statusJson = update.get("status"); + const std::string status = statusJson.truthy() ? CapPy::str(statusJson) : ""; + const std::string text = updateText(update.get("content")); + Json id = update.get("toolCallId"); + const std::string callId = id.truthy() ? CapPy::str(id) : ""; + if (status == "completed" || status == "failed") + emitEvent(CapEvent::make("cap.tool_call.end", Json::object({{"call_id", Json(callId)}, + {"output", Json(text)}, + {"is_error", Json(status == "failed")}, + {"duration_ms", Json(0)}}))); + else + emitEvent(CapEvent::make("cap.tool_call.delta", Json::object({{"call_id", Json(callId)}, {"output_chunk", Json(text)}}))); + } else if (kind == Json("plan")) { + Json entries = Json::array(); + Json source = update.get("entries"); + if (source.isArray()) { + long long nth = 0; + for (const Json& entry : source.items()) { + if (entry.isObject()) { + Json statusJson = entry.get("status"); + const std::string status = statusJson.truthy() ? CapPy::str(statusJson) : "pending"; + Json id = entry.get("id"); + entries.push(Json::object( + {{"id", Json(id.truthy() ? CapPy::str(id) : "p" + std::to_string(nth))}, + {"content", Json(WireText::head(CapPy::orStr(entry.get("content"), ""), 2000))}, + {"status", Json(status == "pending" || status == "in_progress" || status == "completed" ? status : "pending")}})); + } + ++nth; + } + } + if (!entries.items().empty()) emitEvent(CapEvent::make("cap.plan", Json::object({{"entries", entries}}))); + else ++ignoredFrames_; + } else if (kind == Json("usage_update")) { + Json usage = update.get("usage"); + if (!usage.truthy()) usage = update; + long long totalIn = 0, totalOut = 0; + bool ok = true; + Json inRaw = usage.get("inputTokens", usage.get("input_tokens", Json(0))); + Json outRaw = usage.get("outputTokens", usage.get("output_tokens", usage.get("total_tokens", Json(0)))); + if (inRaw.truthy()) ok = CapPy::toInt(inRaw, totalIn); + if (ok && outRaw.truthy()) ok = CapPy::toInt(outRaw, totalOut); + if (!ok) { + ++ignoredFrames_; + return; + } + emitEvent(CapEvent::make("cap.usage", Json::object({{"model_id", Json(!model_.empty() ? model_ : manifest_.name())}, + {"input_tokens", Json(std::max(0, totalIn - lastIn_))}, + {"output_tokens", Json(std::max(0, totalOut - lastOut_))}}))); + lastIn_ = totalIn; + lastOut_ = totalOut; + } else { + ++ignoredFrames_; + } +} + +std::string CapAcpDriver::updateText(const Json& content) { + if (content.isString()) return WireText::head(content.str(), 2000); + std::vector parts; + if (content.isArray()) { + for (const Json& block : content.items()) + if (block.isObject() && block.get("type") == Json("text") && block.get("text").truthy()) + parts.push_back(CapPy::str(block.get("text"))); + } + return WireText::head(WireText::join(parts, "\n"), 2000); +} + +// -------------------------------------------------------------------------- +// served methods + +CapAcpDriver::Served CapAcpDriver::servePermission(const Json& params, const Json& rpcId) { + Json call = params.get("toolCall"); + if (!call.truthy()) call = Json::object(); + Json options = params.get("options"); + if (!options.truthy()) options = Json::array(); + const std::string kind = lowerStr(call.get("kind")); + std::string risk = "low"; + if (kind == "delete" || kind == "execute") risk = "high"; + else if (kind == "edit" || kind == "write") risk = "medium"; + ++askSeq_; + const std::string reqId = "perm_" + std::to_string(askSeq_); + OpenAsk ask; + ask.kind = "permission"; + ask.rpcId = rpcId; + ask.extra = options; + putAsk(reqId, ask); + Json title = call.get("title"); + Json callKind = call.get("kind"); + const std::string scope = kind == "execute" ? "execute" + : (kind == "edit" || kind == "write" || kind == "delete") ? "write" + : "read"; + emitEvent(CapEvent::make( + "cap.permission.request", + Json::object({{"req_id", Json(reqId)}, + {"tool", title.truthy() ? title : (callKind.truthy() ? callKind : Json("tool"))}, + {"intent", Json::object({{"toolCallId", call.get("toolCallId")}, + {"kind", call.get("kind")}, + {"content", Json(WireText::head(CapPy::orStr(call.get("content"), ""), 1000))}})}, + {"scope", Json(scope)}, + {"risk_level", Json(risk)}}))); + setState("input_required"); + Served out; + out.deferred = true; + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveElicit(const Json& params, const Json& rpcId) { + Served out; + if (params.get("mode") != Json("form")) { + out.value = Json::object({{"action", Json("decline")}}); + return out; + } + Json schema = params.get("requestedSchema"); + if (!schema.truthy()) schema = Json::object({{"type", Json("string")}}); + ++askSeq_; + const std::string askId = "ask_" + std::to_string(askSeq_); + OpenAsk ask; + ask.kind = "elicit"; + ask.rpcId = rpcId; + ask.extra = schema; + putAsk(askId, ask); + setState("input_required"); + emitEvent(CapEvent::make("cap.ask_user", Json::object({{"ask_id", Json(askId)}, + {"prompt", Json(WireText::head(CapPy::orStr(params.get("message"), "input needed"), 2000))}, + {"form", schema}}))); + out.deferred = true; + return out; +} + +void CapAcpDriver::ioLogAdd(const std::string& kind, const Json& params) { + Json entry = Json::object({{"kind", Json(kind)}, {"params", params}}); + ioLog_.push_back(entry); + if (ioSink_) { + try { + ioSink_(entry); + } catch (...) { + } + } +} + +CapAcpDriver::Served CapAcpDriver::serveIoShow(const Json& params) { + ioLogAdd("show", params); + Served out; + out.value = Json::object(); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveNotify(const Json& params) { + ioLogAdd("notify", params); + Served out; + out.value = Json::object(); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveIoInput(const Json& params, const Json& rpcId) { + ++askSeq_; + const std::string askId = "ask_" + std::to_string(askSeq_); + OpenAsk ask; + ask.kind = "input"; + ask.rpcId = rpcId; + ask.extra = params; + putAsk(askId, ask); + setState("input_required"); + emitEvent(CapEvent::make("cap.ask_user", Json::object({{"ask_id", Json(askId)}, + {"prompt", Json(WireText::head(CapPy::orStr(params.get("prompt"), "input needed"), 2000))}, + {"form", Json::object({{"type", Json("string")}})}}))); + Served out; + out.deferred = true; + return out; +} + +void CapAcpDriver::answerOpen(const std::string&, const OpenAsk& ask, const Json& value) { + if (ask.kind == "permission") { + respond(ask.rpcId, permissionOutcome(value, ask.extra)); + } else if (ask.kind == "elicit") { + if ((value.isBool() && !value.truthy()) || value.isNull() || value == Json("decline")) + respond(ask.rpcId, Json::object({{"action", Json("decline")}})); + else if (value == Json("cancel")) + respond(ask.rpcId, Json::object({{"action", Json("cancel")}})); + else + respond(ask.rpcId, Json::object({{"action", Json("accept")}, {"content", value}})); + } else if (ask.kind == "input") { + respond(ask.rpcId, Json::object({{"value", value}})); + } else { + throw CapError("cap_invalid_answer", "unknown question kind"); + } + if (openAskCount() == 0 && state() == "input_required") setState("working"); +} + +Json CapAcpDriver::permissionOutcome(const Json& value, const Json& options) { + // by_id / kinds: dict comprehensions over dict options (insertion order, + // later duplicates overwrite the value but keep the first position). + std::vector> kinds; + bool inById = false; + if (options.isArray()) { + for (const Json& opt : options.items()) { + if (!opt.isObject()) continue; + const Json oid = opt.get("optionId"); + if (value == oid) inById = true; + const std::string kind = CapPy::orStr(opt.get("kind"), ""); + bool replaced = false; + for (auto& entry : kinds) { + if (entry.first == oid) { + entry.second = kind; + replaced = true; + break; + } + } + if (!replaced) kinds.emplace_back(oid, kind); + } + } + if (inById) + return Json::object({{"outcome", Json::object({{"outcome", Json("selected")}, {"optionId", value}})}}); + const Json* pick = nullptr; + if (value == Json("deny")) { + for (const auto& entry : kinds) + if (WireText::startsWith(entry.second, "reject")) { + pick = &entry.first; + break; + } + } else if (value == Json("allow_once") || value == Json("allow_always")) { + for (const auto& entry : kinds) + if (entry.second == value.str()) { + pick = &entry.first; + break; + } + if (!pick) + for (const auto& entry : kinds) + if (WireText::startsWith(entry.second, "allow")) { + pick = &entry.first; + break; + } + } + if (pick) return Json::object({{"outcome", Json::object({{"outcome", Json("selected")}, {"optionId", *pick}})}}); + return Json::object({{"outcome", Json::object({{"outcome", Json("cancelled")}})}}); +} + +// -------------------------------------------------------------------------- +// fs + +bool CapAcpDriver::fsRoot(std::string& root, bool& unrestricted) { + unrestricted = false; + if (fsScope_ == "unrestricted") { + unrestricted = true; + return true; + } + if (fsScope_ == "project_root") { + root = vcsRoot(); + return true; + } + if (fsScope_ == "workspace_only") { + root = WireOs::realpath(cwd_); + return true; + } + return false; +} + +std::string CapAcpDriver::vcsRoot() { + std::string node = WireOs::realpath(cwd_); + while (true) { + if (WireOs::isDir(WireText::pathJoin(node, ".git"))) return node; + const std::string parent = WireText::dirname(node); + if (parent == node) return WireOs::realpath(cwd_); + node = parent; + } +} + +std::string CapAcpDriver::fsCheck(const std::string& path) { + std::string root; + bool unrestricted = false; + if (!fsRoot(root, unrestricted)) throw CapError("cap_scope_violation", "file access is not granted"); + const std::string target = WireOs::realpath(WireOs::isAbs(path) ? path : WireText::pathJoin(cwd_, path)); + if (!unrestricted && target != root && !WireText::startsWith(target, root + "/")) + throw CapError("cap_scope_violation", "path escapes the granted scope"); + return target; +} + +CapAcpDriver::Served CapAcpDriver::serveFsRead(const Json& params) { + const std::string target = fsCheck(CapPy::orStr(params.get("path"), "")); + std::vector lines; + try { + lines = WireText::splitLines(WireOs::readFile(target), true); + } catch (const OsError& exc) { + if (exc.notFound()) throw CapError("cap_path_not_found", "no such file"); + throw CapError("cap_io_error", CapPy::shortError(exc.what(), "OSError")); + } catch (const ValueError& exc) { + throw CapError("cap_io_error", CapPy::shortError(exc.what(), "ValueError")); + } + const Json lineJ = params.get("line"), limitJ = params.get("limit"); + long long start = 0, limit = 0; + bool bad = false; + if (lineJ.truthy()) { + long long l = 0; + if (!CapPy::toInt(lineJ, l)) bad = true; + else start = std::max(l - 1, 0); + } + if (!bad && limitJ.truthy()) { + if (!CapPy::toInt(limitJ, limit)) bad = true; + } + if (bad) throw CapError("cap_io_error", "bad line window"); + std::vector picked = + limitJ.truthy() ? pySlice(lines, start, start + limit, true) : pySlice(lines, start, 0, false); + Served out; + out.value = Json::object({{"content", Json(WireText::head(WireText::join(picked, ""), CAP_ACP_FS_MAX))}}); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveFsWrite(const Json& params) { + const std::string target = fsCheck(CapPy::orStr(params.get("path"), "")); + try { + const std::string parent = WireText::dirname(target); + WireOs::makedirs(parent.empty() ? cwd_ : parent); + WireOs::writeFile(target, CapPy::orStr(params.get("content"), "")); + } catch (const OsError& exc) { + throw CapError("cap_io_error", CapPy::shortError(exc.what(), "OSError")); + } + Served out; + out.value = Json::object(); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveFsList(const Json& params) { + const std::string target = fsCheck(CapPy::orStr(params.get("path"), "")); + std::vector names; + try { + names = WireOs::listdir(target); + } catch (const OsError& exc) { + if (exc.notFound()) throw CapError("cap_path_not_found", "no such directory"); + throw CapError("cap_io_error", CapPy::shortError(exc.what(), "OSError")); + } + std::sort(names.begin(), names.end()); + Json entries = Json::array(); + for (size_t i = 0; i < names.size() && i < 1000; ++i) + entries.push(Json::object({{"name", Json(names[i])}, + {"is_dir", Json(WireOs::isDir(WireText::pathJoin(target, names[i])))}})); + Served out; + out.value = Json::object({{"entries", entries}}); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveFsExists(const Json& params) { + Served out; + try { + const std::string target = fsCheck(CapPy::orStr(params.get("path"), "")); + out.value = Json::object({{"exists", Json(WireOs::exists(target))}}); + } catch (const CapError&) { + out.value = Json::object({{"exists", Json(false)}}); + } + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveFsDelete(const Json& params) { + const std::string target = fsCheck(CapPy::orStr(params.get("path"), "")); + if (!WireOs::lexists(target)) throw CapError("cap_path_not_found", "no such path"); + try { + if (WireOs::isDir(target) && !WireOs::isLink(target)) { + if (params.get("recursive").truthy()) WireOs::rmtree(target); + else WireOs::rmdir(target); + } else { + WireOs::remove(target); + } + } catch (const OsError& exc) { + throw CapError("cap_io_error", CapPy::shortError(exc.what(), "OSError")); + } + Served out; + out.value = Json::object(); + return out; +} + +// -------------------------------------------------------------------------- +// terminals + +CapAcpDriver::Served CapAcpDriver::serveTermCreate(const Json& params) { + if (!grantTerminal_) throw CapError("cap_terminal_unavailable", "terminals are not granted"); + if (static_cast(terminals_.size()) >= CAP_ACP_TERMINALS_MAX) + throw CapError("cap_terminal_unavailable", "too many terminals"); + const std::string command = CapPy::orStr(params.get("command"), ""); + if (command.empty()) throw CapError("cap_io_error", "no command"); + const std::string cwd = CapPy::orStr(params.get("cwd"), cwd_); + if (!WireOs::isAbs(cwd)) throw CapError("cap_io_error", "cwd must be absolute"); + std::map env = WireOs::environ(); + Json envItems = params.get("env"); + if (envItems.isArray()) + for (const Json& item : envItems.items()) + if (item.isObject() && item.get("name").truthy()) + env[CapPy::str(item.get("name"))] = CapPy::orStr(item.get("value"), ""); + long long limit = CAP_ACP_TERM_BUF; + { + Json raw = params.get("outputByteLimit"); + long long v = 0; + if (raw.truthy() && CapPy::toInt(raw, v)) limit = v; + } + limit = std::max(1024, std::min(limit, static_cast(CAP_ACP_TERM_BUF) * 4)); + WireProc::Options options; + options.argv.push_back(command); + Json args = params.get("args"); + if (args.isArray()) + for (const Json& a : args.items()) options.argv.push_back(CapPy::str(a)); + options.cwd = cwd; + options.replaceEnv = true; + options.env = env; + options.in = WireProc::Stream::Inherit; + options.out = WireProc::Stream::Pipe; + options.err = WireProc::Stream::ToStdout; + std::unique_ptr proc; + try { + proc = WireProc::spawn(options); + } catch (const OsError& exc) { + throw CapError("cap_terminal_unavailable", CapPy::shortError(exc.what(), "OSError")); + } + ++termSeq_; + const std::string tid = "term_" + std::to_string(termSeq_); + auto term = std::make_shared(); + term->proc = std::move(proc); + term->limit = static_cast(limit); + terminals_.emplace_back(tid, term); + term->pump = std::thread(&CapAcpDriver::termPump, term); + Served out; + out.value = Json::object({{"terminalId", Json(tid)}}); + return out; +} + +void CapAcpDriver::termPump(std::shared_ptr term) { + // BufferedReader.read(65536) semantics: a chunk is published when 64 KiB + // accumulated or the stream ended. + const int fd = term->proc->stdoutFd(); + std::string chunk; + bool eof = false; + while (!eof && !term->stop) { + if (!WireProc::waitReadable(fd, 0.1)) continue; + auto got = WireProc::readFd(fd, 65536 - chunk.size()); + if (!got) continue; + if (got->empty()) { + eof = true; + } else { + chunk += *got; + } + if (chunk.size() >= 65536 || (eof && !chunk.empty())) { + std::lock_guard lock(term->lock); + term->buf += chunk; + if (term->buf.size() > term->limit) { + term->buf.erase(0, term->buf.size() - term->limit); + while (!term->buf.empty() && (static_cast(term->buf[0]) & 0xC0) == 0x80) term->buf.erase(0, 1); + term->truncated = true; + } + chunk.clear(); + } + } +} + +std::shared_ptr CapAcpDriver::termGet(const std::string& tid) { + for (auto& entry : terminals_) + if (entry.first == tid) return entry.second; + throw CapError("cap_terminal_unavailable", "no such terminal"); +} + +Json CapAcpDriver::termStatus(Terminal& term) { + const auto code = term.proc->poll(); + if (!code) return Json(); + Json name; + if (*code < 0) name = Json(signalName(-*code)); + else if (!term.killed.empty()) name = Json(term.killed); + return Json::object({{"exitCode", *code >= 0 ? Json(*code) : Json()}, {"signal", name}}); +} + +CapAcpDriver::Served CapAcpDriver::serveTermOutput(const Json& params) { + auto term = termGet(CapPy::orStr(params.get("terminalId"), "")); + std::string output; + bool truncated; + { + std::lock_guard lock(term->lock); + output = WireText::decodeReplace(term->buf); + truncated = term->truncated; + } + Json result = Json::object({{"output", Json(output)}, {"truncated", Json(truncated)}}); + Json status = termStatus(*term); + if (!status.isNull()) result.set("exitStatus", status); + Served out; + out.value = result; + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveTermWait(const Json& params) { + auto term = termGet(CapPy::orStr(params.get("terminalId"), "")); + const double deadline = WireOs::now() + CAP_ACP_TERM_WAIT_MAX; + Served out; + while (WireOs::now() < deadline) { + Json status = termStatus(*term); + if (!status.isNull()) { + out.value = Json::object({{"exitCode", status.get("exitCode")}, {"signal", status.get("signal")}}); + return out; + } + // Let other threads (cancel, close) in while we wait, like the GIL did. + mu_.unlock(); + WireOs::sleep(0.05); + mu_.lock(); + } + out.value = Json::object({{"exitCode", Json()}, {"signal", Json()}}); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveTermKill(const Json& params) { + auto term = termGet(CapPy::orStr(params.get("terminalId"), "")); + term->killed = "SIGTERM"; + CapProcUtil::capProcKill(*term->proc); + Served out; + out.value = Json::object(); + return out; +} + +CapAcpDriver::Served CapAcpDriver::serveTermRelease(const Json& params) { + terminalDrop(CapPy::orStr(params.get("terminalId"), "")); + Served out; + out.value = Json::object(); + return out; +} + +void CapAcpDriver::terminalDrop(const std::string& tid) { + std::shared_ptr term; + for (auto it = terminals_.begin(); it != terminals_.end(); ++it) { + if (it->first == tid) { + term = it->second; + terminals_.erase(it); + break; + } + } + if (!term) throw CapError("cap_terminal_unavailable", "no such terminal"); + if (!term->proc->poll()) { + term->killed = "SIGTERM"; + CapProcUtil::capProcKill(*term->proc); + } + term->stop = true; + if (term->pump.joinable()) term->pump.join(); + term->proc->closePipes(); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_acp_driver.h b/taicpp/src/cap/cap_acp_driver.h new file mode 100644 index 0000000..c507452 --- /dev/null +++ b/taicpp/src/cap/cap_acp_driver.h @@ -0,0 +1,138 @@ +#pragma once +// CapAcpDriver: orchestrator-side ACP-stdio binding (spec section 6.4, C.3): +// JSON-RPC over pipes, poll-pumped like the stream driver (no reader thread). +// Serves fs/terminal/elicitation plus tai `_cap/*` extensions for +// fs.list/exists/delete and user_io/notify. Answers flow through the unified +// open_asks stash keyed by cap id. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cap_driver.h" +#include "cap_pipe_reader.h" +#include "cap_proc_util.h" +#include "wire_proc.h" + +namespace detz::cap { + +class CapAcpDriver : public CapDriver { + public: + CapAcpDriver(const CapManifest& manifest, std::string urn = "cap://agent-1", std::string cwd = "", + std::string model = "", std::map env = {}, + std::vector acpArgv = {}, std::string fsScope = "workspace_only", bool terminal = true, + std::function ioSink = nullptr); + ~CapAcpDriver() override; + + void sendText(const std::string& text) override; + std::vector poll(double timeout = 0.0) override; + void cancel(const std::string& scope = "current_turn") override; + void close() override; + + int ignoredFrames() const; + std::vector ioLog() const; + std::vector stderrLines() const; + size_t terminalCount() const; + const std::string& acpSession() const { return acpSession_; } + + protected: + void spawn() override; + bool hasProc() const override { return proc_ != nullptr; } + void answerOpen(const std::string& askId, const OpenAsk& ask, const Json& value) override; + + private: + struct Pending { + std::string method; + bool done{false}; + bool hasError{false}; + Json error; + Json result; + bool prompt{false}; + }; + struct Terminal { + std::unique_ptr proc; + std::mutex lock; + std::string buf; + size_t limit{0}; + bool truncated{false}; + std::string killed; + std::atomic stop{false}; + std::thread pump; + }; + struct Served { + bool deferred{false}; + Json value; + }; + + void handshake(); + Json waitResponse(const Json& rpcId, const std::string& what); + Json request(const std::string& method, const Json& params); + void notify(const std::string& method, const Json& params); + void writeFrame(const Json& frame); + void respond(const Json& rpcId, const Json& result, const Json* error = nullptr); + static Json rpcError(long long code, const std::string& message); + Pending* findPending(const Json& id); + void erasePending(const Json& id); + + void onProcGone(); + void mapMessage(const std::string& line); + void mapResponse(const Json& frame); + void mapIncoming(const Json& frame); + void mapUpdate(const Json& update); + static std::string updateText(const Json& content); + + Served serve(const std::string& method, const Json& params, const Json& rpcId, bool& known); + Served servePermission(const Json& params, const Json& rpcId); + Served serveElicit(const Json& params, const Json& rpcId); + Served serveIoShow(const Json& params); + Served serveNotify(const Json& params); + Served serveIoInput(const Json& params, const Json& rpcId); + void ioLogAdd(const std::string& kind, const Json& params); + static Json permissionOutcome(const Json& value, const Json& options); + + // fs + bool fsRoot(std::string& root, bool& unrestricted); + std::string vcsRoot(); + std::string fsCheck(const std::string& path); + Served serveFsRead(const Json& params); + Served serveFsWrite(const Json& params); + Served serveFsList(const Json& params); + Served serveFsExists(const Json& params); + Served serveFsDelete(const Json& params); + + // terminals + Served serveTermCreate(const Json& params); + std::shared_ptr termGet(const std::string& tid); + static Json termStatus(Terminal& term); + Served serveTermOutput(const Json& params); + Served serveTermWait(const Json& params); + Served serveTermKill(const Json& params); + Served serveTermRelease(const Json& params); + void terminalDrop(const std::string& tid); + static void termPump(std::shared_ptr term); + + std::vector acpArgv_; + std::string fsScope_; + bool grantTerminal_; + std::function ioSink_; + std::vector ioLog_; + std::unique_ptr proc_; + std::unique_ptr reader_; + std::unique_ptr drain_; + int ignoredFrames_{0}; + long long nextId_{0}; + std::vector> pending_; + Json promptId_; + std::string acpSession_; + bool haveSession_{false}; + std::string turnMsgId_{"msg_boot"}; + long long lastIn_{0}, lastOut_{0}; + std::vector>> terminals_; + int termSeq_{0}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_agent_manifest.cpp b/taicpp/src/cap/cap_agent_manifest.cpp new file mode 100644 index 0000000..951a4ab --- /dev/null +++ b/taicpp/src/cap/cap_agent_manifest.cpp @@ -0,0 +1,67 @@ +#include "cap_agent_manifest.h" + +namespace detz::cap { + +// Generated from tai.py's TAI_CAP_MANIFEST by tests/capmcp_diff.py's sibling +// check (--cap-manifest output must stay byte-identical to the Python app). +const std::string& CapAgentManifest::TAI_CAP_MANIFEST() { + static const std::string manifest = R"CAPM(# tai CAP agent manifest. Emitted by `tai.py --cap-manifest`. +[agent] +name = "tai" +binary = "tai.py" +profiles = ["coding"] + +[probe] +command = ["tai.py", "--version"] +version_pattern = "tai (\\d+\\.\\d+\\.\\d+)" + +[startup] +command = ["tai.py"] +ready_when = { pattern = "type /help for commands" } +init_timeout_seconds = 60 + +[fast_path] +stream_json = ["tai.py", "--cap-speak"] +grpc = false +acp_stdio = false +a2a_serve_at = false + +[pty] +cols = 200 +rows = 50 +bracketed_paste = false +sigint_cancels_turn = "graceful" +queued_input_supported = false + +[parse] +idle = ["^tai\\[.*\\]› $"] +error_lines = ["^seal error:", "^sandbox unavailable:"] + +[capabilities] +streaming_output = true +queued_input = false +mid_turn_cancel = "graceful" +multi_session = true +input_modalities = ["text"] +output_modalities = ["text"] + +[capabilities.ask_user] +yes_no = true +options = false +free_text = true + +[capabilities.coding] +revision = "2026-05-18" +fs = { read = true, write = true, list = true, exists = true, scope = "workspace_only" } +terminal = { enabled = true, concurrent_max = 4 } +tool_permission = "interactive" +artifacts = ["diff", "transcript"] + +[cost] +metered = true +currency = "USD" +)CAPM"; + return manifest; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_agent_manifest.h b/taicpp/src/cap/cap_agent_manifest.h new file mode 100644 index 0000000..5c2a07e --- /dev/null +++ b/taicpp/src/cap/cap_agent_manifest.h @@ -0,0 +1,14 @@ +#pragma once +// CapAgentManifest: tai's own CAP manifest, TAI_CAP_MANIFEST in tai.py. It is +// what `--cap-manifest` prints, so its bytes are compared against the Python +// app in tests (tests/capmcp_cli_diff.py). +#include + +namespace detz::cap { + +class CapAgentManifest { + public: + static const std::string& TAI_CAP_MANIFEST(); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_agent_port.h b/taicpp/src/cap/cap_agent_port.h new file mode 100644 index 0000000..7595e73 --- /dev/null +++ b/taicpp/src/cap/cap_agent_port.h @@ -0,0 +1,73 @@ +#pragma once +// The seam between the CAP agent side (--cap-speak) and the agent core. +// CapSpeakSession drives an agent only through CapAgentPort; the integrator +// implements it once over the real Agent (see docs/wiring/capmcp.md). +#include +#include +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +// KeyboardInterrupt: thrown by the host (or CapSpeakSession) when SIGINT or +// SIGTERM asked the speak session to stop. +class CapInterrupted : public std::runtime_error { + public: + CapInterrupted() : std::runtime_error("KeyboardInterrupt") {} +}; + +// SealError raised while booting the vault. +class CapSealError : public std::runtime_error { + public: + explicit CapSealError(const std::string& text) : std::runtime_error(text) {} +}; + +// The agent as --cap-speak needs it. Every member mirrors one attribute or +// call of tai.py's Agent. +class CapAgentPort { + public: + virtual ~CapAgentPort() = default; + // agent.messages (conversation, role/content/tool_calls/tool_call_id dicts) + virtual const std::vector& messages() const = 0; + // agent.run_turn(text, capture=True): runs one turn, returns the final text. + // stdout is already parked on stderr by the session. + virtual std::string runTurn(const std::string& text) = 0; + // agent._turn_tools + virtual int turnTools() const = 0; + // conversation_tokens(agent.messages) + virtual long long conversationTokens() const = 0; + // resume_turn(agent) + virtual void resumeTurn() = 0; + // agent.config.home + virtual std::string home() const = 0; + // agent.tools.diff_preview: {"path", "old", "new"} or Null when unset. + virtual Json diffPreview() const = 0; + // agent.quiet = True + virtual void setQuiet(bool quiet) = 0; + // agent.system_message = prompt + "\n" + agent.system_message + virtual void prependSystemMessage(const std::string& prompt) = 0; + // agent.store.close() + virtual void closeStore() = 0; +}; + +// The parsed --cap-* flags plus the global ones cap_speak_main rewrites. +struct CapSpeakArgs { + std::optional capCwd; + std::optional capModel; + std::optional capPermissionMode; + std::optional capMaxTurns; + bool autoFlag{false}; // args.auto + bool yoloFlag{false}; // args.yolo +}; + +// Boots the vault and the agent (tai.py boot(args)). May throw CapInterrupted +// or CapSealError. +class CapSpeakHost { + public: + virtual ~CapSpeakHost() = default; + virtual std::unique_ptr boot(const CapSpeakArgs& args) = 0; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_answerer.cpp b/taicpp/src/cap/cap_answerer.cpp new file mode 100644 index 0000000..ce5eb1d --- /dev/null +++ b/taicpp/src/cap/cap_answerer.cpp @@ -0,0 +1,159 @@ +#include "cap_answerer.h" + +#include + +#include + +#include "cap_py.h" +#include "wire_os.h" +#include "wire_proc.h" +#include "wire_regex.h" + +namespace detz::cap { + +using detz::wire::WireOs; +using detz::wire::WireProc; +using detz::wire::WireRegex; + +namespace { + +std::string selfExecutable() { + char buf[4096]; + const ssize_t n = ::readlink("/proc/self/exe", buf, sizeof buf - 1); + return n > 0 ? std::string(buf, static_cast(n)) : std::string("tai"); +} + +int riskIndex(const std::string& risk) { + for (size_t i = 0; i < CAP_ANSWER_RISKS.size(); ++i) + if (CAP_ANSWER_RISKS[i] == risk) return static_cast(i); + throw ValueError("tuple.index(x): x not in tuple"); +} + +} // namespace + +CapAnswerer::CapAnswerer(std::vector allowTools, std::vector denyTools, + const std::string& maxRisk, const std::string& researchHome, double timeout, + const std::string& taiBinary) + : allowTools_(allowTools.begin(), allowTools.end()), + denyTools_(denyTools.begin(), denyTools.end()), + timeout_(timeout) { + maxRisk_ = "medium"; + for (auto r : CAP_ANSWER_RISKS) + if (maxRisk == r) maxRisk_ = maxRisk; + std::string home = WireOs::getenv("TAI_HOME"); + if (home.empty()) home = WireText::pathJoin(WireOs::home(), ".tai"); + researchHome_ = researchHome.empty() ? WireText::pathJoin(home, "cap-research") : researchHome; + taiBinary_ = taiBinary.empty() ? selfExecutable() : taiBinary; +} + +void CapAnswerer::acquire() { + std::unique_lock lock(slotMutex_); + slotCv_.wait(lock, [&] { return slots_ > 0; }); + --slots_; +} + +void CapAnswerer::release() { + { + std::lock_guard lock(slotMutex_); + ++slots_; + } + slotCv_.notify_one(); +} + +void CapAnswerer::handle(CapAnswerTarget& session, const Json& event) { + const Json kind = event.get("kind"); + if (kind == Json("cap.permission.request")) { + if (!event.has("req_id")) throw std::out_of_range("KeyError: 'req_id'"); + session.answer(CapPy::str(event.get("req_id")), Json(decidePermission(session.urn(), event))); + } else if (kind == Json("cap.ask_user")) { + if (!event.has("ask_id")) throw std::out_of_range("KeyError: 'ask_id'"); + session.answer(CapPy::str(event.get("ask_id")), answerAsk(session.urn(), event)); + } +} + +std::string CapAnswerer::decidePermission(const std::string& urn, const Json& event) { + const std::string tool = CapPy::orStr(event.get("tool"), ""); + if (denyTools_.count(tool)) return "deny"; + if (allowTools_.count(tool)) return "allow_once"; + const std::string risk = CapPy::orStr(event.get("risk_level"), "medium"); + if (riskIndex(risk) > riskIndex(maxRisk_)) return "deny"; + Json intent = event.get("intent"); + if (!intent.truthy()) intent = Json::object(); + const std::string marker(CAP_ANSWER_MARKER); + auto verdict = research("Decide whether the supervised subagent " + urn + " may perform this action.\nTool: " + tool + + "\nIntent: " + WireText::head(intent.dumps(), 2000) + "\nRisk: " + risk + + "\nReply with reasoning, then end with exactly one line: " + marker + " allow OR " + marker + + " deny."); + if (!verdict) return "deny"; + return WireText::startsWith(WireText::lower(WireText::strip(*verdict)), "allow") ? "allow_once" : "deny"; +} + +Json CapAnswerer::answerAsk(const std::string& urn, const Json& event) { + Json form = event.get("form"); + if (!form.truthy()) form = Json::object(); + const std::string prompt = CapPy::orStr(event.get("prompt"), ""); + const bool boolean = isYesNo(form); + const std::string shape = boolean ? "yes/no" : WireText::head(form.dumps(), 2000); + const std::string marker(CAP_ANSWER_MARKER); + auto verdict = research("Answer for the supervised subagent " + urn + + ". Research with web_search and web_fetch, then end with exactly one line: " + marker + + " .\nQuestion: " + WireText::head(prompt, 2000) + "\nAnswer shape: " + shape); + if (!verdict) return boolean ? Json(false) : Json("unknown (research failed)"); + if (boolean) return Json(parseBool(*verdict)); + return Json(WireText::head(*verdict, 2000)); +} + +bool CapAnswerer::isYesNo(const Json& form) { + if (!form.isObject()) return false; + std::set consts; + Json options = form.get("oneOf"); + if (options.truthy() && options.isArray()) + for (const Json& option : options.items()) + if (option.isObject() && option.has("const")) consts.insert(WireText::lower(CapPy::str(option.get("const")))); + return consts == std::set{"yes", "no"}; +} + +bool CapAnswerer::parseBool(const std::string& text) { + const std::string lowered = WireText::lower(WireText::strip(text)); + const std::vector words = WireText::splitWhitespace(lowered); + const std::string first = words.empty() ? "" : words[0]; + if (first == "y" || first == "yes" || first == "true" || first == "1") return true; + if (first == "n" || first == "no" || first == "false" || first == "0") return false; + static const WireRegex yes("\\byes\\b"); + static const WireRegex no("\\bno\\b"); + if (yes.search(lowered)) return true; + if (no.search(lowered)) return false; + return false; +} + +std::optional CapAnswerer::research(const std::string& prompt) { + WireProc::Completed done; + acquire(); + try { + WireProc::Options options; + options.argv = {taiBinary_, "--auto", prompt}; + options.replaceEnv = true; + options.env = WireOs::environ(); + options.env["TAI_HOME"] = researchHome_; + options.env["TAI_CHILD"] = "1"; + done = WireProc::run(options, timeout_); + } catch (const detz::wire::OsError&) { + release(); + return std::nullopt; + } catch (const detz::wire::SubprocessError&) { + release(); + return std::nullopt; + } + release(); + if (done.returncode != 0) return std::nullopt; + const std::string output = WireText::decodeReplace(done.out); + const std::vector lines = WireText::splitLines(output); + const std::string marker(CAP_ANSWER_MARKER); + for (auto it = lines.rbegin(); it != lines.rend(); ++it) { + const std::string stripped = WireText::strip(*it); + if (WireText::startsWith(WireText::upper(stripped), marker)) return WireText::strip(stripped.substr(marker.size())); + } + return std::nullopt; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_answerer.h b/taicpp/src/cap/cap_answerer.h new file mode 100644 index 0000000..45f121c --- /dev/null +++ b/taicpp/src/cap/cap_answerer.h @@ -0,0 +1,60 @@ +#pragma once +// CapAnswerer: answers subagent questions so no turn stalls: permissions by +// policy (unknown tools researched), asks by an isolated research subprocess +// (a tai one-shot in its own vault). Everything bounded, every failure falls +// back to the safe answer. +#include +#include +#include +#include +#include +#include + +#include "cap_constants.h" +#include "cap_types.h" + +namespace detz::cap { + +// What an answerer needs from a session: its URN and a way to answer. +class CapAnswerTarget { + public: + virtual ~CapAnswerTarget() = default; + virtual std::string urn() const = 0; + virtual void answer(const std::string& askId, const Json& value) = 0; +}; + +class CapAnswerer { + public: + CapAnswerer(std::vector allowTools = {}, std::vector denyTools = {}, + const std::string& maxRisk = "medium", const std::string& researchHome = "", + double timeout = CAP_ANSWER_TIMEOUT, const std::string& taiBinary = ""); + + void handle(CapAnswerTarget& session, const Json& event); + std::string decidePermission(const std::string& urn, const Json& event); + Json answerAsk(const std::string& urn, const Json& event); + static bool isYesNo(const Json& form); + static bool parseBool(const std::string& text); + // One isolated research turn. nullopt when it failed, timed out or never marked. + std::optional research(const std::string& prompt); + + const std::set& allowTools() const { return allowTools_; } + const std::set& denyTools() const { return denyTools_; } + const std::string& maxRisk() const { return maxRisk_; } + const std::string& researchHome() const { return researchHome_; } + const std::string& taiBinary() const { return taiBinary_; } + + private: + void acquire(); + void release(); + + std::set allowTools_, denyTools_; + std::string maxRisk_; + std::string researchHome_; + double timeout_; + std::string taiBinary_; + std::mutex slotMutex_; + std::condition_variable slotCv_; + int slots_{CAP_ANSWER_PARALLEL}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_binding.cpp b/taicpp/src/cap/cap_binding.cpp new file mode 100644 index 0000000..9e75802 --- /dev/null +++ b/taicpp/src/cap/cap_binding.cpp @@ -0,0 +1,53 @@ +#include "cap_binding.h" + +#include + +#include "cap_manifest.h" + +namespace detz::cap { + +const std::vector& CapBinding::ORDER() { + static const std::vector order = {"grpc", "stream_json", "acp_stdio", "a2a", "pty"}; + return order; +} + +const std::vector& CapBinding::UNSUPPORTED() { + static const std::vector unsupported = {"grpc"}; + return unsupported; +} + +std::vector CapBinding::fromFast(const Json& fast) { + std::vector found; + if (fast.get("grpc").truthy()) found.push_back("grpc"); + if (fast.get("stream_json").truthy()) found.push_back("stream_json"); + if (fast.get("acp_stdio").truthy()) found.push_back("acp_stdio"); + if (fast.get("a2a_serve_at").truthy()) found.push_back("a2a"); + found.push_back("pty"); + return found; +} + +std::vector CapBinding::declared(const Json& manifestData) { + Json fast = manifestData.get("fast_path", Json::object()); + if (!fast.truthy()) fast = Json::object(); + return fromFast(fast); +} + +std::vector CapBinding::declared(const CapManifest& manifest) { + Json fast = manifest.get("fast_path", Json::object()); + if (!fast.truthy()) fast = Json::object(); + return fromFast(fast); +} + +std::string CapBinding::pick(const std::vector& declaredList) { + for (const std::string& name : ORDER()) { + if (std::find(declaredList.begin(), declaredList.end(), name) != declaredList.end() && + std::find(UNSUPPORTED().begin(), UNSUPPORTED().end(), name) == UNSUPPORTED().end()) + return name; + } + return "pty"; +} + +std::string CapBinding::select(const Json& manifestData) { return pick(declared(manifestData)); } +std::string CapBinding::select(const CapManifest& manifest) { return pick(declared(manifest)); } + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_binding.h b/taicpp/src/cap/cap_binding.h new file mode 100644 index 0000000..88c17cc --- /dev/null +++ b/taicpp/src/cap/cap_binding.h @@ -0,0 +1,31 @@ +#pragma once +// CapBinding: binding priority and selection (spec section 6). grpc is the +// only binding tai cannot drive: the standard library has no HTTP/2 or +// protobuf, so selection falls through past it. +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +class CapManifest; + +class CapBinding { + public: + static const std::vector& ORDER(); + static const std::vector& UNSUPPORTED(); + + // `manifest` is a parsed manifest table (the data dict); the CapManifest + // overloads read its dotted fast_path just like Python's .get("fast_path"). + static std::vector declared(const Json& manifestData); + static std::vector declared(const CapManifest& manifest); + static std::string select(const Json& manifestData); + static std::string select(const CapManifest& manifest); + + private: + static std::vector fromFast(const Json& fast); + static std::string pick(const std::vector& declaredList); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_cli.cpp b/taicpp/src/cap/cap_cli.cpp new file mode 100644 index 0000000..72c1909 --- /dev/null +++ b/taicpp/src/cap/cap_cli.cpp @@ -0,0 +1,457 @@ +#include "cap_cli.h" + +#include +#include + +#include +#include + +#include "cap_error.h" +#include "cap_interrupt.h" +#include "cap_py.h" +#include "wire_log.h" +#include "wire_os.h" +#include "wire_shlex.h" +#include "wire_toml.h" + +namespace detz::cap { + +using detz::wire::ArgParseExit; +using detz::wire::WireArgParse; +using detz::wire::WireLog; +using detz::wire::WireOs; + +void CapCli::out(const std::string& text) { + size_t off = 0; + while (off < text.size()) { + ssize_t n = ::write(1, text.data() + off, text.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + return; + } + off += static_cast(n); + } +} + +void CapCli::err(const std::string& text) { + size_t off = 0; + while (off < text.size()) { + ssize_t n = ::write(2, text.data() + off, text.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + return; + } + off += static_cast(n); + } +} + +namespace { + +std::vector strings(const Json& list) { + std::vector out; + if (list.isArray()) + for (const Json& item : list.items()) out.push_back(CapPy::str(item)); + return out; +} + +// Runs a parser; prints what argparse printed and returns false on SystemExit. +bool parseOrExit(const WireArgParse& parser, const std::vector& argv, Json& ns, int& code) { + try { + ns = parser.parseArgs(argv); + return true; + } catch (const ArgParseExit& exit) { + if (!exit.stdoutText.empty()) CapCli::out(exit.stdoutText); + if (!exit.stderrText.empty()) CapCli::err(exit.stderrText); + code = exit.exitCode; + return false; + } +} + +} // namespace + +std::string CapCli::capCliHelp() { + return "usage: tai.py --cap [...]\n" + " resolve [--binary B] print the manifest source, binding and profiles\n" + " chat --driver NAME [--task T] [--cwd D] [--model M]\n" + " talk to one agent (task once, then REPL on a tty)\n" + " run [--task T] static-route fleet run, see cap.md for the schema"; +} + +int CapCli::capCliMain(const std::vector& argv) { + if (argv.empty() || argv[0] == "-h" || argv[0] == "--help" || argv[0] == "help") { + out(capCliHelp() + "\n"); + return 0; + } + const std::string& verb = argv[0]; + const std::vector rest(argv.begin() + 1, argv.end()); + if (verb == "resolve") return capResolveMain(rest); + if (verb == "chat") return capChatMain(rest); + if (verb == "run") return capRunMain(rest); + err("unknown cap command " + WireText::repr(verb) + "\n" + capCliHelp() + "\n"); + return 2; +} + +WireArgParse CapCli::resolveParser() { + WireArgParse parser("tai.py --cap resolve"); + parser.addPositional("name"); + parser.addOption({"--binary"}, WireArgParse::Kind::Store, Json()); + return parser; +} + +void CapCli::capAnswerFlags(WireArgParse& parser) { + parser.addOption({"--auto-answer"}, WireArgParse::Kind::StoreTrue, Json(false), + "answer subagent questions by policy plus research turns"); + parser.addOption({"--allow-tool"}, WireArgParse::Kind::Append, Json::array(), "pre-approve a tool (repeatable)", "TOOL"); + parser.addOption({"--deny-tool"}, WireArgParse::Kind::Append, Json::array(), "pre-deny a tool (repeatable)", "TOOL"); + std::vector risks; + for (auto r : CAP_ANSWER_RISKS) risks.emplace_back(r); + parser.addOption({"--max-risk"}, WireArgParse::Kind::Store, Json("medium"), "riskiest auto-approved unknown tool", "", + false, risks, true); +} + +WireArgParse CapCli::chatParser() { + WireArgParse parser("tai.py --cap chat"); + parser.addOption({"--driver"}, WireArgParse::Kind::Store, Json(), "", "", true); + parser.addOption({"--task"}, WireArgParse::Kind::Store, Json("")); + parser.addOption({"--cwd"}, WireArgParse::Kind::Store, Json()); + parser.addOption({"--model"}, WireArgParse::Kind::Store, Json()); + parser.addOption({"--argv"}, WireArgParse::Kind::Store, Json(), "ACP server command line (shell-split) for acp_stdio agents"); + capAnswerFlags(parser); + return parser; +} + +WireArgParse CapCli::runParser() { + WireArgParse parser("tai.py --cap run"); + parser.addPositional("fleet_file"); + parser.addOption({"--task"}, WireArgParse::Kind::Store, Json()); + capAnswerFlags(parser); + return parser; +} + +int CapCli::capResolveMain(const std::vector& argv) { + Json ns; + int code = 0; + if (!parseOrExit(resolveParser(), argv, ns, code)) return code; + try { + const Json binary = ns.get("binary"); + CapManifest manifest = CapRegistry().resolve(ns.get("name").str(), binary.isNull() ? "" : binary.str()); + std::vector profiles; + Json list = manifest.get("agent.profiles"); + if (list.truthy() && list.isArray()) + for (const Json& p : list.items()) profiles.push_back(CapPy::str(p)); + const std::string joined = WireText::join(profiles, ","); + out(manifest.name() + ": " + manifest.source() + " via " + manifest.binding() + " profiles=" + + (joined.empty() ? std::string("-") : joined) + "\n"); + return 0; + } catch (const CapError& exc) { + err(std::string("cap resolve: ") + exc.what() + "\n"); + return 1; + } catch (const ValueError& exc) { + err(std::string("cap resolve: ") + exc.what() + "\n"); + return 1; + } +} + +void CapCli::capPrintResult(const CapTurnResult& result) { + const std::string text = result.text(); + if (!WireText::strip(text).empty()) out(text + "\n"); + out("[" + result.state() + "] tokens in=" + std::to_string(result.usage().inputTokens) + + " out=" + std::to_string(result.usage().outputTokens) + "\n"); +} + +std::shared_ptr CapCli::capBuildAnswerer(bool autoAnswer, const std::vector& allow, + const std::vector& deny, + const std::string& maxRisk) { + if (!autoAnswer) return nullptr; + return std::make_shared(allow, deny, maxRisk); +} + +namespace { + +// input(prompt): returns false at EOF, sets `interrupted` on SIGINT. +bool readInput(const std::string& prompt, std::string& line, bool& interrupted) { + CapCli::out(prompt); + line.clear(); + interrupted = false; + while (true) { + char c; + ssize_t n = ::read(0, &c, 1); + if (n < 0) { + if (errno == EINTR) { + if (CapInterrupt::requested()) { + CapInterrupt::clear(); + interrupted = true; + return true; + } + continue; + } + return false; + } + if (n == 0) return !line.empty() ? true : false; + if (c == '\n') return true; + line += c; + } +} + +} // namespace + +int CapCli::capChatMain(const std::vector& argv) { + Json ns; + int code = 0; + if (!parseOrExit(chatParser(), argv, ns, code)) return code; + const std::string driver = ns.get("driver").str(); + std::optional manifest; + try { + manifest.emplace(CapRegistry().resolve(driver, driver)); + } catch (const CapError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + return 1; + } catch (const ValueError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + return 1; + } + CapFleet fleet; + fleet.setAnswerer(capBuildAnswerer(ns.get("auto_answer").truthy(), strings(ns.get("allow_tool")), + strings(ns.get("deny_tool")), ns.get("max_risk").str())); + CapSession* session = nullptr; + try { + std::optional> acpArgv; + const Json argvText = ns.get("argv"); + if (argvText.truthy()) acpArgv = detz::wire::WireShlex::split(argvText.str()); + CapFleet::AddOptions options; + if (ns.get("cwd").truthy()) options.cwd = ns.get("cwd").str(); + if (ns.get("model").truthy()) options.model = ns.get("model").str(); + options.argv = acpArgv; + session = &fleet.add("chat", *manifest, options); + session->start(); + } catch (const CapError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + return 1; + } catch (const ValueError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + return 1; + } + int result = 0; + struct sigaction act {}, old {}; + act.sa_handler = &CapInterrupt::capInterrupt; + sigemptyset(&act.sa_mask); + act.sa_flags = 0; + ::sigaction(SIGINT, &act, &old); + CapInterrupt::clear(); + try { + bool done = false; + const Json task = ns.get("task"); + if (task.truthy()) { + try { + capPrintResult(fleet.ask(session->urn(), task.str())); + } catch (const CapError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + result = 1; + done = true; + } catch (const CapTimeoutError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + result = 1; + done = true; + } + } + if (!done && !::isatty(0)) { + done = true; + } + while (!done) { + std::string line; + bool interrupted = false; + if (!readInput("cap[" + driver + "]\xE2\x80\xBA ", line, interrupted)) { + out("\n"); + break; + } + if (interrupted) { + out("\n"); + continue; + } + const std::string stripped = WireText::strip(line); + if (stripped.empty() || stripped == "/quit" || stripped == "/exit") { + if (!stripped.empty()) break; + continue; + } + try { + capPrintResult(fleet.ask(session->urn(), line)); + } catch (const CapTimeoutError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + try { + session->cancel(); + } catch (const CapError&) { + } + } catch (const CapError& exc) { + err(std::string("cap chat: ") + exc.what() + "\n"); + } + } + } catch (const CapInterrupted&) { + result = 130; + } catch (...) { + fleet.closeAll(); + ::sigaction(SIGINT, &old, nullptr); + throw; + } + fleet.closeAll(); + ::sigaction(SIGINT, &old, nullptr); + return result; +} + +int CapCli::capRunMain(const std::vector& argv) { + Json ns; + int code = 0; + if (!parseOrExit(runParser(), argv, ns, code)) return code; + const std::string fleetFile = ns.get("fleet_file").str(); + Json spec; + try { + spec = detz::wire::WireToml::loads([&] { + std::string text, message; + const std::string raw = WireOs::readBytes(fleetFile); + if (!WireText::decodeStrict(raw, text, message)) throw ValueError(message); + return text; + }()); + } catch (const detz::wire::OsError& exc) { + err("cap run: cannot load " + fleetFile + ": " + CapPy::shortError(exc.what(), "OSError") + "\n"); + return 2; + } catch (const std::runtime_error& exc) { // TOMLDecodeError, UnicodeDecodeError + err("cap run: cannot load " + fleetFile + ": " + CapPy::shortError(exc.what(), "ValueError") + "\n"); + return 2; + } + CapFleetPlan plan; + try { + const Json taskArg = ns.get("task"); + const std::string taskText = taskArg.isNull() ? "" : taskArg.str(); + plan = capFleetPlan(spec, taskArg.isNull() ? nullptr : &taskText); + } catch (const ValueError& exc) { + err(std::string("cap run: ") + exc.what() + "\n"); + return 2; + } + CapFleet fleet("", plan.budgetUsd); + WireLog::event("INFO", "cap", "run_start", "cap run started (" + std::to_string(plan.sessions.items().size()) + " sessions)", + "", Json::object({{"sessions", Json(static_cast(plan.sessions.items().size()))}})); + const bool autoAnswer = ns.get("auto_answer").truthy() || plan.autoAnswer; + const std::string argRisk = ns.get("max_risk").str(); + const std::string maxRisk = argRisk == "medium" ? plan.maxRisk : argRisk; + std::vector allow = strings(ns.get("allow_tool")), deny = strings(ns.get("deny_tool")); + allow.insert(allow.end(), plan.allowTools.begin(), plan.allowTools.end()); + deny.insert(deny.end(), plan.denyTools.begin(), plan.denyTools.end()); + fleet.setAnswerer(capBuildAnswerer(autoAnswer, allow, deny, maxRisk)); + int result = 0; + try { + for (const Json& entry : plan.sessions.items()) { + CapFleet::AddOptions options; + if (entry.get("cwd").truthy()) options.cwd = CapPy::str(entry.get("cwd")); + if (entry.get("model").truthy()) options.model = CapPy::str(entry.get("model")); + if (!entry.get("argv").isNull()) options.argv = strings(entry.get("argv")); + fleet.add(CapPy::str(entry.get("name")), CapPy::str(entry.get("agent")), options); + } + fleet.startAll(); + std::vector> lastText; + auto setLast = [&](const std::string& urn, const std::string& text) { + for (auto& entry : lastText) + if (entry.first == urn) { + entry.second = text; + return; + } + lastText.emplace_back(urn, text); + }; + auto getLast = [&](const std::string& urn) { + for (auto& entry : lastText) + if (entry.first == urn) return entry.second; + return std::string(); + }; + CapTurnResult first = fleet.ask(plan.start, plan.task); + out("== " + plan.start + " [" + first.state() + "] ==\n"); + capPrintResult(first); + setLast(plan.start, first.text()); + for (const auto& route : plan.routes) { + const std::string payload = getLast(route.first); + out("== route " + route.first + " -> " + route.second + " ==\n"); + CapTurnResult next = fleet.ask(route.second, payload.empty() ? "[no output from " + route.first + "]" : payload); + out("== " + route.second + " [" + next.state() + "] ==\n"); + capPrintResult(next); + setLast(route.second, next.text()); + } + } catch (const CapError& exc) { + err(std::string("cap run: ") + exc.what() + "\n"); + result = 1; + } catch (const CapTimeoutError& exc) { + err(std::string("cap run: ") + exc.what() + "\n"); + result = 1; + } catch (const ValueError& exc) { + err(std::string("cap run: ") + exc.what() + "\n"); + result = 1; + } catch (...) { + fleet.closeAll(); + throw; + } + fleet.closeAll(); + return result; +} + +CapFleetPlan CapCli::capFleetPlan(const Json& spec, const std::string* taskOverride) { + if (!spec.isObject()) throw ValueError("fleet file must hold TOML tables"); + Json fleet = spec.get("fleet"); + if (!fleet.truthy()) fleet = Json::object(); + Json sessions = spec.get("sessions"); + if (!sessions.truthy()) sessions = Json::array(); + if (!sessions.truthy()) throw ValueError("fleet needs at least one [[sessions]] entry"); + std::vector urns; + for (const Json& entry : sessions.items()) { + if (!entry.isObject() || !entry.get("name").truthy() || !entry.get("agent").truthy()) + throw ValueError("every [[sessions]] entry needs name and agent"); + if (!entry.get("argv").isNull() && !entry.get("argv").isArray()) + throw ValueError("session " + entry.get("name").pyRepr() + " argv must be a list"); + urns.push_back("cap://" + CapPy::str(entry.get("name"))); + } + Json startJson = fleet.get("start"); + const std::string start = startJson.truthy() ? CapPy::str(startJson) : ""; + if (std::find(urns.begin(), urns.end(), start) == urns.end()) { + std::vector sorted(urns); + std::sort(sorted.begin(), sorted.end()); + sorted.erase(std::unique(sorted.begin(), sorted.end()), sorted.end()); + throw ValueError("[fleet] start must name one of: " + WireText::join(sorted, ", ")); + } + std::string task; + if (taskOverride) task = *taskOverride; + else task = CapPy::orStr(fleet.get("task"), ""); + if (WireText::strip(task).empty()) throw ValueError("fleet needs a task ([fleet] task or --task)"); + CapFleetPlan plan; + Json routesJson = spec.get("routes"); + if (routesJson.truthy() && routesJson.isArray()) { + for (const Json& route : routesJson.items()) { + if (!route.isObject()) throw ValueError("every [[routes]] entry needs when and route_to"); + std::string source = CapPy::orStr(route.get("when"), ""); + if (WireText::endsWith(source, ".done")) source = source.substr(0, source.size() - 5); + const std::string target = CapPy::orStr(route.get("route_to"), ""); + if (std::find(urns.begin(), urns.end(), source) == urns.end() || + std::find(urns.begin(), urns.end(), target) == urns.end()) + throw ValueError("route " + WireText::repr(source) + " -> " + WireText::repr(target) + " names an unknown session"); + plan.routes.emplace_back(source, target); + } + } + Json budget = fleet.get("budget_usd"); + if (!budget.isNull()) { + double v = 0; + if (!CapPy::toFloat(budget, v)) throw ValueError("[fleet] budget_usd must be a number"); + plan.budgetUsd = v; + } + Json maxRisk = fleet.get("max_risk", Json("medium")); + bool riskOk = false; + if (maxRisk.isString()) + for (auto r : CAP_ANSWER_RISKS) + if (maxRisk.str() == r) riskOk = true; + if (!riskOk) throw ValueError("[fleet] max_risk must be one of low, medium, high"); + for (const char* key : {"allow_tools", "deny_tools"}) + if (fleet.has(key) && !fleet.get(key).isArray()) throw ValueError(std::string("[fleet] ") + key + " must be a list"); + plan.start = start; + plan.task = task; + plan.sessions = sessions; + plan.autoAnswer = fleet.get("auto_answer", Json(false)).truthy(); + plan.allowTools = strings(fleet.get("allow_tools")); + plan.denyTools = strings(fleet.get("deny_tools")); + plan.maxRisk = maxRisk.str(); + return plan; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_cli.h b/taicpp/src/cap/cap_cli.h new file mode 100644 index 0000000..9d8dfe9 --- /dev/null +++ b/taicpp/src/cap/cap_cli.h @@ -0,0 +1,60 @@ +#pragma once +// CapCli: the `--cap` orchestrator commands: resolve, chat, run (cap_cli_main +// and friends in tai.py), the fleet.toml validation (cap_fleet_plan) and the +// auto-answer flags. Output goes to stdout/stderr exactly like print(). +#include +#include +#include +#include +#include + +#include "cap_answerer.h" +#include "cap_fleet.h" +#include "wire_argparse.h" + +namespace detz::cap { + +// The validated fleet.toml (the dict cap_fleet_plan returns). +struct CapFleetPlan { + std::string start; + std::string task; + std::optional budgetUsd; + Json sessions; // array of the [[sessions]] tables + std::vector> routes; + bool autoAnswer{false}; + std::vector allowTools; + std::vector denyTools; + std::string maxRisk{"medium"}; +}; + +class CapCli { + public: + // cap_cli_help() + static std::string capCliHelp(); + // cap_cli_main(argv): returns the exit code. + static int capCliMain(const std::vector& argv); + static int capResolveMain(const std::vector& argv); + static int capChatMain(const std::vector& argv); + static int capRunMain(const std::vector& argv); + // cap_print_result(result) + static void capPrintResult(const CapTurnResult& result); + // cap_build_answerer(auto, allow, deny, max_risk): null unless auto. + static std::shared_ptr capBuildAnswerer(bool autoAnswer, const std::vector& allow = {}, + const std::vector& deny = {}, + const std::string& maxRisk = "medium"); + // cap_answer_flags(parser) + static void capAnswerFlags(detz::wire::WireArgParse& parser); + // cap_fleet_plan(spec, task_override=None): throws ValueError. + static CapFleetPlan capFleetPlan(const Json& spec, const std::string* taskOverride = nullptr); + + // The three argparse parsers (prog strings are tai.py's). + static detz::wire::WireArgParse resolveParser(); + static detz::wire::WireArgParse chatParser(); + static detz::wire::WireArgParse runParser(); + + // print(...) to the real stdout / stderr. + static void out(const std::string& text); + static void err(const std::string& text); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_constants.h b/taicpp/src/cap/cap_constants.h new file mode 100644 index 0000000..a3c2e98 --- /dev/null +++ b/taicpp/src/cap/cap_constants.h @@ -0,0 +1,75 @@ +#pragma once +// CAP constants. Names and values are tai.py's, verbatim; docs/cap.md lists +// every one (CapDocTests drift check is mirrored in tests/cap_doc_test.cpp). +#include +#include +#include + +namespace detz::cap { + +inline constexpr std::string_view CAP_VERSION = "cap-protocol/v1"; +inline constexpr std::string_view CAP_MANIFEST_SCHEMA = "https://cap-protocol.org/schema/manifest/v1.json"; +inline constexpr std::string_view CAP_PROFILE_CODING = "cap-protocol/v1/profile/coding"; +inline constexpr std::string_view CAP_MANIFEST_REVISION = "2026-05-18"; +inline constexpr int CAP_PROBE_TIMEOUT = 15; + +// CAP_ERROR_CODES: numeric -> symbol, in the Python dict's order. +inline constexpr std::array, 15> CAP_ERROR_CODES = {{ + {-32001, "cap_manifest_missing"}, + {-32002, "cap_session_config_missing"}, + {-32003, "cap_unsupported_capability"}, + {-32004, "cap_budget_exceeded"}, + {-32005, "cap_invalid_answer"}, + {-32006, "cap_unknown_permission"}, + {-32007, "cap_queued_input_unsupported"}, + {-32008, "cap_cancel_unsupported"}, + {-32009, "cap_binding_unavailable"}, + {-32010, "cap_profile_unsupported"}, + {-32020, "cap_scope_violation"}, + {-32021, "cap_path_not_found"}, + {-32022, "cap_io_error"}, + {-32023, "cap_terminal_unavailable"}, + {-32024, "cap_terminal_killed"}, +}}; + +// CAP_ERROR_STRINGS: the cap.error string codes. +inline constexpr std::array CAP_ERROR_STRINGS = {{ + "rate_limit", "auth_error", "tool_timeout", "context_overflow", "model_unavailable", "pty_died", "parse_failed", + "git_dirty", "test_failed", "merge_conflict", "language_server_down", +}}; + +inline constexpr std::string_view CAP_SESSION_CONFIG_ENV = "CAP_SESSION_CONFIG"; +inline constexpr int CAP_SPEAK_MAX_TURNS = 50; +inline constexpr int CAP_SPEAK_DIFF_MAX = 65536; + +inline constexpr int CAP_PTY_SETTLE_MS = 800; +inline constexpr int CAP_PTY_IDLE_MS = 200; +inline constexpr int CAP_PTY_SUBMIT_GAP_MS = 150; +inline constexpr int CAP_PTY_RESUBMITS_MAX = 3; +inline constexpr int CAP_PTY_SCROLLBACK = 10000; +inline constexpr int CAP_PTY_INIT_TIMEOUT = 60; + +inline constexpr int CAP_STREAM_LINE_MAX = 1048576; +inline constexpr int CAP_STREAM_STDERR_KEEP = 50; + +inline constexpr int CAP_ACP_VERSION = 1; +inline constexpr int CAP_ACP_TERMINALS_MAX = 4; +inline constexpr int CAP_ACP_TERM_BUF = 1048576; +inline constexpr int CAP_ACP_TERM_WAIT_MAX = 600; +inline constexpr int CAP_ACP_FS_MAX = 1048576; + +inline constexpr int CAP_A2A_TIMEOUT = 30; +inline constexpr int CAP_ANSWER_TIMEOUT = 300; +inline constexpr int CAP_ANSWER_PARALLEL = 4; +inline constexpr std::string_view CAP_ANSWER_MARKER = "ANSWER:"; +inline constexpr std::array CAP_ANSWER_RISKS = {{"low", "medium", "high"}}; + +inline constexpr double CAP_SESSION_POLL = 0.1; +inline constexpr int CAP_TURN_TIMEOUT = 600; +inline constexpr std::string_view CAP_WORKTREE_DIR = ".cap-worktrees"; +inline constexpr int CAP_ROUTE_QUEUE_MAX = 16; +inline constexpr int CAP_FLEET_DIFF_MAX = 65536; +// CAP_MENTION_RE: @(cap://[A-Za-z0-9_.-]+) +inline constexpr std::string_view CAP_MENTION_RE = "@(cap://[A-Za-z0-9_.-]+)"; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_driver.cpp b/taicpp/src/cap/cap_driver.cpp new file mode 100644 index 0000000..4782544 --- /dev/null +++ b/taicpp/src/cap/cap_driver.cpp @@ -0,0 +1,198 @@ +#include "cap_driver.h" + +#include +#include + +#include "cap_constants.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::WireOs; + +const std::vector& CapDriver::STATES() { + static const std::vector states = {"starting", "ready", "working", "input_required", + "done", "failed", "closed"}; + return states; +} + +CapDriver::CapDriver(const CapManifest& manifest, std::string urn, std::string cwd, std::string model, + std::map env) + : manifest_(manifest), + urn_(std::move(urn)), + cwd_(cwd.empty() ? WireOs::getcwd() : std::move(cwd)), + model_(std::move(model)), + extraEnv_(std::move(env)) {} + +std::string CapDriver::state() const { return STATES()[static_cast(state_.load())]; } + +void CapDriver::setState(const std::string& name) { + const auto& all = STATES(); + auto it = std::find(all.begin(), all.end(), name); + state_ = static_cast(it - all.begin()); +} + +std::vector CapDriver::warnings() const { + std::lock_guard lock(mu_); + return warnings_; +} + +void CapDriver::addWarning(const std::string& text) { + std::lock_guard lock(mu_); + warnings_.push_back(text); +} + +bool CapDriver::hasOpenAsk(const std::string& askId) const { + std::lock_guard lock(mu_); + for (const auto& entry : openAsks_) + if (entry.first == askId) return true; + return false; +} + +size_t CapDriver::openAskCount() const { + std::lock_guard lock(mu_); + return openAsks_.size(); +} + +std::vector> CapDriver::openAsks() const { + std::lock_guard lock(mu_); + return openAsks_; +} + +void CapDriver::putAsk(const std::string& askId, const OpenAsk& ask) { + for (auto& entry : openAsks_) { + if (entry.first == askId) { + entry.second = ask; + return; + } + } + openAsks_.emplace_back(askId, ask); +} + +bool CapDriver::popAsk(const std::string& askId, OpenAsk& out) { + for (auto it = openAsks_.begin(); it != openAsks_.end(); ++it) { + if (it->first == askId) { + out = it->second; + openAsks_.erase(it); + return true; + } + } + return false; +} + +void CapDriver::eraseAsk(const std::string& askId) { + OpenAsk ignored; + popAsk(askId, ignored); +} + +void CapDriver::clearAsks() { openAsks_.clear(); } + +std::string CapDriver::cancelMode() const { + Json mode = manifest_.get("capabilities.mid_turn_cancel"); + if (mode.isNull()) mode = manifest_.get("pty.sigint_cancels_turn", Json("hard")); + if (mode.isString() && (mode.str() == "graceful" || mode.str() == "hard" || mode.str() == "none")) return mode.str(); + return "hard"; +} + +bool CapDriver::queued() const { + Json value = manifest_.get("capabilities.queued_input"); + if (value.isNull()) value = manifest_.get("pty.queued_input_supported", Json(false)); + return value.truthy(); +} + +double CapDriver::initTimeout() const { + Json value = manifest_.get("startup.init_timeout_seconds"); + if (value.truthy() && value.isNumber()) return value.asDouble(); + return CAP_PTY_INIT_TIMEOUT; +} + +std::vector CapDriver::drain() { + std::lock_guard lock(mu_); + std::vector out; + out.swap(events_); + return out; +} + +void CapDriver::emitEvent(const Json& event) { + std::lock_guard lock(mu_); + events_.push_back(event); +} + +std::map CapDriver::sessionEnv() const { + std::map env = WireOs::environ(); + for (const auto& kv : extraEnv_) env[kv.first] = kv.second; + Json sidEnv = manifest_.get("startup.session_id_env"); + if (sidEnv.truthy() && sidEnv.isString()) env[sidEnv.str()] = urn_; + return env; +} + +std::vector CapDriver::modelArgv(std::vector argv) { + if (model_.empty()) return argv; + Json modelArg = manifest_.get("startup.model_arg"); + if (modelArg.truthy() && modelArg.isString()) { + argv.push_back(modelArg.str()); + argv.push_back(model_); + return argv; + } + addWarning("model " + WireText::repr(model_) + " has nowhere to go, manifest lacks model_arg"); + return argv; +} + +std::vector CapDriver::start(double timeout) { + spawn(); + return runUntilReady(timeout); +} + +std::vector CapDriver::runUntilReady(double timeout) { + const double limit = timeout > 0 ? timeout : initTimeout(); + const double deadline = WireOs::now() + std::max(1.0, limit); + std::vector collected; + while (WireOs::now() < deadline) { + for (Json& event : poll(0.1)) collected.push_back(std::move(event)); + if (state() == "ready") return collected; + if (state() == "failed") throw CapError("cap_binding_unavailable", "agent died before ready"); + } + close(); + setState("failed"); + throw CapError("cap_binding_unavailable", + "agent never became ready within " + std::to_string(static_cast(limit)) + "s"); +} + +void CapDriver::waitReady() { + if (!hasProc() && state() == "starting") spawn(); + const double deadline = WireOs::now() + std::max(1.0, initTimeout()); + std::vector kept; + while (WireOs::now() < deadline) { + for (Json& event : poll(0.1)) kept.push_back(std::move(event)); + if (state() == "ready") { + std::lock_guard lock(mu_); + kept.insert(kept.end(), events_.begin(), events_.end()); + events_ = std::move(kept); + return; + } + if (state() == "failed") throw CapError("cap_binding_unavailable", "agent died before ready"); + } + throw CapError("cap_binding_unavailable", "agent never became ready"); +} + +void CapDriver::preSend() { + const std::string st = state(); + if (st == "failed" || st == "closed") throw CapError("cap_binding_unavailable", "session is over"); + if (st == "starting") waitReady(); + if (!hasProc()) throw CapError("cap_binding_unavailable", "session never started"); + if (state() == "working" && !queued()) + throw CapError("cap_queued_input_unsupported", "agent does not queue mid-turn input"); +} + +void CapDriver::answer(const std::string& askId, const Json& value) { + std::lock_guard lock(mu_); + OpenAsk ask; + if (!popAsk(askId, ask)) throw CapError("cap_invalid_answer", "no open question " + WireText::repr(askId)); + answerOpen(askId, ask, value); +} + +void CapDriver::answerOpen(const std::string&, const OpenAsk&, const Json&) { + throw CapError("cap_invalid_answer", "this binding takes no answers"); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_driver.h b/taicpp/src/cap/cap_driver.h new file mode 100644 index 0000000..add402c --- /dev/null +++ b/taicpp/src/cap/cap_driver.h @@ -0,0 +1,123 @@ +#pragma once +// CapDriver: shared orchestrator-driver plumbing: session identity, event +// queue, id sequences, open questions, the send/cancel guards, and the +// spawn-then-wait-ready start flow. Bindings implement spawn, sendText, poll, +// cancel and close; all drivers are synchronous and poll-based, one per +// session thread. +// +// State contract: done means the TURN completed and the session is alive +// (sendText starts a new turn); closed and failed are terminal (sendText +// throws). +// +// Thread model: a CapSession pumps poll() on its own thread while the caller +// uses sendText/answer/cancel/close. Public members take the driver's +// recursive mutex; blocking waits (select) happen outside it. +#include +#include +#include +#include +#include +#include +#include + +#include "cap_error.h" +#include "cap_manifest.h" + +namespace detz::cap { + +class CapDriver { + public: + // One entry of the unified open_asks stash. PTY drivers use only `kind` + // ("yes_no" / "options"); ACP stores (kind, rpc id, extra); A2A ("a2a_ask"). + struct OpenAsk { + std::string kind; + Json rpcId; + Json extra; + }; + + static const std::vector& STATES(); + + CapDriver(const CapManifest& manifest, std::string urn = "cap://agent-1", std::string cwd = "", + std::string model = "", std::map env = {}); + virtual ~CapDriver() = default; + CapDriver(const CapDriver&) = delete; + CapDriver& operator=(const CapDriver&) = delete; + + // Identity and wiring (public attributes in Python). + const CapManifest& manifest() const { return manifest_; } + const std::string& urn() const { return urn_; } + const std::string& cwd() const { return cwd_; } + const std::string& model() const { return model_; } + std::string state() const; + std::vector warnings() const; + bool hasOpenAsk(const std::string& askId) const; + size_t openAskCount() const; + std::vector> openAsks() const; + + // Manifest-derived knobs. + std::string cancelMode() const; + bool queued() const; + double initTimeout() const; + + std::vector drain(); + + // Spawns, then blocks until ready or the init timeout (0 = manifest value). + std::vector start(double timeout = 0); + // Answers an open question by its cap id. + void answer(const std::string& askId, const Json& value); + + // Binding hooks. + virtual void sendText(const std::string& text) = 0; + virtual std::vector poll(double timeout = 0.0) = 0; + virtual void cancel(const std::string& scope = "current_turn") = 0; + virtual void close() = 0; + + // CapDriver.create: builds the driver for the manifest's selected binding. + // `rawSink` is the PTY raw-bytes opt-in; `acpArgv` the out-of-band ACP server. + static std::unique_ptr create(const CapManifest& manifest, const std::string& urn = "cap://agent-1", + const std::string& cwd = "", const std::string& model = "", + const std::map& env = {}, + std::function rawSink = nullptr, + const std::vector& acpArgv = {}); + + protected: + virtual void spawn() = 0; + virtual bool hasProc() const = 0; + virtual void answerOpen(const std::string& askId, const OpenAsk& ask, const Json& value); + + std::vector runUntilReady(double timeout); + void waitReady(); + void preSend(); + void emitEvent(const Json& event); + std::map sessionEnv() const; + std::vector modelArgv(std::vector argv); + void setState(const std::string& name); + bool stateIs(const std::string& name) const { return state() == name; } + void addWarning(const std::string& text); + + CapManifest manifest_; + std::string urn_; + std::string cwd_; + std::string model_; + std::map extraEnv_; + + mutable std::recursive_mutex mu_; + std::vector events_; + std::vector warnings_; + std::vector> openAsks_; + int msgSeq_{0}; + int callSeq_{0}; + int askSeq_{0}; + int planSeq_{0}; + + // open_asks helpers. + void putAsk(const std::string& askId, const OpenAsk& ask); + bool popAsk(const std::string& askId, OpenAsk& out); + void eraseAsk(const std::string& askId); + void clearAsks(); + + private: + std::atomic state_{0}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_driver_factory.cpp b/taicpp/src/cap/cap_driver_factory.cpp new file mode 100644 index 0000000..4d5795e --- /dev/null +++ b/taicpp/src/cap/cap_driver_factory.cpp @@ -0,0 +1,24 @@ +// CapDriver::create lives apart from cap_driver.cpp so the base class does not +// depend on its four bindings. +#include "cap_a2a_driver.h" +#include "cap_acp_driver.h" +#include "cap_driver.h" +#include "cap_pty_driver.h" +#include "cap_stream_driver.h" + +namespace detz::cap { + +std::unique_ptr CapDriver::create(const CapManifest& manifest, const std::string& urn, + const std::string& cwd, const std::string& model, + const std::map& env, + std::function rawSink, + const std::vector& acpArgv) { + const std::string binding = manifest.binding(); + if (binding == "pty") return std::make_unique(manifest, urn, cwd, model, env, std::move(rawSink)); + if (binding == "stream_json") return std::make_unique(manifest, urn, cwd, model, env); + if (binding == "acp_stdio") return std::make_unique(manifest, urn, cwd, model, env, acpArgv); + if (binding == "a2a") return std::make_unique(manifest, urn, cwd, model, env); + throw CapError("cap_binding_unavailable", "no tai driver for binding " + WireText::repr(binding)); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_error.h b/taicpp/src/cap/cap_error.h new file mode 100644 index 0000000..70b397a --- /dev/null +++ b/taicpp/src/cap/cap_error.h @@ -0,0 +1,29 @@ +#pragma once +#include + +#include "cap_constants.h" +#include "cap_types.h" + +namespace detz::cap { + +// CAP_ERROR_SYMBOLS lookup: symbol -> numeric twin, 0 when not registered. +inline int capErrorNumeric(const std::string& symbol) { + for (const auto& entry : CAP_ERROR_CODES) + if (entry.second == symbol) return entry.first; + return 0; +} + +// A CAP wire failure: always carries the spec symbol and, when the symbol is +// registered, its JSON-RPC numeric twin. what() is the message, or the symbol +// when none was given (Python: super().__init__(message or symbol)). +class CapError : public std::runtime_error { + public: + explicit CapError(const std::string& symbolName, const std::string& message = "") + : std::runtime_error(message.empty() ? symbolName : message), + symbol(symbolName), + numeric(capErrorNumeric(symbolName)) {} + std::string symbol; + int numeric; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_event.cpp b/taicpp/src/cap/cap_event.cpp new file mode 100644 index 0000000..9c97857 --- /dev/null +++ b/taicpp/src/cap/cap_event.cpp @@ -0,0 +1,118 @@ +#include "cap_event.h" + +#include +#include + +namespace detz::cap { + +const std::vector& CapEvent::KINDS() { + static const std::vector kinds = { + "cap.session.ready", "cap.text_chunk", "cap.tool_call.start", "cap.tool_call.delta", "cap.tool_call.end", + "cap.plan", "cap.thought", "cap.ask_user", "cap.permission.request", "cap.usage", "cap.error", + "cap.artifact", "cap.fs.change", "cap.pty.raw_bytes", "cap.user_input.inject", "cap.cancel", + "cap.session.config", "cap.ask_user.answer", "cap.permission.response", + }; + return kinds; +} + +const char* CapEvent::DIRECTIONS(const std::string& kind) { + static const std::map table = { + {"cap.session.ready", FROM_AGENT}, {"cap.text_chunk", FROM_AGENT}, {"cap.tool_call.start", FROM_AGENT}, + {"cap.tool_call.delta", FROM_AGENT}, {"cap.tool_call.end", FROM_AGENT}, {"cap.plan", FROM_AGENT}, + {"cap.thought", FROM_AGENT}, {"cap.ask_user", FROM_AGENT}, {"cap.permission.request", FROM_AGENT}, + {"cap.usage", FROM_AGENT}, {"cap.error", FROM_AGENT}, {"cap.artifact", FROM_AGENT}, + {"cap.fs.change", FROM_AGENT}, {"cap.pty.raw_bytes", FROM_AGENT}, {"cap.user_input.inject", TO_AGENT}, + {"cap.cancel", TO_AGENT}, {"cap.session.config", TO_AGENT}, {"cap.ask_user.answer", TO_AGENT}, + {"cap.permission.response", TO_AGENT}, + }; + auto it = table.find(kind); + if (it == table.end()) throw std::out_of_range("KeyError: " + WireText::repr(kind)); + return it->second; +} + +const std::vector& CapEvent::REQUIRED(const std::string& kind) { + static const std::map> table = { + {"cap.session.ready", {}}, + {"cap.text_chunk", {"message_id", "text", "channel"}}, + {"cap.tool_call.start", {"call_id", "name", "input"}}, + {"cap.tool_call.delta", {"call_id", "output_chunk"}}, + {"cap.tool_call.end", {"call_id", "output", "is_error", "duration_ms"}}, + {"cap.plan", {"entries"}}, + {"cap.thought", {"message_id", "text"}}, + {"cap.ask_user", {"ask_id", "prompt", "form"}}, + {"cap.ask_user.answer", {"ask_id", "value"}}, + {"cap.permission.request", {"req_id", "tool", "intent", "scope", "risk_level"}}, + {"cap.permission.response", {"req_id", "decision"}}, + {"cap.user_input.inject", {"content"}}, + {"cap.cancel", {"scope", "reason"}}, + {"cap.usage", {"model_id"}}, + {"cap.session.config", {}}, + {"cap.error", {"code", "message", "retryable"}}, + {"cap.pty.raw_bytes", {"bytes_b64"}}, + {"cap.artifact", {"artifact_id", "mime_type", "name", "size"}}, + {"cap.fs.change", {"subscription_id", "path", "event"}}, + }; + static const std::vector none; + auto it = table.find(kind); + return it == table.end() ? none : it->second; +} + +const std::vector& CapEvent::ENUMS() { + static const std::vector rules = { + {"cap.text_chunk", "channel", {"assistant", "thought", "system"}}, + {"cap.cancel", "scope", {"current_turn", "session"}}, + {"cap.permission.request", "scope", {"read", "write", "execute", "network"}}, + {"cap.permission.request", "risk_level", {"low", "medium", "high"}}, + {"cap.permission.response", "decision", {"allow_once", "allow_always", "deny"}}, + {"cap.session.config", "permission_mode", {"none", "confirm", "interactive"}}, + {"cap.usage", "stop_reason", {"end_turn", "max_tokens", "tool_use", "stop_sequence", "cancelled", "error"}}, + {"cap.fs.change", "event", {"created", "modified", "deleted", "renamed"}}, + }; + return rules; +} + +Json CapEvent::make(const std::string& kind, const Json& fields, const Json& meta) { + const auto& kinds = KINDS(); + if (std::find(kinds.begin(), kinds.end(), kind) == kinds.end()) + throw ValueError("unknown CAP event kind: " + WireText::repr(kind)); + std::vector missing; + for (const std::string& name : REQUIRED(kind)) + if (!fields.has(name)) missing.push_back(name); + if (!missing.empty()) throw ValueError(kind + " missing required fields: " + WireText::join(missing, ", ")); + for (const EnumRule& rule : ENUMS()) { + if (kind != rule.kind) continue; + const Json* value = fields.find(rule.field); + if (!value) continue; + bool ok = false; + if (value->isString()) + for (const char* allowed : rule.allowed) + if (value->str() == allowed) ok = true; + if (!ok) { + std::string list; + for (size_t i = 0; i < rule.allowed.size(); ++i) list += (i ? "|" : "") + std::string(rule.allowed[i]); + throw ValueError(kind + "." + rule.field + " must be one of " + list + ", got " + value->pyRepr()); + } + } + Json event = Json::object(); + event.set("kind", Json(kind)); + event.update(fields); + if (!meta.isNull()) event.set("_meta", meta); + return event; +} + +std::string CapEvent::validate(const Json& event) { + const Json* kind = event.find("kind"); + const auto& kinds = KINDS(); + if (!event.isObject() || !kind || !kind->isString() || + std::find(kinds.begin(), kinds.end(), kind->str()) == kinds.end()) + throw ValueError("not a CAP event: " + event.pyRepr()); + std::vector missing; + for (const std::string& name : REQUIRED(kind->str())) + if (!event.has(name)) missing.push_back(name); + if (!missing.empty()) throw ValueError(kind->str() + " missing required fields: " + WireText::join(missing, ", ")); + return kind->str(); +} + +const char* CapEvent::direction(const std::string& kind) { return DIRECTIONS(kind); } + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_event.h b/taicpp/src/cap/cap_event.h new file mode 100644 index 0000000..e0af955 --- /dev/null +++ b/taicpp/src/cap/cap_event.h @@ -0,0 +1,40 @@ +#pragma once +// CapEvent: core event constructors and validators (spec section 7 plus the +// coding profile's cap.artifact and cap.fs.change). Internal construction +// mistakes throw ValueError; wire failures throw CapError. +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +class CapEvent { + public: + static constexpr const char* FROM_AGENT = "agent_to_orchestrator"; + static constexpr const char* TO_AGENT = "orchestrator_to_agent"; + + struct EnumRule { + const char* kind; + const char* field; + std::vector allowed; + }; + + // CapEvent.KINDS + static const std::vector& KINDS(); + // CapEvent.DIRECTIONS: kind -> FROM_AGENT / TO_AGENT + static const char* DIRECTIONS(const std::string& kind); + // CapEvent.REQUIRED: kind -> required field names (empty tuple for unknown) + static const std::vector& REQUIRED(const std::string& kind); + // CapEvent.ENUMS in declaration order. + static const std::vector& ENUMS(); + + // CapEvent.make(kind, meta=None, **fields). `fields` is an object whose key + // order becomes wire order; `meta` Null means None. + static Json make(const std::string& kind, const Json& fields, const Json& meta = Json()); + // CapEvent.validate(event) -> kind + static std::string validate(const Json& event); + static const char* direction(const std::string& kind); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_fleet.cpp b/taicpp/src/cap/cap_fleet.cpp new file mode 100644 index 0000000..c15e9e1 --- /dev/null +++ b/taicpp/src/cap/cap_fleet.cpp @@ -0,0 +1,378 @@ +#include "cap_fleet.h" + +#include +#include + +#include "cap_constants.h" +#include "cap_error.h" +#include "cap_py.h" +#include "wire_log.h" +#include "wire_os.h" +#include "wire_proc.h" +#include "wire_regex.h" + +namespace detz::cap { + +using detz::wire::OsError; +using detz::wire::SubprocessError; +using detz::wire::WireLog; +using detz::wire::WireOs; +using detz::wire::WireProc; +using detz::wire::WireRegex; + +CapFleet::CapFleet(const std::string& workdir, std::optional budgetUsd) + : workdir_(WireOs::abspath(workdir.empty() ? WireOs::getcwd() : workdir)), + budgetUsd_(budgetUsd), + shared_(std::make_shared()) {} + +CapFleet::~CapFleet() { + for (auto& entry : sessions_) entry.second->stopPump(); +} + +void CapFleet::setAnswerer(std::shared_ptr answerer) { answerer_ = std::move(answerer); } + +void CapFleet::log(Shared& shared, const std::string& kind, const std::string& sender, const std::string& recipient, + const std::string& text) { + std::lock_guard lock(shared.mutex); + shared.audit.push_back(Json::object({{"ts", Json(WireOs::now())}, + {"kind", Json(kind)}, + {"from", Json(sender)}, + {"to", Json(recipient)}, + {"text", Json(WireText::head(text, 2000))}})); +} + +void CapFleet::log(const std::string& kind, const std::string& sender, const std::string& recipient, + const std::string& text) { + log(*shared_, kind, sender, recipient, text); +} + +std::vector CapFleet::audit() const { + std::lock_guard lock(shared_->mutex); + return shared_->audit; +} + +CapSession* CapFleet::session(const std::string& urn) { + for (auto& entry : sessions_) + if (entry.first == urn) return entry.second.get(); + return nullptr; +} + +std::vector CapFleet::sessionUrns() const { + std::vector out; + for (const auto& entry : sessions_) out.push_back(entry.first); + return out; +} + +size_t CapFleet::pendingRoutes(const std::string& urn) const { + auto it = pendingRoutes_.find(urn); + return it == pendingRoutes_.end() ? 0 : it->second.size(); +} + +CapSession& CapFleet::add(const std::string& name, const std::string& agent, const AddOptions& options) { + CapRegistry local; + const CapRegistry& registry = options.registry ? *options.registry : local; + CapManifest manifest = registry.resolve(agent, agent); + return add(name, manifest, options); +} + +CapSession& CapFleet::add(const std::string& name, const CapManifest& manifest, const AddOptions& options) { + const std::string urn = "cap://" + name; + if (session(urn)) throw ValueError("fleet already has " + urn); + const std::string path = sessionCwd(name, options.cwd, manifest, options.worktree); + std::unique_ptr driver = CapDriver::create(manifest, urn, path, options.model.value_or(""), options.env, + nullptr, options.argv.value_or(std::vector())); + auto created = std::make_shared(urn, std::move(driver)); + created->addTap([this](const std::string& from, const Json& event) { + std::lock_guard lock(feedMutex_); + feed_.emplace_back(from, event); + }); + sessions_.emplace_back(urn, created); + pendingRoutes_[urn]; + log("session.add", "", urn, manifest.name() + " via " + manifest.binding() + " in " + path); + return *created; +} + +std::string CapFleet::sessionCwd(const std::string& name, const std::optional& cwd, + const CapManifest& manifest, bool worktree) { + if (cwd && !cwd->empty()) return WireOs::abspath(*cwd); + bool coding = false; + Json profiles = manifest.get("agent.profiles"); + if (profiles.truthy()) { + if (profiles.isArray()) { + for (const Json& p : profiles.items()) + if (p == Json("coding")) coding = true; + } else if (profiles.isString()) { + coding = WireText::contains(profiles.str(), "coding"); + } else if (profiles.isObject()) { + coding = profiles.has("coding"); + } + } + if (!worktree || !coding) return workdir_; + // slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-") or "agent" + std::string lowered = WireText::lower(name); + std::string slug; + bool inRun = false; + for (char32_t cp : WireText::codepoints(lowered)) { + const bool ok = (cp >= 'a' && cp <= 'z') || (cp >= '0' && cp <= '9') || cp == '-'; + if (ok) { + slug += static_cast(cp); + inRun = false; + } else if (!inRun) { + slug += '-'; + inRun = true; + } + } + slug = WireText::stripChars(slug, "-"); + if (slug.empty()) slug = "agent"; + const std::string path = WireText::pathJoin(WireText::pathJoin(workdir_, std::string(CAP_WORKTREE_DIR)), slug); + const std::string urn = "cap://" + name; + if (gitInside()) { + const std::string branch = "cap/" + slug; + WireProc::Options options; + options.argv = {"git", "worktree", "add", "-b", branch, path, "HEAD"}; + options.cwd = workdir_; + WireProc::Completed done = WireProc::run(options, 120); + if (done.returncode != 0) { + const std::string err = WireText::head(WireText::strip(WireText::decodeReplace(done.err)), 300); + throw CapError("cap_io_error", "worktree for " + name + " failed: " + err); + } + worktrees_.emplace_back(urn, path); + log("worktree.add", "", urn, path + " on " + branch); + return path; + } + WireOs::makedirs(path); + worktrees_.emplace_back(urn, path); + log("worktree.plain", "", urn, path + " (no git, unisolated)"); + return path; +} + +bool CapFleet::gitInside() const { + try { + WireProc::Options options; + options.argv = {"git", "rev-parse", "--is-inside-work-tree"}; + options.cwd = workdir_; + WireProc::Completed done = WireProc::run(options, 15); + return done.returncode == 0 && WireText::strip(done.out) == "true"; + } catch (const OsError&) { + return false; + } catch (const SubprocessError&) { + return false; + } +} + +void CapFleet::startAll(double timeout) { + for (auto& entry : sessions_) { + entry.second->start(timeout); + log("session.ready", "", entry.first); + } +} + +void CapFleet::publishPlan(const Json& plan) { + if (!plan.isObject() || plan.get("kind") != Json("cap.plan") || !plan.get("entries").isArray()) + throw ValueError("master plan must be a cap.plan event with entries"); + for (const Json& entry : plan.get("entries").items()) { + Json target; + Json meta = entry.get("_meta"); + if (meta.isObject()) target = meta.get("cap.assigned_to"); + if (!target.isNull() && !(target.isString() && session(target.str()))) + throw ValueError("plan assigns " + CapPy::str(target) + " to unknown session"); + } + masterPlan_ = plan; + log("plan.publish", "", "", std::to_string(plan.get("entries").items().size()) + " entries"); +} + +CapTurnResult CapFleet::ask(const std::string& urn, const std::string& text, double timeout) { + CapSession* target = session(urn); + if (!target) throw ValueError("unknown session " + urn); + if (budgetUsd_ && spentUsd() >= *budgetUsd_) + throw CapError("cap_budget_exceeded", "fleet spent " + WireText::fmtFloat(spentUsd(), 4) + " of " + + WireText::fmtFloat(*budgetUsd_, 4) + " USD"); + log("turn.start", "", urn, text); + const double started = WireOs::now(); + CapTurnResult result = target->ask(text, timeout); + const std::string state = result.state(); + const std::string outcome = state == "done" ? "ok" : state == "timeout" ? "timeout" : "error"; + WireLog::event("INFO", "cap", "turn", "cap turn to " + urn + " finished: " + state, "", + Json::object({{"target", Json(urn)}, + {"payload_chars", Json(static_cast(WireText::length(text)))}, + {"exec_ms", Json(static_cast((WireOs::now() - started) * 1000))}, + {"reply_chars", Json(static_cast(WireText::length(result.text())))}, + {"outcome", Json(outcome)}})); + log("turn.end", "", urn, state + ", " + std::to_string(result.events().size()) + " events"); + pump(); + return result; +} + +double CapFleet::spentUsd() const { + double total = 0.0; + for (const auto& entry : sessions_) total += entry.second->usage().costUsdEstimate; + return total; +} + +int CapFleet::pump() { + int routed = 0; + while (true) { + std::pair item; + { + std::lock_guard lock(feedMutex_); + if (feed_.empty()) break; + item = std::move(feed_.front()); + feed_.pop_front(); + } + const Json kind = item.second.get("kind"); + if (kind == Json("cap.text_chunk")) routed += routeMentions(item.first, CapPy::orStr(item.second.get("text"), "")); + else if (kind == Json("cap.ask_user") || kind == Json("cap.permission.request")) maybeAnswer(item.first, item.second); + } + for (auto& entry : sessions_) routed += deliverPending(entry.first, *entry.second); + return routed; +} + +void CapFleet::maybeAnswer(const std::string& urn, const Json& event) { + if (!answerer_) return; + Json keyJson = event.get("ask_id"); + if (!keyJson.truthy()) keyJson = event.get("req_id"); + const std::string key = keyJson.truthy() ? CapPy::str(keyJson) : ""; + CapSession* target = session(urn); + if (!target || !target->driver().hasOpenAsk(key)) return; + { + std::lock_guard lock(shared_->mutex); + if (shared_->answering.count(key)) return; + shared_->answering.insert(key); + } + log("answer.start", "", urn, key); + std::shared_ptr sess; + for (auto& entry : sessions_) + if (entry.first == urn) sess = entry.second; + std::shared_ptr shared = shared_; + std::shared_ptr answerer = answerer_; + std::thread([shared, answerer, sess, urn, key, event]() { + try { + if (sess && answerer) { + answerer->handle(*sess, event); + log(*shared, "answer.done", "", urn, key); + } + } catch (const std::exception& exc) { + log(*shared, "answer.error", "", urn, key + ": " + CapPy::shortError(exc.what())); + } + std::lock_guard lock(shared->mutex); + shared->answering.erase(key); + }).detach(); +} + +int CapFleet::routeMentions(const std::string& sender, const std::string& text) { + static const WireRegex mention{std::string(CAP_MENTION_RE)}; + int count = 0; + for (const std::string& target : mention.findallGroup1(text)) { + if (target == sender || !session(target)) { + log("route.drop", sender, target, "unknown or self target"); + continue; + } + auto& pending = pendingRoutes_[target]; + if (static_cast(pending.size()) >= CAP_ROUTE_QUEUE_MAX) { + log("route.drop", sender, target, "pending queue full"); + continue; + } + pending.emplace_back(sender, WireText::head(WireText::strip(text), 2000)); + log("route.queue", sender, target, text); + ++count; + } + return count; +} + +int CapFleet::deliverPending(const std::string& urn, CapSession& sess) { + const std::string st = sess.driver().state(); + if (st != "ready" && st != "done") return 0; + int count = 0; + auto& pending = pendingRoutes_[urn]; + while (!pending.empty()) { + auto item = pending.front(); + pending.pop_front(); + try { + sess.driver().sendText("[from " + item.first + "]\n" + item.second); + } catch (const CapError& exc) { + log("route.drop", item.first, urn, exc.what()); + continue; + } + log("route.send", item.first, urn, item.second); + ++count; + } + return count; +} + +bool CapFleet::waitIdle(double timeout) { + const double deadline = WireOs::now() + std::max(1.0, timeout); + while (WireOs::now() < deadline) { + pump(); + bool busy = false; + for (auto& entry : sessions_) { + const std::string st = entry.second->driver().state(); + if (st == "starting" || st == "working" || !pendingRoutes_[entry.first].empty()) busy = true; + } + if (!busy) { + std::lock_guard lock(shared_->mutex); + busy = !shared_->answering.empty(); + } + if (!busy) return true; + WireOs::sleep(CAP_SESSION_POLL); + } + return false; +} + +Json CapFleet::collectDiffs() { + Json result = Json::object(); + for (const auto& wt : worktrees_) { + try { + WireProc::Options options; + options.argv = {"git", "diff", "HEAD", "--"}; + options.cwd = wt.second; + WireProc::Completed done = WireProc::run(options, 60); + const std::string diff = done.out; + result.set(wt.first, Json::object({{"size", Json(static_cast(diff.size()))}, + {"truncated", Json(diff.size() > static_cast(CAP_FLEET_DIFF_MAX))}, + {"diff", Json(WireText::decodeReplace(diff.substr(0, CAP_FLEET_DIFF_MAX)))}})); + } catch (const OsError& exc) { + result.set(wt.first, Json::object({{"note", Json("diff failed: " + CapPy::shortError(exc.what(), "OSError"))}})); + } catch (const SubprocessError& exc) { + result.set(wt.first, Json::object({{"note", Json("diff failed: " + CapPy::shortError(exc.what()))}})); + } + } + for (const auto& entry : sessions_) + if (!result.has(entry.first)) result.set(entry.first, Json::object({{"note", Json("no worktree")}})); + return result; +} + +void CapFleet::cancelAll(const std::string& scope) { + for (auto& entry : sessions_) { + try { + entry.second->cancel(scope); + } catch (const CapError&) { + } + } +} + +void CapFleet::closeAll() { + for (auto& entry : sessions_) { + try { + entry.second->close(); + } catch (...) { + } + } + for (const auto& wt : worktrees_) { + try { + WireProc::Options options; + options.argv = {"git", "worktree", "remove", "--force", wt.second}; + options.cwd = workdir_; + WireProc::Completed done = WireProc::run(options, 120); + if (done.returncode != 0) WireOs::rmtree(wt.second, true); + WireProc::Options prune; + prune.argv = {"git", "worktree", "prune"}; + prune.cwd = workdir_; + WireProc::run(prune, 60); + } catch (const OsError&) { + } catch (const SubprocessError&) { + } + log("worktree.remove", "", wt.first, wt.second); + } +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_fleet.h b/taicpp/src/cap/cap_fleet.h new file mode 100644 index 0000000..7935ef2 --- /dev/null +++ b/taicpp/src/cap/cap_fleet.h @@ -0,0 +1,95 @@ +#pragma once +// CapFleet: N orchestrated sessions (spec section 10): worktree isolation, +// master plan, mediated @cap:// routing with per-session pending queues, +// budget aggregation with hard refusal, and the human audit log. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cap_answerer.h" +#include "cap_registry.h" +#include "cap_session.h" + +namespace detz::cap { + +// Optional arguments of CapFleet::add (cwd, model, env, worktree, registry, argv). +struct CapFleetAddOptions { + std::optional cwd; + std::optional model; + std::map env; + bool worktree{true}; + const CapRegistry* registry{nullptr}; + std::optional> argv; +}; + +class CapFleet { + public: + using AddOptions = CapFleetAddOptions; + + // workdir empty -> current directory; no budget means None. + explicit CapFleet(const std::string& workdir = "", std::optional budgetUsd = std::nullopt); + ~CapFleet(); + + void setAnswerer(std::shared_ptr answerer); + CapSession& add(const std::string& name, const CapManifest& manifest, const AddOptions& options = AddOptions()); + CapSession& add(const std::string& name, const std::string& agent, const AddOptions& options = AddOptions()); + void startAll(double timeout = 0); + void publishPlan(const Json& plan); + CapTurnResult ask(const std::string& urn, const std::string& text, double timeout = CAP_TURN_TIMEOUT); + double spentUsd() const; + // Drains the tapped feed, routes @cap:// mentions, hands open questions to + // the answerer, delivers pending routes. Returns the routed count. + int pump(); + bool waitIdle(double timeout = CAP_TURN_TIMEOUT); + // Per-session uncommitted diffs (bounded); merging stays the operator's call. + Json collectDiffs(); + void cancelAll(const std::string& scope = "session"); + void closeAll(); + + // Introspection used by callers and tests. + const std::string& workdir() const { return workdir_; } + CapSession* session(const std::string& urn); + std::vector sessionUrns() const; + std::vector audit() const; + // urn -> worktree path, in creation order. + const std::vector>& worktrees() const { return worktrees_; } + const Json& masterPlan() const { return masterPlan_; } + size_t pendingRoutes(const std::string& urn) const; + + private: + struct Shared { + std::mutex mutex; + std::vector audit; + std::set answering; + }; + + static void log(Shared& shared, const std::string& kind, const std::string& sender, const std::string& recipient, + const std::string& text); + void log(const std::string& kind, const std::string& sender = "", const std::string& recipient = "", + const std::string& text = ""); + std::string sessionCwd(const std::string& name, const std::optional& cwd, const CapManifest& manifest, + bool worktree); + bool gitInside() const; + void maybeAnswer(const std::string& urn, const Json& event); + int routeMentions(const std::string& sender, const std::string& text); + int deliverPending(const std::string& urn, CapSession& session); + + std::string workdir_; + std::optional budgetUsd_; + std::vector>> sessions_; + std::vector> worktrees_; + std::map>> pendingRoutes_; + Json masterPlan_; + std::shared_ptr shared_; + std::mutex feedMutex_; + std::deque> feed_; + std::shared_ptr answerer_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_interrupt.h b/taicpp/src/cap/cap_interrupt.h new file mode 100644 index 0000000..0140073 --- /dev/null +++ b/taicpp/src/cap/cap_interrupt.h @@ -0,0 +1,28 @@ +#pragma once +// CapInterrupt: Python's KeyboardInterrupt for the CAP entry points. The +// signal handler (cap_interrupt in tai.py) only raises a flag; blocking +// loops poll check() and turn it into CapInterrupted. +#include + +#include "cap_agent_port.h" + +namespace detz::cap { + +class CapInterrupt { + public: + // cap_interrupt(signum, frame): installed for SIGTERM/SIGINT. + static void capInterrupt(int) { flag() = 1; } + static bool requested() { return flag() != 0; } + static void clear() { flag() = 0; } + static void check() { + if (flag()) throw CapInterrupted(); + } + + private: + static volatile std::sig_atomic_t& flag() { + static volatile std::sig_atomic_t value = 0; + return value; + } +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_manifest.cpp b/taicpp/src/cap/cap_manifest.cpp new file mode 100644 index 0000000..3de4f0f --- /dev/null +++ b/taicpp/src/cap/cap_manifest.cpp @@ -0,0 +1,146 @@ +#include "cap_manifest.h" + +#include "cap_binding.h" +#include "wire_regex.h" +#include "wire_toml.h" + +namespace detz::cap { + +const std::vector& CapManifest::REQUIRED() { + static const std::vector required = { + "agent.name", "agent.binary", "agent.profiles", "startup.command", "startup.ready_when", + "pty.cols", "pty.rows", "capabilities.streaming_output", + }; + return required; +} + +const Json& CapManifest::DEFAULTS() { + static const Json defaults = [] { + Json d = Json::object(); + d.set("pty.bracketed_paste", Json(false)); + d.set("pty.sigint_cancels_turn", Json("hard")); + d.set("pty.queued_input_supported", Json(false)); + d.set("capabilities.multi_session", Json(false)); + d.set("capabilities.streaming_tool_output", Json(false)); + d.set("cost.metered", Json(false)); + d.set("parse.idle", Json::array({Json("^>\\s*$"), Json("^\xE2\x9D\xAF\\s*$")})); + d.set("parse.error_lines", Json::array()); + return d; + }(); + return defaults; +} + +const std::vector& CapManifest::PATTERN_FIELDS() { + static const std::vector fields = { + "agent.version_match", "probe.version_pattern", "startup.ready_when.pattern", "parse.tool_call_start", + "parse.tool_call_end", "parse.plan_section", "parse.thought_section", "parse.ask_yes_no", "parse.ask_options", + }; + return fields; +} + +const std::vector& CapManifest::PATTERN_LIST_FIELDS() { + static const std::vector fields = {"parse.idle", "parse.error_lines"}; + return fields; +} + +CapManifest::CapManifest(const Json& data, const std::string& source) : data_(data), source_(source) { + const std::string where = source.empty() ? "unknown source" : source; + if (!data.isObject()) throw ValueError("CAP manifest from " + where + " is not a table"); + std::vector missing; + for (const std::string& path : REQUIRED()) + if (get(path).isNull()) missing.push_back(path); + if (!missing.empty()) + throw ValueError("CAP manifest from " + where + " missing required fields: " + WireText::join(missing, ", ")); + checkPatterns(source); +} + +CapManifest CapManifest::fromToml(const std::string& text, const std::string& source) { + Json data; + try { + data = detz::wire::WireToml::loads(text); + } catch (const std::exception& exc) { + throw ValueError("CAP manifest from " + (source.empty() ? std::string("unknown source") : source) + + " does not parse: " + exc.what()); + } + return CapManifest(data, source); +} + +Json CapManifest::get(const std::string& dotted, const Json& fallback) const { + const Json* node = &data_; + for (const std::string& part : WireText::split(dotted, ".")) { + const Json* next = node->isObject() ? node->find(part) : nullptr; + if (!next) { + const Json* def = DEFAULTS().find(dotted); + if (def && fallback.isNull()) return *def; + return fallback; + } + node = next; + } + return *node; +} + +std::string CapManifest::name() const { + Json v = get("agent.name", Json("")); + return v.isString() ? v.str() : v.pyStr(); +} + +std::string CapManifest::binary() const { + Json v = get("agent.binary", Json("")); + return v.isString() ? v.str() : v.pyStr(); +} + +bool CapManifest::askUser(const std::string& form) const { + Json table = get("capabilities.ask_user", Json::object()); + if (!table.truthy()) table = Json::object(); + return table.get(form, Json(false)).truthy(); +} + +std::string CapManifest::binding() const { return CapBinding::select(*this); } + +void CapManifest::checkPatterns(const std::string& source) const { + const std::string label = source.empty() ? "manifest" : source; + std::vector> patterns; + for (const std::string& path : PATTERN_FIELDS()) { + Json value = get(path); + if (!value.isNull()) patterns.emplace_back(path, value); + } + for (const std::string& path : PATTERN_LIST_FIELDS()) { + Json list = get(path, Json::array()); + if (!list.truthy()) continue; + if (list.isArray()) { + for (const Json& item : list.items()) patterns.emplace_back(path, item); + } else if (list.isString()) { + for (char32_t cp : WireText::codepoints(list.str())) patterns.emplace_back(path, Json(WireText::encode(cp))); + } else if (list.isObject()) { + for (const auto& member : list.members()) patterns.emplace_back(path, Json(member.first)); + } else { + const char* type = list.isBool() ? "bool" : list.isFloat() ? "float" : "int"; + throw detz::wire::PyTypeError(std::string("'") + type + "' object is not iterable"); + } + } + for (const auto& entry : patterns) { + const std::string& path = entry.first; + const Json& value = entry.second; + if (!value.isString()) throw ValueError("CAP manifest " + label + "." + path + " must be a string"); + const std::string& text = value.str(); + // LOOKAROUND_RE: \(\?= '1' && stripped[k + 1] <= '9') + throw ValueError("CAP manifest " + label + "." + path + " uses a backreference, outside the \xC2\xA7" "5.4 subset"); + try { + detz::wire::WireRegex probe(text); + } catch (const detz::wire::RegexError& exc) { + throw ValueError("CAP manifest " + label + "." + path + " does not compile: " + exc.what()); + } + } +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_manifest.h b/taicpp/src/cap/cap_manifest.h new file mode 100644 index 0000000..7d357f5 --- /dev/null +++ b/taicpp/src/cap/cap_manifest.h @@ -0,0 +1,44 @@ +#pragma once +// CapManifest: one parsed agent manifest (spec section 5): required-field +// validation, section 5.3 defaults, and the regex-flavor gate. Tai +// deliberately accepts Python named groups, which every shipped example +// manifest uses, and rejects only lookaround plus backreferences. +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +class CapManifest { + public: + // CapManifest.REQUIRED, DEFAULTS, PATTERN_FIELDS, PATTERN_LIST_FIELDS + static const std::vector& REQUIRED(); + // DEFAULTS as an ordered object: dotted path -> value. + static const Json& DEFAULTS(); + static const std::vector& PATTERN_FIELDS(); + static const std::vector& PATTERN_LIST_FIELDS(); + + // Throws ValueError on a non-table, a missing required field or a bad pattern. + CapManifest(const Json& data, const std::string& source = ""); + // CapManifest.from_toml: ValueError carrying the same text as Python. + static CapManifest fromToml(const std::string& text, const std::string& source = ""); + + // CapManifest.get(dotted, default): walks the tables; DEFAULTS fill in only + // when no explicit default was passed (Null). + Json get(const std::string& dotted, const Json& fallback = Json()) const; + std::string name() const; + std::string binary() const; + bool askUser(const std::string& form) const; + void checkPatterns(const std::string& source = "") const; + std::string binding() const; + + const Json& data() const { return data_; } + const std::string& source() const { return source_; } + + private: + Json data_; + std::string source_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_pipe_reader.cpp b/taicpp/src/cap/cap_pipe_reader.cpp new file mode 100644 index 0000000..0d2c310 --- /dev/null +++ b/taicpp/src/cap/cap_pipe_reader.cpp @@ -0,0 +1,67 @@ +#include "cap_pipe_reader.h" + +#include + +#include + +#include "cap_constants.h" +#include "wire_proc.h" +#include "wire_text.h" + +namespace detz::cap { + +using detz::wire::WireProc; +using detz::wire::WireText; + +std::vector CapPipeReader::readLines(double timeout) { + if (!eof_) { + if (!WireProc::waitReadable(fd_, timeout < 0 ? 0.0 : timeout)) return splitLines(); + } + while (!eof_) { + char chunk[65536]; + ssize_t n = ::read(fd_, chunk, sizeof chunk); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + eof_ = true; + break; + } + if (n == 0) { + eof_ = true; + break; + } + buf_.append(chunk, static_cast(n)); + if (buf_.size() > static_cast(CAP_STREAM_LINE_MAX) && buf_.find('\n') == std::string::npos) { + buf_.clear(); + ++dropped_; + break; + } + } + return splitLines(); +} + +std::vector CapPipeReader::splitLines() { + std::vector lines; + while (true) { + const size_t mark = buf_.find('\n'); + if (mark == std::string::npos) { + if (buf_.size() > static_cast(CAP_STREAM_LINE_MAX)) { + buf_.clear(); + ++dropped_; + } + break; + } + std::string raw = WireText::decodeReplace(buf_.substr(0, mark)); + buf_.erase(0, mark + 1); + if (!WireText::strip(raw).empty()) lines.push_back(std::move(raw)); + } + return lines; +} + +size_t CapPipeReader::takeDropped() { + const size_t count = dropped_; + dropped_ = 0; + return count; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_pipe_reader.h b/taicpp/src/cap/cap_pipe_reader.h new file mode 100644 index 0000000..abcfbae --- /dev/null +++ b/taicpp/src/cap/cap_pipe_reader.h @@ -0,0 +1,33 @@ +#pragma once +// CapPipeReader: non-blocking NDJSON line pump over one fd, shared by the +// stream-json and ACP-stdio drivers. Overlong lines are dropped loudly via +// the dropped counter. +#include +#include + +namespace detz::cap { + +class CapPipeReader { + public: + explicit CapPipeReader(int fd) : fd_(fd) {} + + // Reads what the fd offers (waiting up to `timeout` seconds for the first + // byte) and returns the complete lines, blank ones dropped. + std::vector readLines(double timeout = 0.0); + size_t takeDropped(); + + bool eof() const { return eof_; } + bool bufferEmpty() const { return buf_.empty(); } + size_t dropped() const { return dropped_; } + int fd() const { return fd_; } + + private: + std::vector splitLines(); + + int fd_; + std::string buf_; + bool eof_{false}; + size_t dropped_{0}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_proc_util.cpp b/taicpp/src/cap/cap_proc_util.cpp new file mode 100644 index 0000000..db532dc --- /dev/null +++ b/taicpp/src/cap/cap_proc_util.cpp @@ -0,0 +1,79 @@ +#include "cap_proc_util.h" + +#include + +#include + +#include "wire_text.h" + +namespace detz::cap { + +using detz::wire::WireProc; +using detz::wire::WireText; + +CapStderrDrain::CapStderrDrain(int fd, size_t keep) : fd_(fd), keep_(keep) { + thread_ = std::thread([this] { run(); }); +} + +CapStderrDrain::~CapStderrDrain() { stop(); } + +void CapStderrDrain::stop() { + stop_ = true; + if (thread_.joinable()) thread_.join(); +} + +std::vector CapStderrDrain::lines() const { + std::lock_guard lock(mutex_); + return std::vector(lines_.begin(), lines_.end()); +} + +bool CapStderrDrain::empty() const { + std::lock_guard lock(mutex_); + return lines_.empty(); +} + +void CapStderrDrain::run() { + std::string pending; + auto push = [&](const std::string& raw) { + std::string line = WireText::head(WireText::rstrip(WireText::decodeReplace(raw)), 500); + std::lock_guard lock(mutex_); + lines_.push_back(line); + while (lines_.size() > keep_) lines_.pop_front(); + }; + char buf[65536]; + while (!stop_) { + if (!WireProc::waitReadable(fd_, 0.1)) continue; + ssize_t n = ::read(fd_, buf, sizeof buf); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN) continue; + break; + } + if (n == 0) break; + pending.append(buf, static_cast(n)); + size_t nl; + while ((nl = pending.find('\n')) != std::string::npos) { + push(pending.substr(0, nl + 1)); + pending.erase(0, nl + 1); + } + } + if (!pending.empty()) push(pending); +} + +void CapProcUtil::capProcKill(WireProc& proc) { + try { + proc.terminate(); + if (!proc.wait(5)) { + proc.kill(); + proc.wait(1); // reap, so the child does not linger as a zombie + } + } catch (const detz::wire::OsError&) { + } +} + +void CapProcUtil::capProcReap(WireProc& proc, CapStderrDrain* drain) { + capProcKill(proc); + if (drain) drain->stop(); + proc.closePipes(); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_proc_util.h b/taicpp/src/cap/cap_proc_util.h new file mode 100644 index 0000000..a94016f --- /dev/null +++ b/taicpp/src/cap/cap_proc_util.h @@ -0,0 +1,48 @@ +#pragma once +// cap_proc_kill, cap_proc_reap and cap_stderr_drain: the shared child +// handling of the pipe drivers (stream-json, ACP-stdio, MCP stdio). +#include +#include +#include +#include +#include +#include + +#include "cap_constants.h" +#include "wire_proc.h" + +namespace detz::cap { + +// cap_stderr_drain: a daemon thread keeping the last `keep` stderr lines. +class CapStderrDrain { + public: + explicit CapStderrDrain(int fd, size_t keep = CAP_STREAM_STDERR_KEEP); + ~CapStderrDrain(); + CapStderrDrain(const CapStderrDrain&) = delete; + CapStderrDrain& operator=(const CapStderrDrain&) = delete; + + std::vector lines() const; + bool empty() const; + // Stops the thread and joins it (cap_proc_reap's stderr_thread.join(5)). + void stop(); + + private: + void run(); + + int fd_; + size_t keep_; + mutable std::mutex mutex_; + std::deque lines_; + std::atomic stop_{false}; + std::thread thread_; +}; + +class CapProcUtil { + public: + // cap_proc_kill(proc): terminate, wait up to 5s, then kill. + static void capProcKill(detz::wire::WireProc& proc); + // cap_proc_reap(proc, stderr_thread): kill, close the pipes, join the drain. + static void capProcReap(detz::wire::WireProc& proc, CapStderrDrain* drain = nullptr); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_pty_driver.cpp b/taicpp/src/cap/cap_pty_driver.cpp new file mode 100644 index 0000000..6965dc1 --- /dev/null +++ b/taicpp/src/cap/cap_pty_driver.cpp @@ -0,0 +1,430 @@ +#include "cap_pty_driver.h" + +#include + +#include "cap_constants.h" +#include "cap_event.h" +#include "cap_vt_screen.h" +#include "wire_hash.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::RegexError; +using detz::wire::WireHash; +using detz::wire::WireOs; +using detz::wire::WirePty; +using detz::wire::WireRegex; + +namespace { + +CapPtyDriver::ScreenFactory& screenFactorySlot() { + static CapPtyDriver::ScreenFactory factory = [](int rows, int cols) -> std::unique_ptr { + return std::make_unique(rows, cols); + }; + return factory; +} + +// `for pattern in manifest.get(path, []) or []`: lists iterate items, a str +// iterates characters, a dict its keys. +std::vector iterate(const Json& value) { + std::vector out; + if (!value.truthy()) return out; + if (value.isArray()) return value.items(); + if (value.isString()) { + for (char32_t cp : WireText::codepoints(value.str())) out.push_back(Json(WireText::encode(cp))); + } else if (value.isObject()) { + for (const auto& m : value.members()) out.push_back(Json(m.first)); + } + return out; +} + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } + +} // namespace + +void CapPtyDriver::setScreenFactory(ScreenFactory factory) { screenFactorySlot() = std::move(factory); } + +std::optional CapPtyDriver::compileOpt(const Json& pattern) { + if (!pattern.truthy()) return std::nullopt; + return WireRegex(str(pattern)); +} + +CapPtyDriver::CapPtyDriver(const CapManifest& manifest, std::string urn, std::string cwd, std::string model, + std::map env, std::function rawSink) + : CapDriver(manifest, std::move(urn), std::move(cwd), std::move(model), std::move(env)), + rawSink_(std::move(rawSink)) { + Json rowsJson = manifest_.get("pty.rows"); + Json colsJson = manifest_.get("pty.cols"); + const int rows = rowsJson.truthy() ? static_cast(rowsJson.asInt()) : 24; + const int cols = colsJson.truthy() ? static_cast(colsJson.asInt()) : 80; + screen_ = screenFactorySlot()(rows, cols); + for (const Json& p : iterate(manifest_.get("parse.idle", Json::array()))) idle_.emplace_back(str(p)); + toolStart_ = compileOpt(manifest_.get("parse.tool_call_start")); + toolEnd_ = compileOpt(manifest_.get("parse.tool_call_end")); + planMark_ = compileOpt(manifest_.get("parse.plan_section")); + thoughtMark_ = compileOpt(manifest_.get("parse.thought_section")); + askYesNo_ = compileOpt(manifest_.get("parse.ask_yes_no")); + askOptions_ = compileOpt(manifest_.get("parse.ask_options")); + for (const Json& p : iterate(manifest_.get("parse.error_lines", Json::array()))) errorRes_.emplace_back(str(p)); + Json readyWhen = manifest_.get("startup.ready_when"); + Json ready = readyWhen.truthy() ? readyWhen.get("pattern") : Json(); + if (ready.truthy()) readyRe_ = WireRegex(str(ready)); + prevRows_.assign(static_cast(rows), ""); +} + +CapPtyDriver::~CapPtyDriver() = default; + +void CapPtyDriver::spawn() { + std::vector argv; + Json cmd = manifest_.get("startup.command"); + for (const Json& part : iterate(cmd)) argv.push_back(str(part)); + if (argv.empty()) throw CapError("cap_binding_unavailable", "manifest has no startup.command"); + { + std::lock_guard lock(mu_); + proc_ = WirePty::spawn(modelArgv(argv), cwd_, sessionEnv(), screen_->rows(), screen_->cols()); + } + if (manifest_.get("pty.bracketed_paste").truthy()) writeText("\x1b[?2004h"); + lastBytesAt_ = WireOs::now(); +} + +void CapPtyDriver::writeText(const std::string& text) { + try { + proc_->write(text); + } catch (const detz::wire::OsError& exc) { + throw CapError("cap_binding_unavailable", "pty write failed: " + WireText::head(exc.what(), 300)); + } +} + +void CapPtyDriver::sendText(const std::string& text) { + std::unique_lock lock(mu_); + preSend(); + if (manifest_.get("pty.bracketed_paste").truthy() && (WireText::length(text) > 1024 || text.find('\n') != std::string::npos)) + writeText("\x1b[200~" + text + "\x1b[201~"); + else + writeText(text); + lock.unlock(); + WireOs::sleep(CAP_PTY_SUBMIT_GAP_MS / 1000.0); + lock.lock(); + writeText("\r"); + promptSent_ = true; + submitKey_ = WireText::head(WireText::split(text, "\n", 1)[0], 60); + resubmits_ = 0; + clearAsks(); + seenLineEvents_.clear(); + ++msgSeq_; + turnMsgId_ = "msg_" + std::to_string(msgSeq_); + setState("working"); +} + +void CapPtyDriver::answerOpen(const std::string&, const OpenAsk& ask, const Json& value) { + if (ask.kind == "yes_no") { + const std::string word = WireText::lower(WireText::strip(value.pyStr())); + writeText(word == "y" || word == "yes" || word == "true" || word == "1" ? "y" : "n"); + WireOs::sleep(CAP_PTY_SUBMIT_GAP_MS / 1000.0); + writeText("\r"); + } else { + sendText(value.pyStr()); + return; + } + setState("working"); +} + +void CapPtyDriver::cancel(const std::string& scope) { + std::unique_lock lock(mu_); + if (!proc_) throw CapError("cap_binding_unavailable", "session never started"); + if (scope == "session") { + lock.unlock(); + close(); + return; + } + const std::string mode = cancelMode(); + if (mode == "none") throw CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none"); + if (mode == "graceful") { + writeText("\x03"); + } else { + terminate(); + setState("done"); + } +} + +void CapPtyDriver::resize(int rows, int cols) { + std::lock_guard lock(mu_); + if (proc_) { + try { + proc_->resize(rows, cols); + } catch (const detz::wire::OsError&) { + } + } + screen_->resize(rows, cols); + prevRows_.assign(static_cast(screen_->rows()), ""); +} + +void CapPtyDriver::close() { + std::lock_guard lock(mu_); + if (proc_) { + try { + terminate(); + } catch (...) { + proc_->close(); + proc_.reset(); + throw; + } + proc_->close(); + proc_.reset(); + } + if (state() != "failed") setState("closed"); +} + +void CapPtyDriver::terminate() { + if (!proc_->killpgTerm()) { + proc_->kill(); + return; + } + const double deadline = WireOs::now() + 5; + while (WireOs::now() < deadline) { + if (proc_->poll()) return; + WireOs::sleep(0.05); + } + proc_->kill(); + proc_->poll(); +} + +std::vector CapPtyDriver::poll(double timeout) { + int fd = -1; + { + std::lock_guard lock(mu_); + const std::string st = state(); + if (!proc_ || st == "failed" || st == "closed") return drain(); + fd = proc_->masterFd(); + } + const bool readable = detz::wire::WireProc::waitReadable(fd, std::max(0.0, timeout)); + std::lock_guard lock(mu_); + { + const std::string st = state(); + if (!proc_ || st == "failed" || st == "closed") return drain(); + } + std::string chunk; + bool eof = false; + if (readable) chunk = proc_->read(0.0, eof); + const double now = WireOs::now(); + if (eof) { + onEof(); + return drain(); + } + if (!chunk.empty()) { + lastBytesAt_ = now; + if (rawSink_) + rawSink_(CapEvent::make("cap.pty.raw_bytes", Json::object({{"bytes_b64", Json(WireHash::b64encode(chunk))}}))); + screen_->feed(chunk); + const std::string replies = screen_->takeReplies(); + if (!replies.empty()) { + try { + proc_->writeBytes(replies); + } catch (const detz::wire::OsError&) { + } + } + parseScreen(); + } + checkSettle(now); + return drain(); +} + +void CapPtyDriver::onEof() { + if (state() == "done") { + setState("closed"); + return; + } + if (state() != "failed") + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json("pty_died")}, + {"message", Json("agent process exited unexpectedly")}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + setState("failed"); +} + +void CapPtyDriver::parseScreen() { + std::vector rows = screen_->textLines(); + std::set prev(prevRows_.begin(), prevRows_.end()); + prevRows_ = rows; + std::vector freshRows; + for (const std::string& text : rows) + if (!text.empty() && !prev.count(text)) freshRows.push_back(text); + for (const std::string& text : freshRows) matchLine(text); + std::vector spoken; + for (const std::string& text : freshRows) + if (!isIdle(text)) spoken.push_back(text); + if (!spoken.empty()) + emitEvent(CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json(turnMsgId_)}, + {"text", Json(WireText::join(spoken, "\n"))}, + {"channel", Json("assistant")}}))); +} + +bool CapPtyDriver::isIdle(const std::string& text) const { + for (const WireRegex& item : idle_) + if (item.search(text)) return true; + return false; +} + +bool CapPtyDriver::fresh(const std::string& group, const std::string& text) { + return seenLineEvents_.insert({group, text}).second; +} + +void CapPtyDriver::matchLine(const std::string& text) { + for (const WireRegex& regex : errorRes_) { + if (regex.search(text)) { + const std::string lowered = WireText::lower(text); + std::string code; + if (WireText::contains(lowered, "rate_limit") || WireText::contains(lowered, "rate limit")) code = "rate_limit"; + else if (WireText::contains(lowered, "auth") || WireText::contains(lowered, "login")) code = "auth_error"; + else code = "error"; + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json(code)}, + {"message", Json(WireText::head(text, 500))}, + {"retryable", Json(code == "rate_limit")}, + {"details", Json::object()}}))); + return; + } + } + if (toolEnd_) { + auto match = toolEnd_->search(text); + if (match && fresh("tool_end", text)) { + Json groups = match.groupdict(); + Json status = groups.get("status", Json("")); + const bool failed = status == Json("failed"); + std::string callId; + if (openTool_) { + callId = openTool_->first; + openTool_.reset(); + } else { + ++callSeq_; + callId = "c" + std::to_string(callSeq_); + } + emitEvent(CapEvent::make("cap.tool_call.end", Json::object({{"call_id", Json(callId)}, + {"output", Json("")}, + {"is_error", Json(failed)}, + {"duration_ms", Json(0)}}))); + return; + } + } + if (toolStart_) { + auto match = toolStart_->search(text); + if (match && fresh("tool_start", text)) { + Json groups = match.groupdict(); + Json nameJson = groups.get("tool"); + const std::string name = nameJson.truthy() ? str(nameJson) : "tool"; + Json rawJson = groups.get("args"); + const std::string rawArgs = rawJson.truthy() ? str(rawJson) : ""; + Json args; + if (!rawArgs.empty()) { + try { + args = Json::parse(rawArgs); + } catch (const detz::wire::JsonError&) { + args = Json::object({{"raw", Json(rawArgs)}}); + } + } else { + args = Json::object(); + } + if (!args.isObject()) args = Json::object({{"raw", Json(rawArgs)}}); + ++callSeq_; + const std::string callId = "c" + std::to_string(callSeq_); + openTool_ = std::make_pair(callId, name); + emitEvent(CapEvent::make("cap.tool_call.start", Json::object({{"call_id", Json(callId)}, + {"name", Json(name)}, + {"input", args}}))); + return; + } + } + if (askYesNo_ && askYesNo_->search(text) && fresh("ask", text)) { + raiseAsk("yes_no", text, + Json::object({{"type", Json("string")}, + {"oneOf", Json::array({Json::object({{"const", Json("yes")}, {"title", Json("Yes")}}), + Json::object({{"const", Json("no")}, {"title", Json("No")}})})}})); + return; + } + if (askOptions_ && askOptions_->search(text) && fresh("ask", text)) { + raiseAsk("options", text, Json::object({{"type", Json("string")}})); + return; + } + if (planMark_ && planMark_->search(text) && fresh("plan", text)) { + ++planSeq_; + emitEvent(CapEvent::make( + "cap.plan", Json::object({{"entries", Json::array({Json::object({{"id", Json("p" + std::to_string(planSeq_))}, + {"content", Json(WireText::head(text, 500))}, + {"status", Json("in_progress")}})})}}))); + return; + } + if (thoughtMark_ && thoughtMark_->search(text) && fresh("thought", text)) { + emitEvent(CapEvent::make("cap.thought", Json::object({{"message_id", Json(turnMsgId_)}, + {"text", Json(WireText::head(text, 2000))}}))); + } +} + +void CapPtyDriver::raiseAsk(const std::string& kind, const std::string& prompt, const Json& form) { + ++askSeq_; + const std::string askId = "ask_" + std::to_string(askSeq_); + OpenAsk ask; + ask.kind = kind; + putAsk(askId, ask); + emitEvent(CapEvent::make("cap.ask_user", Json::object({{"ask_id", Json(askId)}, + {"prompt", Json(WireText::head(prompt, 2000))}, + {"form", form}, + {"timeout_seconds", Json()}}))); + setState("input_required"); +} + +void CapPtyDriver::checkSettle(double now) { + const double silentMs = (now - lastBytesAt_) * 1000.0; + std::vector rows = screen_->textLines(); + if (state() == "starting") { + if (readyRe_ && silentMs >= CAP_PTY_IDLE_MS) { + bool any = false; + for (const std::string& text : rows) + if (!text.empty() && readyRe_->search(text)) { + any = true; + break; + } + if (any) { + readyAt_ = now; + setState("ready"); + emitEvent(CapEvent::make("cap.session.ready", Json::object({{"version", Json(std::string(CAP_VERSION))}, + {"session_id", Json(urn_)}}))); + seenLineEvents_.clear(); + } + } + return; + } + if (state() != "working" || !promptSent_) return; + if (silentMs < CAP_PTY_SETTLE_MS) return; + std::vector rest; + for (const std::string& text : rows) + if (!text.empty()) rest.push_back(text); + if (rest.empty() || !isIdle(rest.back())) return; + if (!submitKey_.empty()) { + bool shown = false; + for (const std::string& text : rows) + if (WireText::contains(text, submitKey_)) { + shown = true; + break; + } + if (shown && resubmits_ < CAP_PTY_RESUBMITS_MAX) { + ++resubmits_; + lastBytesAt_ = now; + try { + proc_->write("\r"); + } catch (const detz::wire::OsError&) { + } + return; + } + } + setState("done"); +} + +std::string CapPtyDriver::lastText() { + std::lock_guard lock(mu_); + std::vector rest; + for (const std::string& text : screen_->textLines()) + if (!text.empty()) rest.push_back(text); + return WireText::join(rest, "\n"); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_pty_driver.h b/taicpp/src/cap/cap_pty_driver.h new file mode 100644 index 0000000..0b9b3d3 --- /dev/null +++ b/taicpp/src/cap/cap_pty_driver.h @@ -0,0 +1,75 @@ +#pragma once +// CapPtyDriver: orchestrator-side PTY binding (spec section 6.1, Appendix A): +// spawn the agent under a pty, render through the screen emulator, match the +// manifest [parse] patterns against rendered rows, and convict turn ends with +// the reference settle heuristic (byte silence plus prompt glyph). +#include +#include +#include +#include +#include +#include +#include + +#include "cap_driver.h" +#include "cap_screen.h" +#include "wire_proc.h" +#include "wire_regex.h" + +namespace detz::cap { + +class CapPtyDriver : public CapDriver { + public: + using ScreenFactory = std::function(int rows, int cols)>; + + CapPtyDriver(const CapManifest& manifest, std::string urn = "cap://agent-1", std::string cwd = "", + std::string model = "", std::map env = {}, + std::function rawSink = nullptr); + ~CapPtyDriver() override; + + // Replaces the screen emulator used by new drivers (default CapVtScreen). + static void setScreenFactory(ScreenFactory factory); + + void sendText(const std::string& text) override; + std::vector poll(double timeout = 0.0) override; + void cancel(const std::string& scope = "current_turn") override; + void close() override; + void resize(int rows, int cols); + std::string lastText(); + + protected: + void spawn() override; + bool hasProc() const override { return proc_ != nullptr; } + void answerOpen(const std::string& askId, const OpenAsk& ask, const Json& value) override; + + private: + static std::optional compileOpt(const Json& pattern); + void writeText(const std::string& text); + void terminate(); + void onEof(); + void parseScreen(); + bool isIdle(const std::string& text) const; + void matchLine(const std::string& text); + bool fresh(const std::string& group, const std::string& text); + void raiseAsk(const std::string& kind, const std::string& prompt, const Json& form); + void checkSettle(double now); + + std::function rawSink_; + std::unique_ptr screen_; + std::vector idle_; + std::optional toolStart_, toolEnd_, planMark_, thoughtMark_, askYesNo_, askOptions_; + std::vector errorRes_; + std::optional readyRe_; + std::vector prevRows_; + std::optional> openTool_; + bool promptSent_{false}; + std::string submitKey_; + int resubmits_{0}; + std::set> seenLineEvents_; + double lastBytesAt_{0.0}; + std::string turnMsgId_{"msg_boot"}; + std::optional readyAt_; + std::unique_ptr proc_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_py.cpp b/taicpp/src/cap/cap_py.cpp new file mode 100644 index 0000000..fdecbb5 --- /dev/null +++ b/taicpp/src/cap/cap_py.cpp @@ -0,0 +1,94 @@ +#include "cap_py.h" + +#include +#include + +namespace detz::cap { + +bool CapPy::toInt(const Json& value, long long& out) { + switch (value.type()) { + case Json::Type::Int: + case Json::Type::Bool: + out = value.asInt(); + return true; + case Json::Type::BigInt: + out = value.asInt(); + return true; + case Json::Type::Float: { + const double d = value.asDouble(); + if (std::isnan(d) || std::isinf(d)) return false; + out = static_cast(d); + return true; + } + case Json::Type::String: { + const std::string t = WireText::strip(value.str()); + size_t i = 0; + bool neg = false; + if (i < t.size() && (t[i] == '+' || t[i] == '-')) { + neg = t[i] == '-'; + ++i; + } + std::string digits; + bool prevUnderscore = true; + for (; i < t.size(); ++i) { + if (t[i] >= '0' && t[i] <= '9') { + digits += t[i]; + prevUnderscore = false; + } else if (t[i] == '_' && !prevUnderscore) { + prevUnderscore = true; + } else { + return false; + } + } + if (digits.empty() || prevUnderscore) return false; + long long v = std::strtoll(digits.c_str(), nullptr, 10); + out = neg ? -v : v; + return true; + } + default: + return false; + } +} + +bool CapPy::toFloat(const Json& value, double& out) { + switch (value.type()) { + case Json::Type::Int: + case Json::Type::Bool: + case Json::Type::BigInt: + case Json::Type::Float: + out = value.asDouble(); + return true; + case Json::Type::String: { + std::string t = WireText::strip(value.str()); + std::string clean; + for (char c : t) + if (c != '_') clean += c; + if (clean.empty()) return false; + char* end = nullptr; + const std::string lower = WireText::lower(clean); + if (lower == "inf" || lower == "+inf" || lower == "infinity" || lower == "+infinity") { + out = INFINITY; + return true; + } + if (lower == "-inf" || lower == "-infinity") { + out = -INFINITY; + return true; + } + if (lower == "nan" || lower == "+nan" || lower == "-nan") { + out = std::nan(""); + return true; + } + out = std::strtod(clean.c_str(), &end); + return end && *end == '\0'; + } + default: + return false; + } +} + +std::string CapPy::shortError(const std::string& text, const std::string& typeName) { + const std::string head = WireText::head(text, 300); + return head.empty() ? typeName : head; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_py.h b/taicpp/src/cap/cap_py.h new file mode 100644 index 0000000..209f28f --- /dev/null +++ b/taicpp/src/cap/cap_py.h @@ -0,0 +1,28 @@ +#pragma once +// CapPy: Python conversions the drivers rely on (int(), float(), str(), +// slicing). Failures mirror the ValueError/TypeError cases tai.py catches by +// returning false. +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +class CapPy { + public: + // int(value): ints and bools, floats (truncated), numeric strings. + static bool toInt(const Json& value, long long& out); + // float(value) + static bool toFloat(const Json& value, double& out); + // str(value) + static std::string str(const Json& value) { return value.isString() ? value.str() : value.pyStr(); } + // str(value or fallback) pattern: value if truthy else fallback (as str). + static std::string orStr(const Json& value, const std::string& fallback) { + return value.truthy() ? str(value) : fallback; + } + // short_error(exc): text[:300], or the exception class name when empty. + static std::string shortError(const std::string& text, const std::string& typeName = "Exception"); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_registry.cpp b/taicpp/src/cap/cap_registry.cpp new file mode 100644 index 0000000..85c1d4e --- /dev/null +++ b/taicpp/src/cap/cap_registry.cpp @@ -0,0 +1,85 @@ +#include "cap_registry.h" + +#include "cap_constants.h" +#include "cap_error.h" +#include "wire_os.h" +#include "wire_proc.h" + +namespace detz::cap { + +using detz::wire::WireOs; +using detz::wire::WireProc; + +CapRegistry::CapRegistry(const std::string& userDir, const std::string& systemDir) + : userDir_(userDir.empty() ? WireText::pathJoin(WireText::pathJoin(WireText::pathJoin(WireOs::home(), ".config"), "cap"), "agents") + : userDir), + systemDir_(systemDir.empty() ? std::string(SYSTEM_DIR) : systemDir) {} + +CapManifest CapRegistry::resolve(const std::string& name, const std::string& binary) const { + std::vector tried; + const std::string command = binary.empty() ? name : binary; + const std::string sibling = siblingManifest(command); + if (!sibling.empty()) return loadFile(sibling, name); + for (const std::string& folder : {userDir_, systemDir_}) { + const std::string path = WireText::pathJoin(folder, name + ".toml"); + tried.push_back(path); + if (WireOs::isFile(path)) return loadFile(path, name); + } + bool ok = false; + std::vector argv = probeArgv(command, ok); + if (ok) { + tried.push_back(WireText::join(argv, " ")); + return loadProbe(argv, name); + } + throw CapError("cap_manifest_missing", + "no manifest for " + WireText::repr(name) + " (looked in " + + (tried.empty() ? std::string("nothing") : WireText::join(tried, ", ")) + ")"); +} + +CapManifest CapRegistry::loadFile(const std::string& path, const std::string& name) const { + CapManifest manifest = CapManifest::fromToml(WireOs::readFile(path), path); + if (manifest.name() != name) + throw ValueError("CAP manifest " + path + " names " + WireText::repr(manifest.name()) + ", expected " + + WireText::repr(name)); + return manifest; +} + +CapManifest CapRegistry::loadProbe(const std::vector& argv, const std::string& name) const { + const std::string joined = WireText::join(argv, " "); + WireProc::Completed done; + try { + WireProc::Options options; + options.argv = argv; + options.in = WireProc::Stream::Inherit; + done = WireProc::run(options, CAP_PROBE_TIMEOUT); + } catch (const detz::wire::OsError& exc) { + throw CapError("cap_manifest_missing", "probe " + joined + " failed: " + exc.what()); + } catch (const detz::wire::SubprocessError& exc) { + throw CapError("cap_manifest_missing", "probe " + joined + " failed: " + exc.what()); + } + if (done.returncode != 0) + throw CapError("cap_manifest_missing", "probe " + joined + " exited " + std::to_string(done.returncode)); + std::string text, message; + if (!WireText::decodeStrict(done.out, text, message)) + throw CapError("cap_manifest_missing", "probe " + joined + " is not UTF-8: " + message); + CapManifest manifest = CapManifest::fromToml(text, joined); + if (manifest.name() != name) + throw ValueError("CAP probe " + joined + " names " + WireText::repr(manifest.name()) + ", expected " + + WireText::repr(name)); + return manifest; +} + +std::string CapRegistry::siblingManifest(const std::string& command) { + const std::string resolved = WireOs::which(command); + if (resolved.empty()) return ""; + const std::string sibling = WireText::pathJoin(WireOs::dirnameAbs(resolved), "cap-agent.toml"); + return WireOs::isFile(sibling) ? sibling : ""; +} + +std::vector CapRegistry::probeArgv(const std::string& command, bool& ok) { + ok = !WireOs::which(command).empty(); + if (!ok) return {}; + return {command, "--cap-manifest"}; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_registry.h b/taicpp/src/cap/cap_registry.h new file mode 100644 index 0000000..61776fa --- /dev/null +++ b/taicpp/src/cap/cap_registry.h @@ -0,0 +1,37 @@ +#pragma once +// CapRegistry: manifest resolution in spec order: binary sibling, user-local, +// system-wide, probe. The first source that EXISTS wins; an existing source +// that fails to parse throws instead of falling through. +#include +#include +#include + +#include "cap_manifest.h" + +namespace detz::cap { + +class CapRegistry { + public: + static constexpr const char* SYSTEM_DIR = "/usr/share/cap-agents"; + + // userDir/systemDir empty -> ~/.config/cap/agents and SYSTEM_DIR. + explicit CapRegistry(const std::string& userDir = "", const std::string& systemDir = ""); + + // `binary` empty means "use name". Throws CapError (cap_manifest_missing) + // and ValueError. + CapManifest resolve(const std::string& name, const std::string& binary = "") const; + + const std::string& userDir() const { return userDir_; } + const std::string& systemDir() const { return systemDir_; } + + private: + CapManifest loadFile(const std::string& path, const std::string& name) const; + CapManifest loadProbe(const std::vector& argv, const std::string& name) const; + static std::string siblingManifest(const std::string& command); + static std::vector probeArgv(const std::string& command, bool& ok); + + std::string userDir_; + std::string systemDir_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_screen.h b/taicpp/src/cap/cap_screen.h new file mode 100644 index 0000000..e2dd9b3 --- /dev/null +++ b/taicpp/src/cap/cap_screen.h @@ -0,0 +1,26 @@ +#pragma once +// CapScreen: the slice of tai.py's TuiVtScreen that CapPtyDriver uses. The +// PTY driver renders agent output through it and matches the manifest +// [parse] patterns against `textLines()`. The default implementation is +// CapVtScreen (a port of TuiVtScreen); the integrator may bind the TUI +// module's own screen through CapPtyDriver::setScreenFactory. +#include +#include + +namespace detz::cap { + +class CapScreen { + public: + virtual ~CapScreen() = default; + virtual int rows() const = 0; + virtual int cols() const = 0; + // Feeds raw pty bytes (UTF-8, possibly split mid-sequence). + virtual void feed(const std::string& bytes) = 0; + // Bytes the emulator wants written back to the child (cursor reports...). + virtual std::string takeReplies() = 0; + // Rendered rows as plain strings. + virtual std::vector textLines() const = 0; + virtual void resize(int rows, int cols) = 0; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_session.cpp b/taicpp/src/cap/cap_session.cpp new file mode 100644 index 0000000..8cf296a --- /dev/null +++ b/taicpp/src/cap/cap_session.cpp @@ -0,0 +1,167 @@ +#include "cap_session.h" + +#include +#include + +#include "cap_event.h" +#include "cap_interrupt.h" +#include "cap_py.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::WireOs; + +void CapEventQueue::put(const Json& event) { + { + std::lock_guard lock(mutex_); + items_.push_back(event); + } + cv_.notify_one(); +} + +bool CapEventQueue::get(Json& out, double timeout) { + std::unique_lock lock(mutex_); + if (!cv_.wait_for(lock, std::chrono::duration(timeout), [&] { return !items_.empty(); })) return false; + out = items_.front(); + items_.pop_front(); + return true; +} + +bool CapEventQueue::tryGet(Json& out) { + std::lock_guard lock(mutex_); + if (items_.empty()) return false; + out = items_.front(); + items_.pop_front(); + return true; +} + +bool CapEventQueue::empty() const { + std::lock_guard lock(mutex_); + return items_.empty(); +} + +void CapEventQueue::clear() { + std::lock_guard lock(mutex_); + items_.clear(); +} + +CapSession::CapSession(std::string urn, std::unique_ptr driver) + : urn_(std::move(urn)), driver_(std::move(driver)) {} + +CapSession::~CapSession() { + stop_ = true; + if (thread_.joinable()) thread_.join(); +} + +void CapSession::addTap(Tap tap) { taps_.push_back(std::move(tap)); } + +CapUsage CapSession::usage() const { + std::lock_guard lock(usageMutex_); + return usage_; +} + +CapSession& CapSession::start(double timeout) { + for (const Json& event : driver_->start(timeout)) note(event); + events_.clear(); + thread_ = std::thread([this] { pumpLoop(); }); + return *this; +} + +CapTurnResult CapSession::ask(const std::string& text, double timeout) { + const CapUsage baseline = usage(); + driver_->sendText(text); + return waitDone(timeout, &baseline); +} + +CapTurnResult CapSession::waitDone(double timeout, const CapUsage* baseline) { + std::vector events; + const double deadline = WireOs::now() + std::max(1.0, timeout); + while (true) { + Json event; + if (events_.get(event, CAP_SESSION_POLL)) events.push_back(event); + const std::string state = driver_->state(); + if ((state == "done" || state == "failed" || state == "closed" || state == "input_required") && events_.empty()) { + for (Json& extra : graceDrain()) events.push_back(std::move(extra)); + return CapTurnResult(events, state, delta(baseline)); + } + if (stop_.load()) return CapTurnResult(events, state, delta(baseline)); + CapInterrupt::check(); + if (WireOs::now() > deadline) + throw CapTimeoutError("turn timed out after " + std::to_string(static_cast(timeout)) + "s"); + } +} + +std::vector CapSession::graceDrain() { + std::vector extra; + const double deadline = WireOs::now() + 0.2; + while (WireOs::now() < deadline) { + Json event; + if (events_.get(event, 0.05)) extra.push_back(event); + } + return extra; +} + +CapUsage CapSession::delta(const CapUsage* baseline) const { + const CapUsage now = usage(); + CapUsage base; + if (baseline) base = *baseline; + CapUsage out; + out.inputTokens = now.inputTokens - base.inputTokens; + out.outputTokens = now.outputTokens - base.outputTokens; + out.costUsdEstimate = now.costUsdEstimate - base.costUsdEstimate; + return out; +} + +void CapSession::answer(const std::string& askId, const Json& value) { driver_->answer(askId, value); } + +void CapSession::cancel(const std::string& scope) { driver_->cancel(scope); } + +void CapSession::stopPump() { + stop_ = true; + if (thread_.joinable()) thread_.join(); +} + +void CapSession::close() { + stop_ = true; + if (thread_.joinable()) thread_.join(); + driver_->close(); +} + +void CapSession::note(const Json& event) { + events_.put(event); + for (const Tap& tap : taps_) tap(urn_, event); + if (event.get("kind") == Json("cap.usage")) { + std::lock_guard lock(usageMutex_); + long long in = 0, out = 0; + Json inRaw = event.get("input_tokens", Json(0)); + Json outRaw = event.get("output_tokens", Json(0)); + if (inRaw.truthy()) CapPy::toInt(inRaw, in); + if (outRaw.truthy()) CapPy::toInt(outRaw, out); + usage_.inputTokens += in; + usage_.outputTokens += out; + Json cost = event.get("cost_usd_estimate", Json(0.0)); + double c = 0; + if (cost.truthy() && CapPy::toFloat(cost, c)) usage_.costUsdEstimate += c; + } +} + +void CapSession::pumpLoop() { + while (!stop_.load()) { + std::vector events; + try { + events = driver_->poll(CAP_SESSION_POLL); + } catch (const std::exception& exc) { + note(CapEvent::make("cap.error", Json::object({{"code", Json("error")}, + {"message", Json("driver pump failed: " + CapPy::shortError(exc.what()))}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + return; + } + for (const Json& event : events) note(event); + const std::string state = driver_->state(); + if (state == "failed" || state == "closed") return; + } +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_session.h b/taicpp/src/cap/cap_session.h new file mode 100644 index 0000000..a7fd275 --- /dev/null +++ b/taicpp/src/cap/cap_session.h @@ -0,0 +1,82 @@ +#pragma once +// CapSession: one orchestrated agent. The driver pumps on its own thread, all +// events land on a queue, ask() blocks one turn with a timeout. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cap_answerer.h" +#include "cap_driver.h" +#include "cap_turn_result.h" + +namespace detz::cap { + +// Python TimeoutError raised by blocking turns. +class CapTimeoutError : public std::runtime_error { + public: + explicit CapTimeoutError(const std::string& text) : std::runtime_error(text) {} +}; + +// queue.Queue of events with a timed get. +class CapEventQueue { + public: + void put(const Json& event); + bool get(Json& out, double timeout); // false on timeout + bool tryGet(Json& out); + bool empty() const; + void clear(); + + private: + mutable std::mutex mutex_; + std::condition_variable cv_; + std::deque items_; +}; + +class CapSession : public CapAnswerTarget { + public: + using Tap = std::function; + + CapSession(std::string urn, std::unique_ptr driver); + ~CapSession() override; + + std::string urn() const override { return urn_; } + CapDriver& driver() { return *driver_; } + const CapDriver& driver() const { return *driver_; } + void addTap(Tap tap); + CapUsage usage() const; + + // Starts the driver (spawn + ready) and the pump thread; returns itself. + CapSession& start(double timeout = 0); + CapTurnResult ask(const std::string& text, double timeout = CAP_TURN_TIMEOUT); + CapTurnResult waitDone(double timeout = CAP_TURN_TIMEOUT, const CapUsage* baseline = nullptr); + void answer(const std::string& askId, const Json& value) override; + void cancel(const std::string& scope = "current_turn"); + // Stops the pump thread without closing the driver. + void stopPump(); + void close(); + + private: + std::vector graceDrain(); + CapUsage delta(const CapUsage* baseline) const; + void note(const Json& event); + void pumpLoop(); + + std::string urn_; + std::unique_ptr driver_; + CapEventQueue events_; + std::vector taps_; + mutable std::mutex usageMutex_; + CapUsage usage_; + std::atomic stop_{false}; + std::thread thread_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_session_config.cpp b/taicpp/src/cap/cap_session_config.cpp new file mode 100644 index 0000000..28018f7 --- /dev/null +++ b/taicpp/src/cap/cap_session_config.cpp @@ -0,0 +1,126 @@ +#include "cap_session_config.h" + +#include + +#include +#include +#include + +#include "cap_constants.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::WireOs; + +const std::vector& CapSessionConfig::MODES() { + static const std::vector modes = {"none", "confirm", "interactive"}; + return modes; +} + +CapSessionConfig::CapSessionConfig(const Json& data) : data_(data.isObject() ? data : Json::object()) {} + +CapSessionConfig CapSessionConfig::fromEnv() { + const std::string name(CAP_SESSION_CONFIG_ENV); + const std::string raw = WireOs::getenv(name, ""); + if (WireText::strip(raw).empty()) return CapSessionConfig(Json::object()); + Json data; + try { + data = Json::parse(raw); + } catch (const detz::wire::JsonError& exc) { + throw ValueError(name + " is not JSON: " + exc.what()); + } + if (!data.isObject()) throw ValueError(name + " must be a JSON object"); + return CapSessionConfig(data); +} + +CapSessionConfig CapSessionConfig::fromArgs(const CapSpeakArgs& args) { + CapSessionConfig config = fromEnv(); + if (args.capCwd) config.data_.set("cwd", Json(*args.capCwd)); + if (args.capModel) config.data_.set("model", Json(*args.capModel)); + if (args.capPermissionMode) config.data_.set("permission_mode", Json(*args.capPermissionMode)); + if (args.capMaxTurns) config.data_.set("max_turns", Json(*args.capMaxTurns)); + return config; +} + +Json CapSessionConfig::get(const std::string& key, const Json& fallback) const { return data_.get(key, fallback); } + +std::string CapSessionConfig::cwd() const { + Json v = data_.get("cwd"); + if (!v.truthy()) return ""; + return v.isString() ? v.str() : v.pyStr(); +} + +std::string CapSessionConfig::permissionMode() const { + Json mode = data_.get("permission_mode"); + if (!mode.truthy()) return "interactive"; + if (mode.isString()) + for (const std::string& allowed : MODES()) + if (mode.str() == allowed) return mode.str(); + return "interactive"; +} + +long long CapSessionConfig::maxTurns() const { + Json v = data_.get("max_turns"); + long long value = 0; + if (v.truthy()) { + if (v.isInt() || v.isBool()) { + value = v.asInt(); + } else if (v.isFloat()) { + const double d = v.asDouble(); + if (std::isnan(d) || std::isinf(d)) return CAP_SPEAK_MAX_TURNS; + value = static_cast(d); + } else if (v.isString()) { + // int(str): optional sign, digits with single underscores, surrounding space + std::string t = WireText::strip(v.str()); + std::string digits; + bool neg = false; + size_t i = 0; + if (!t.empty() && (t[0] == '+' || t[0] == '-')) { + neg = t[0] == '-'; + i = 1; + } + bool ok = i < t.size(); + bool prevUnderscore = true; // no leading underscore + for (; i < t.size() && ok; ++i) { + if (t[i] >= '0' && t[i] <= '9') { + digits += t[i]; + prevUnderscore = false; + } else if (t[i] == '_' && !prevUnderscore) { + prevUnderscore = true; + } else { + ok = false; + } + } + if (!ok || prevUnderscore || digits.empty()) return CAP_SPEAK_MAX_TURNS; + value = std::strtoll(digits.c_str(), nullptr, 10); + if (neg) value = -value; + } else { + return CAP_SPEAK_MAX_TURNS; + } + } + return value > 0 ? value : CAP_SPEAK_MAX_TURNS; +} + +void CapSessionConfig::applyPreBoot() const { + const std::string dir = cwd(); + if (!dir.empty() && WireOs::abspath(dir) != WireOs::getcwd()) { + if (::chdir(dir.c_str()) != 0) throw WireOs::makeError(errno, dir); + } +} + +std::vector CapSessionConfig::apply(CapAgentPort& agent) const { + agent.setQuiet(true); + std::vector notes; + Json prompt = data_.get("system_prompt"); + if (prompt.truthy()) agent.prependSystemMessage(prompt.isString() ? prompt.str() : prompt.pyStr()); + if (data_.get("model").truthy()) + notes.push_back("model " + data_.get("model").pyRepr() + " noted, tai picks its own backend"); + if (!data_.get("budget_usd").isNull()) + notes.push_back("budget_usd " + data_.get("budget_usd").pyStr() + " noted, tai backends are unmetered"); + if (data_.get("profile_config").truthy()) + notes.push_back("profile_config noted, tai has no tool allow-list to enforce it with"); + return notes; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_session_config.h b/taicpp/src/cap/cap_session_config.h new file mode 100644 index 0000000..4e82a89 --- /dev/null +++ b/taicpp/src/cap/cap_session_config.h @@ -0,0 +1,38 @@ +#pragma once +// CapSessionConfig: the cap.session.config the orchestrator passes the agent +// side. Bulk path is CAP_SESSION_CONFIG (JSON, set by the driver); --cap-* +// flags override single fields. Applied in two steps: applyPreBoot() before +// boot (working directory), apply() after. +#include +#include + +#include "cap_agent_port.h" + +namespace detz::cap { + +class CapSessionConfig { + public: + static const std::vector& MODES(); + + explicit CapSessionConfig(const Json& data = Json::object()); + // Throws ValueError ("CAP_SESSION_CONFIG is not JSON: ..." / "... must be a JSON object"). + static CapSessionConfig fromEnv(); + static CapSessionConfig fromArgs(const CapSpeakArgs& args); + + Json get(const std::string& key, const Json& fallback = Json()) const; + Json& data() { return data_; } + const Json& data() const { return data_; } + std::string cwd() const; + std::string permissionMode() const; + long long maxTurns() const; + + // os.chdir(cwd) when it differs from the current directory; throws OsError. + void applyPreBoot() const; + // Wires the config into a booted agent; returns the stderr notes. + std::vector apply(CapAgentPort& agent) const; + + private: + Json data_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_speak_main.cpp b/taicpp/src/cap/cap_speak_main.cpp new file mode 100644 index 0000000..9f8b0fe --- /dev/null +++ b/taicpp/src/cap/cap_speak_main.cpp @@ -0,0 +1,95 @@ +#include "cap_speak_main.h" + +#include + +#include +#include + +#include "cap_session_config.h" +#include "cap_speak_session.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::WireOs; + +namespace { + +void err(const std::string& text) { + if (::write(2, text.data(), text.size()) < 0) { + } +} + +// paint() while stdout is parked: colors only when stderr is a tty. +std::string paintYellow(const std::string& text) { + if (WireOs::hasEnv("NO_COLOR") && !WireOs::getenv("NO_COLOR").empty()) return text; + if (!::isatty(2)) return text; + return "\033[33m" + text + "\033[0m"; +} + +// RAII: fd 1 -> stderr, keeping the original stdout as `wireFd`. +class StdoutParker { + public: + StdoutParker() : wireFd_(::dup(1)) { ::dup2(2, 1); } + ~StdoutParker() { + if (wireFd_ >= 0) { + ::dup2(wireFd_, 1); + ::close(wireFd_); + } + } + int wireFd() const { return wireFd_; } + + private: + int wireFd_; +}; + +} // namespace + +int CapSpeakMain::capSpeakMain(CapSpeakHost& host, CapSpeakArgs args) { + CapSessionConfig config; + try { + config = CapSessionConfig::fromArgs(args); + } catch (const ValueError& exc) { + err(std::string("cap-speak: ") + exc.what() + "\n"); + return 2; + } + try { + config.applyPreBoot(); + } catch (const detz::wire::OsError& exc) { + std::string text = WireText::head(exc.what(), 300); + err("cap-speak: cannot use cwd " + WireText::repr(config.cwd()) + ": " + text + "\n"); + return 2; + } + args.autoFlag = args.autoFlag || config.permissionMode() != "none"; + args.yoloFlag = args.yoloFlag || config.permissionMode() == "none"; + StdoutParker parker; + std::unique_ptr agent; + try { + agent = host.boot(args); + } catch (const CapInterrupted&) { + err(paintYellow("\ninterrupted") + "\n"); + return 130; + } catch (const CapSealError& exc) { + err(std::string("seal error: ") + exc.what() + "\n"); + return 2; + } + for (const std::string& note : config.apply(*agent)) err("cap-speak: " + note + "\n"); + CapSpeakSession session(*agent, config, 0, parker.wireFd()); + int code; + try { + code = session.run(); + } catch (...) { + try { + agent->closeStore(); + } catch (...) { + } + throw; + } + try { + agent->closeStore(); + } catch (...) { + } + return code; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_speak_main.h b/taicpp/src/cap/cap_speak_main.h new file mode 100644 index 0000000..d7fd21c --- /dev/null +++ b/taicpp/src/cap/cap_speak_main.h @@ -0,0 +1,15 @@ +#pragma once +// CapSpeakMain: cap_speak_main, the --cap-speak entry. Stdout is parked on +// stderr for the whole run so the agent core can print freely; the wire gets +// a private duplicate of the original stdout. +#include "cap_agent_port.h" + +namespace detz::cap { + +class CapSpeakMain { + public: + // cap_speak_main(args): returns the process exit code. + static int capSpeakMain(CapSpeakHost& host, CapSpeakArgs args); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_speak_session.cpp b/taicpp/src/cap/cap_speak_session.cpp new file mode 100644 index 0000000..14e2ab3 --- /dev/null +++ b/taicpp/src/cap/cap_speak_session.cpp @@ -0,0 +1,286 @@ +#include "cap_speak_session.h" + +#include +#include +#include + +#include +#include + +#include "cap_constants.h" +#include "cap_interrupt.h" +#include "wire_difflib.h" +#include "wire_hash.h" +#include "wire_os.h" +#include "wire_proc.h" + +namespace detz::cap { + +using detz::wire::WireDifflib; +using detz::wire::WireHash; +using detz::wire::WireOs; +using detz::wire::WireProc; + +namespace { +std::string shortError(const std::exception& exc) { + std::string text = WireText::head(exc.what(), 300); + return text.empty() ? "Exception" : text; +} +} // namespace + +CapSpeakSession::CapSpeakSession(CapAgentPort& agent, CapSessionConfig config, int stdinFd, int stdoutFd) + : agent_(agent), config_(std::move(config)), stdinFd_(stdinFd), stdoutFd_(stdoutFd) { + sessionId_ = "cap_" + WireHash::uuid4Hex().substr(0, 12); + Json model = config_.get("model"); + model_ = model.truthy() ? (model.isString() ? model.str() : model.pyStr()) : "tai"; +} + +void CapSpeakSession::capInterrupt(int signum) { CapInterrupt::capInterrupt(signum); } +bool CapSpeakSession::interruptRequested() { return CapInterrupt::requested(); } +void CapSpeakSession::clearInterrupt() { CapInterrupt::clear(); } +void CapSpeakSession::checkInterrupt() { CapInterrupt::check(); } + +void CapSpeakSession::emit(const Json& frame) { + frames_.push_back(frame); + const std::string line = frame.dumps() + "\n"; + size_t off = 0; + while (off < line.size()) { + ssize_t n = ::write(stdoutFd_, line.data() + off, line.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + throw WireOs::makeError(errno); + } + off += static_cast(n); + } +} + +bool CapSpeakSession::readLine(std::string& line) { + while (true) { + const size_t nl = inbuf_.find('\n'); + if (nl != std::string::npos) { + line = inbuf_.substr(0, nl + 1); + inbuf_.erase(0, nl + 1); + return true; + } + if (eof_) { + line.swap(inbuf_); + inbuf_.clear(); + return !line.empty(); + } + checkInterrupt(); + struct pollfd p; + p.fd = stdinFd_; + p.events = POLLIN; + p.revents = 0; + const int r = ::poll(&p, 1, 100); + if (r < 0) { + if (errno == EINTR) continue; + throw WireOs::makeError(errno); + } + if (r == 0) continue; + char buf[65536]; + ssize_t n = ::read(stdinFd_, buf, sizeof buf); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN) continue; + throw WireOs::makeError(errno); + } + if (n == 0) eof_ = true; + else inbuf_.append(buf, static_cast(n)); + } +} + +int CapSpeakSession::run() { + struct sigaction act {}, oldTerm {}, oldInt {}; + act.sa_handler = &CapSpeakSession::capInterrupt; + sigemptyset(&act.sa_mask); + act.sa_flags = 0; + ::sigaction(SIGTERM, &act, &oldTerm); + ::sigaction(SIGINT, &act, &oldInt); + int code = 0; + try { + code = runLoop(); + } catch (const CapInterrupted&) { + emit(CapStreamJson::resultError(sessionId_, model_, "cancelled")); + code = 130; + } catch (...) { + ::sigaction(SIGTERM, &oldTerm, nullptr); + ::sigaction(SIGINT, &oldInt, nullptr); + throw; + } + ::sigaction(SIGTERM, &oldTerm, nullptr); + ::sigaction(SIGINT, &oldInt, nullptr); + return code; +} + +int CapSpeakSession::runLoop() { + emit(CapStreamJson::systemInit(sessionId_, model_)); + if (config_.get("session_resume_id").truthy()) runResume(); + while (true) { + std::string line; + if (!readLine(line)) break; + if (WireText::strip(line).empty()) continue; + std::string text; + try { + text = CapStreamJson::parseUserText(line); + } catch (const ValueError& exc) { + emit(CapStreamJson::resultError(sessionId_, model_, std::string("invalid frame: ") + exc.what())); + continue; + } + if (turns_ >= config_.maxTurns()) { + emit(CapStreamJson::resultError(sessionId_, model_, + "max turns (" + std::to_string(config_.maxTurns()) + ") reached")); + break; + } + runOne(text); + ++turns_; + } + finish(); + return 0; +} + +void CapSpeakSession::runResume() { + const size_t before = agent_.messages().size(); + agent_.resumeTurn(); + if (agent_.messages().size() == before) return; + emitTranscriptDiff(before, 0, "", 0.0); +} + +void CapSpeakSession::runOne(const std::string& userText) { + const size_t before = agent_.messages().size(); + const long long tokensBefore = agent_.conversationTokens(); + const double started = WireOs::now(); + std::string finalText; + try { + finalText = agent_.runTurn(userText); + } catch (const CapInterrupted&) { + throw; + } catch (const std::exception& exc) { + emit(CapStreamJson::resultError(sessionId_, model_, "turn failed: " + shortError(exc))); + return; + } + const double total = WireOs::now() - started; + emitTranscriptDiff(before, agent_.conversationTokens() - tokensBefore, finalText, total); +} + +void CapSpeakSession::emitTranscriptDiff(size_t before, long long tokensNew, const std::string& finalText, + double duration) { + std::vector pending; + auto flush = [&]() { + if (!pending.empty()) { + emit(CapStreamJson::toolResults(pending)); + pending.clear(); + } + }; + const std::vector& messages = agent_.messages(); + for (size_t k = before; k < messages.size(); ++k) { + const Json& message = messages[k]; + const Json role = message.get("role"); + if (role == Json("assistant")) { + flush(); + ++msgSeq_; + Json content = message.get("content"); + Json calls = message.get("tool_calls"); + Json frame = CapStreamJson::assistant("msg_" + std::to_string(msgSeq_), + content.truthy() ? (content.isString() ? content.str() : content.pyStr()) : "", + calls.truthy() ? calls : Json::array()); + if (!frame.isNull()) emit(frame); + } else if (role == Json("tool")) { + Json content = message.get("content"); + const std::string text = content.truthy() ? (content.isString() ? content.str() : content.pyStr()) : ""; + const bool failed = text == "denied by user" || WireText::startsWith(text, "error:") || + WireText::startsWith(text, "[error]") || WireText::startsWith(text, "[missing]"); + Json id = message.get("tool_call_id"); + pending.emplace_back(id.truthy() ? (id.isString() ? id.str() : id.pyStr()) : "", text, failed); + } + } + flush(); + Json usage = Json::object({{"total_tokens", Json(std::max(tokensNew, 0))}, + {"duration_ms", Json(static_cast(duration * 1000))}, + {"tool_calls", Json(agent_.turnTools())}, + {"model", Json(model_)}}); + if (!config_.get("budget_usd").isNull()) usage.set("budget_usd", config_.get("budget_usd")); + if (WireText::startsWith(finalText, "backend error:")) + emit(CapStreamJson::resultError(sessionId_, model_, finalText)); + else + emit(CapStreamJson::resultOk(sessionId_, model_, finalText, static_cast(duration * 1000), usage)); + Json artifact = diffArtifact(); + if (!artifact.isNull()) emit(CapStreamJson::artifactFrame(artifact)); +} + +Json CapSpeakSession::diffArtifact() { + const std::string diff = currentDiff(); + if (diff.empty() || diff == lastDiff_) return Json(); + lastDiff_ = diff; + ++artSeq_; + const std::string content = diff.substr(0, CAP_SPEAK_DIFF_MAX); + return Json::object({{"artifact_id", Json("art_" + std::to_string(artSeq_))}, + {"mime_type", Json("application/vnd.cap.coding.diff.v1+text")}, + {"name", Json("turn-" + std::to_string(turns_ + 1) + ".diff")}, + {"size", Json(static_cast(diff.size()))}, + {"content", Json(WireText::decodeReplace(content))}, + {"truncated", Json(diff.size() > static_cast(CAP_SPEAK_DIFF_MAX))}}); +} + +std::string CapSpeakSession::currentDiff() { + bool inside = false; + try { + WireProc::Options options; + options.argv = {"git", "rev-parse", "--is-inside-work-tree"}; + options.in = WireProc::Stream::Inherit; + WireProc::Completed done = WireProc::run(options, 10); + inside = done.returncode == 0 && WireText::strip(done.out) == "true"; + } catch (const detz::wire::OsError&) { + } catch (const detz::wire::SubprocessError&) { + } + if (inside) { + try { + WireProc::Options options; + options.argv = {"git", "diff", "HEAD", "--"}; + WireProc::Completed done = WireProc::run(options, 30); + return done.returncode == 0 ? done.out : std::string(); + } catch (const detz::wire::OsError&) { + return ""; + } catch (const detz::wire::SubprocessError&) { + return ""; + } + } + Json preview = agent_.diffPreview(); + if (!preview.truthy()) return ""; + auto textOf = [&](const char* key) { + Json v = preview.get(key); + return v.truthy() ? (v.isString() ? v.str() : v.pyStr()) : std::string(); + }; + std::string path = textOf("path"); + if (path.empty()) path = "file"; + return WireDifflib::unifiedDiff(WireText::splitLines(textOf("old"), true), WireText::splitLines(textOf("new"), true), + path, path); +} + +void CapSpeakSession::finish() { + long long size = 0; + std::string path; + try { + const std::string folder = WireText::pathJoin(agent_.home(), "cap-transcripts"); + WireOs::makedirs(folder); + path = WireText::pathJoin(folder, sessionId_ + ".json"); + Json doc = Json::object({{"session_id", Json(sessionId_)}, {"agent", Json("tai")}, {"model", Json(model_)}}); + Json events = Json::array(); + for (const Json& frame : frames_) events.push(frame); + doc.set("events", events); + WireOs::writeFile(path, doc.dumps()); + size = WireOs::fileSize(path); + } catch (const std::exception& exc) { + const std::string msg = "cap-speak: transcript not saved: " + shortError(exc) + "\n"; + if (::write(2, msg.data(), msg.size()) < 0) { + } + return; + } + ++artSeq_; + emit(CapStreamJson::artifactFrame(Json::object({{"artifact_id", Json("art_" + std::to_string(artSeq_))}, + {"mime_type", Json("application/vnd.cap.coding.transcript.v1+json")}, + {"name", Json(sessionId_ + ".transcript.json")}, + {"size", Json(size)}, + {"uri", Json("file://" + path)}}))); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_speak_session.h b/taicpp/src/cap/cap_speak_session.h new file mode 100644 index 0000000..8334b57 --- /dev/null +++ b/taicpp/src/cap/cap_speak_session.h @@ -0,0 +1,61 @@ +#pragma once +// CapSpeakSession: one --cap-speak agent session. stdin NDJSON user frames in, +// NDJSON assistant/tool/result frames out. Turns run through the agent port; +// the emitted frames are also the session transcript, saved at the end. +#include +#include +#include + +#include "cap_agent_port.h" +#include "cap_session_config.h" +#include "cap_stream_json.h" + +namespace detz::cap { + +class CapSpeakSession { + public: + // stdinFd/stdoutFd are the wire. The caller parks the process stdout on + // stderr so agent prints never reach the wire (see CapSpeakMain). + CapSpeakSession(CapAgentPort& agent, CapSessionConfig config, int stdinFd = 0, int stdoutFd = 1); + + // cap_interrupt: SIGTERM/SIGINT handler. Sets the flag the readline loop and + // the host's turn loop poll; checkInterrupt() turns it into CapInterrupted. + static void capInterrupt(int signum); + static bool interruptRequested(); + static void clearInterrupt(); + static void checkInterrupt(); + + // Runs until stdin closes. Returns the exit code (0, or 130 when cancelled). + int run(); + + const std::string& sessionId() const { return sessionId_; } + const std::vector& frames() const { return frames_; } + int turns() const { return turns_; } + + private: + void emit(const Json& frame); + int runLoop(); + void runResume(); + void runOne(const std::string& userText); + void emitTranscriptDiff(size_t before, long long tokensNew, const std::string& finalText, double duration); + Json diffArtifact(); + std::string currentDiff(); + void finish(); + bool readLine(std::string& line); + + CapAgentPort& agent_; + CapSessionConfig config_; + int stdinFd_; + int stdoutFd_; + std::string sessionId_; + std::string model_; + std::vector frames_; + int msgSeq_{0}; + int artSeq_{0}; + std::string lastDiff_; + int turns_{0}; + std::string inbuf_; + bool eof_{false}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_stream_driver.cpp b/taicpp/src/cap/cap_stream_driver.cpp new file mode 100644 index 0000000..1faa87c --- /dev/null +++ b/taicpp/src/cap/cap_stream_driver.cpp @@ -0,0 +1,301 @@ +#include "cap_stream_driver.h" + +#include "cap_constants.h" +#include "cap_event.h" +#include "cap_py.h" +#include "wire_os.h" + +namespace detz::cap { + +using detz::wire::WireProc; + +CapStreamDriver::CapStreamDriver(const CapManifest& manifest, std::string urn, std::string cwd, std::string model, + std::map env) + : CapDriver(manifest, std::move(urn), std::move(cwd), std::move(model), std::move(env)) {} + +CapStreamDriver::~CapStreamDriver() { + if (drain_) drain_->stop(); +} + +int CapStreamDriver::ignoredFrames() const { + std::lock_guard lock(mu_); + return ignoredFrames_; +} + +std::vector CapStreamDriver::stderrLines() const { + std::lock_guard lock(mu_); + return drain_ ? drain_->lines() : std::vector(); +} + +void CapStreamDriver::spawn() { + Json argvJson = manifest_.get("fast_path.stream_json"); + if (!argvJson.truthy()) throw CapError("cap_binding_unavailable", "manifest has no fast_path.stream_json"); + std::vector argv; + if (argvJson.isArray()) + for (const Json& part : argvJson.items()) argv.push_back(CapPy::str(part)); + else + argv.push_back(CapPy::str(argvJson)); + WireProc::Options options; + options.argv = modelArgv(argv); + options.cwd = cwd_; + options.replaceEnv = true; + options.env = sessionEnv(); + options.in = options.out = options.err = WireProc::Stream::Pipe; + std::unique_ptr proc; + try { + proc = WireProc::spawn(options); + } catch (const detz::wire::OsError& exc) { + throw CapError("cap_binding_unavailable", + "cannot spawn " + (argv.empty() ? std::string() : argv[0]) + ": " + CapPy::shortError(exc.what(), "OSError")); + } + WireProc::setNonBlocking(proc->stdoutFd()); + std::lock_guard lock(mu_); + reader_ = std::make_unique(proc->stdoutFd()); + drain_ = std::make_unique(proc->stderrFd()); + proc_ = std::move(proc); +} + +void CapStreamDriver::sendText(const std::string& text) { + std::lock_guard lock(mu_); + preSend(); + const std::string line = + Json::object({{"type", Json("user")}, {"message", Json(text)}}).dumps() + "\n"; + try { + proc_->writeStdin(line); + } catch (const detz::wire::OsError& exc) { + throw CapError("cap_binding_unavailable", "agent input failed: " + CapPy::shortError(exc.what(), "OSError")); + } + ++msgSeq_; + turnMsgId_ = "msg_" + std::to_string(msgSeq_); + clearAsks(); + setState("working"); +} + +std::vector CapStreamDriver::poll(double timeout) { + int fd = -1; + { + std::lock_guard lock(mu_); + const std::string st = state(); + if (!proc_ || st == "failed" || st == "closed") return drain(); + fd = reader_->fd(); + } + // Wait outside the lock; the read below is non-blocking. + if (timeout > 0) WireProc::waitReadable(fd, timeout); + std::lock_guard lock(mu_); + { + const std::string st = state(); + if (!proc_ || st == "failed" || st == "closed") return drain(); + } + for (const std::string& line : reader_->readLines(0.0)) mapFrame(line); + if (reader_->takeDropped()) + emitEvent(CapEvent::make("cap.error", Json::object({{"code", Json("parse_failed")}, + {"message", Json("line over " + std::to_string(CAP_STREAM_LINE_MAX) + " bytes dropped")}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + if (reader_->eof() && reader_->bufferEmpty() && proc_->poll()) onProcGone(); + return drain(); +} + +void CapStreamDriver::cancel(const std::string& scope) { + std::unique_lock lock(mu_); + if (!proc_) throw CapError("cap_binding_unavailable", "session never started"); + if (scope == "session") { + lock.unlock(); + close(); + return; + } + if (cancelMode() == "none") throw CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none"); + CapProcUtil::capProcKill(*proc_); + if (drain_) drain_->stop(); + proc_->closePipes(); + proc_.reset(); + reader_.reset(); + drain_.reset(); + setState("starting"); +} + +void CapStreamDriver::close() { + std::lock_guard lock(mu_); + if (proc_) { + std::unique_ptr proc = std::move(proc_); + CapProcUtil::capProcReap(*proc, drain_.get()); + reader_.reset(); + // keep the drained stderr lines available for diagnostics + } + if (state() != "failed") setState("closed"); +} + +void CapStreamDriver::mapFrame(const std::string& line) { + Json frame; + try { + frame = Json::parse(line); + } catch (const detz::wire::JsonError&) { + ++ignoredFrames_; + return; + } + if (!frame.isObject()) { + ++ignoredFrames_; + return; + } + const Json ftype = frame.get("type"); + if (ftype == Json("system") && frame.get("subtype") == Json("init")) { + if (state() == "starting") { + setState("ready"); + emitEvent(CapEvent::make( + "cap.session.ready", + Json::object({{"version", Json(std::string(CAP_VERSION))}, + {"session_id", frame.get("session_id").truthy() ? frame.get("session_id") : Json(urn_)}}))); + } + return; + } + if (ftype == Json("assistant")) { + mapAssistant(frame); + return; + } + if (ftype == Json("user")) { + mapToolResults(frame); + return; + } + if (ftype == Json("result")) { + mapResult(frame); + return; + } + if (ftype == Json("artifact") && frame.get("artifact").isObject()) { + try { + const Json artifact = frame.get("artifact"); + if (!artifact.has("artifact_id") || !artifact.has("mime_type")) throw std::out_of_range("KeyError"); + long long size = 0; + if (!CapPy::toInt(artifact.get("size", Json(0)), size)) throw ValueError("int"); + emitEvent(CapEvent::make("cap.artifact", Json::object({{"artifact_id", artifact.get("artifact_id")}, + {"mime_type", artifact.get("mime_type")}, + {"name", artifact.get("name", Json(""))}, + {"size", Json(size)}, + {"uri", artifact.get("uri")}, + {"content", artifact.get("content")}}))); + } catch (const std::out_of_range&) { + ++ignoredFrames_; + } catch (const ValueError&) { + ++ignoredFrames_; + } + return; + } + ++ignoredFrames_; +} + +void CapStreamDriver::mapAssistant(const Json& frame) { + Json message = frame.get("message"); + if (!message.truthy()) message = Json::object(); + Json idJson = message.get("id"); + const std::string msgId = idJson.truthy() ? CapPy::str(idJson) : turnMsgId_; + Json content = message.get("content"); + std::vector blocks; + if (content.isArray()) blocks = content.items(); + for (const Json& block : blocks) { + if (!block.isObject()) continue; + const Json btype = block.get("type"); + if (btype == Json("text") && block.get("text").truthy()) { + emitEvent(CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json(msgId)}, + {"text", block.get("text")}, + {"channel", Json("assistant")}}))); + } else if (btype == Json("thinking") && block.get("text").truthy()) { + emitEvent(CapEvent::make("cap.thought", Json::object({{"message_id", Json(msgId)}, {"text", block.get("text")}}))); + } else if (btype == Json("tool_use")) { + Json payload = block.get("input"); + if (!payload.isObject()) payload = Json::object({{"raw", payload}}); + ++callSeq_; + Json callId = block.get("id"); + Json name = block.get("name"); + emitEvent(CapEvent::make("cap.tool_call.start", + Json::object({{"call_id", callId.truthy() ? callId : Json("c" + std::to_string(callSeq_))}, + {"name", name.truthy() ? name : Json("tool")}, + {"input", payload}}))); + } + } +} + +void CapStreamDriver::mapToolResults(const Json& frame) { + Json message = frame.get("message"); + if (!message.truthy()) message = Json::object(); + if (message.isString()) return; + Json blocksJson = message.get("content"); + std::vector blocks; + if (blocksJson.isArray()) blocks = blocksJson.items(); + for (const Json& block : blocks) { + if (!block.isObject() || block.get("type") != Json("tool_result")) continue; + Json content = block.get("content"); + std::string text; + if (content.isString()) text = content.str(); + else text = content.dumps(); + Json useId = block.get("tool_use_id"); + emitEvent(CapEvent::make("cap.tool_call.end", + Json::object({{"call_id", useId.truthy() ? useId : Json("")}, + {"output", Json(text)}, + {"is_error", Json(block.get("is_error", Json(false)).truthy())}, + {"duration_ms", Json(0)}}))); + } +} + +void CapStreamDriver::mapResult(const Json& frame) { + if (frame.get("subtype") == Json("error")) { + Json err = frame.get("error"); + emitEvent(CapEvent::make("cap.error", + Json::object({{"code", Json("error")}, + {"message", Json(WireText::head(CapPy::orStr(err, "agent failed"), 2000))}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + setState("done"); + return; + } + Json usage = frame.get("usage"); + if (!usage.truthy()) usage = Json::object(); + long long outTokens = 0, inTokens = 0; + { + Json outRaw = usage.get("output_tokens", usage.get("total_tokens", Json(0))); + Json inRaw = usage.get("input_tokens", Json(0)); + long long o = 0, i = 0; + bool ok = true; + if (outRaw.truthy()) ok = CapPy::toInt(outRaw, o); + if (ok && inRaw.truthy()) ok = CapPy::toInt(inRaw, i); + if (ok) { + outTokens = o; + inTokens = i; + } + } + long long durationMs = 0; + { + Json d = frame.get("duration_ms", Json(0)); + if (d.truthy()) CapPy::toInt(d, durationMs); + } + Json event = CapEvent::make("cap.usage", + Json::object({{"model_id", Json(!model_.empty() ? model_ : manifest_.name())}, + {"input_tokens", Json(inTokens)}, + {"output_tokens", Json(outTokens)}, + {"duration_ms", Json(durationMs)}, + {"stop_reason", Json("end_turn")}})); + if (!frame.get("total_cost_usd").isNull()) { + double cost = 0; + if (CapPy::toFloat(frame.get("total_cost_usd"), cost)) event.set("cost_usd_estimate", Json(cost)); + } + emitEvent(event); + setState("done"); +} + +void CapStreamDriver::onProcGone() { + if (state() == "done") { + setState("closed"); + return; + } + if (state() != "failed") { + std::string hint; + const std::vector lines = drain_ ? drain_->lines() : std::vector(); + if (!lines.empty()) hint = "; stderr: " + WireText::join(lines, " | "); + emitEvent(CapEvent::make("cap.error", + Json::object({{"code", Json("error")}, + {"message", Json(WireText::head("agent process exited unexpectedly" + hint, 2000))}, + {"retryable", Json(false)}, + {"details", Json::object()}}))); + } + setState("failed"); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_stream_driver.h b/taicpp/src/cap/cap_stream_driver.h new file mode 100644 index 0000000..6b86117 --- /dev/null +++ b/taicpp/src/cap/cap_stream_driver.h @@ -0,0 +1,49 @@ +#pragma once +// CapStreamDriver: orchestrator-side stream-json binding (spec section 6.2, +// C.1): NDJSON over pipes, frames translated to core events. Cancelling a +// turn kills the one-shot process; the next send respawns lazily. The only +// thread is the stderr drain; stdout is pumped via poll(). +#include +#include +#include + +#include "cap_driver.h" +#include "cap_pipe_reader.h" +#include "cap_proc_util.h" +#include "wire_proc.h" + +namespace detz::cap { + +class CapStreamDriver : public CapDriver { + public: + CapStreamDriver(const CapManifest& manifest, std::string urn = "cap://agent-1", std::string cwd = "", + std::string model = "", std::map env = {}); + ~CapStreamDriver() override; + + void sendText(const std::string& text) override; + std::vector poll(double timeout = 0.0) override; + void cancel(const std::string& scope = "current_turn") override; + void close() override; + + int ignoredFrames() const; + std::vector stderrLines() const; + + protected: + void spawn() override; + bool hasProc() const override { return proc_ != nullptr; } + + private: + void mapFrame(const std::string& line); + void mapAssistant(const Json& frame); + void mapToolResults(const Json& frame); + void mapResult(const Json& frame); + void onProcGone(); + + std::unique_ptr proc_; + std::unique_ptr reader_; + std::unique_ptr drain_; + int ignoredFrames_{0}; + std::string turnMsgId_{"msg_boot"}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_stream_json.cpp b/taicpp/src/cap/cap_stream_json.cpp new file mode 100644 index 0000000..4d3c835 --- /dev/null +++ b/taicpp/src/cap/cap_stream_json.cpp @@ -0,0 +1,117 @@ +#include "cap_stream_json.h" + +namespace detz::cap { + +Json CapStreamJson::systemInit(const std::string& sessionId, const std::string& model) { + return Json::object({{"type", Json("system")}, {"subtype", Json("init")}, {"session_id", Json(sessionId)}, + {"model", Json(model)}}); +} + +Json CapStreamJson::assistant(const std::string& messageId, const std::string& text, const Json& calls) { + Json content = Json::array(); + if (!text.empty()) content.push(Json::object({{"type", Json("text")}, {"text", Json(text)}})); + if (calls.isArray()) { + for (const Json& call : calls.items()) { + Json args = call.get("arguments"); + std::string argText = args.truthy() ? (args.isString() ? args.str() : args.pyStr()) : "{}"; + Json payload; + bool parsed = true; + try { + payload = Json::parse(argText); + } catch (const detz::wire::JsonError&) { + parsed = false; + } + auto raw = [&]() { + Json a = call.get("arguments"); + return Json::object({{"raw", a.truthy() ? a : Json("")}}); + }; + if (!parsed) payload = raw(); + else if (!payload.isObject()) payload = raw(); + Json id = call.get("id"), name = call.get("name"); + content.push(Json::object({{"type", Json("tool_use")}, {"id", id.truthy() ? id : Json("")}, + {"name", name.truthy() ? name : Json("")}, {"input", payload}})); + } + } + if (content.items().empty()) return Json(); + return Json::object({{"type", Json("assistant")}, + {"message", Json::object({{"role", Json("assistant")}, {"id", Json(messageId)}, + {"content", content}})}}); +} + +Json CapStreamJson::toolResults(const std::vector& results) { + Json content = Json::array(); + for (const auto& [callId, text, failed] : results) + content.push(Json::object({{"type", Json("tool_result")}, {"tool_use_id", Json(callId)}, {"content", Json(text)}, + {"is_error", Json(failed)}})); + return Json::object({{"type", Json("user")}, + {"message", Json::object({{"role", Json("user")}, {"content", content}})}}); +} + +Json CapStreamJson::resultOk(const std::string& sessionId, const std::string& model, const std::string& text, + long long durationMs, const Json& usage, const Json& extra) { + Json frame = Json::object({{"type", Json("result")}, {"subtype", Json("success")}, {"session_id", Json(sessionId)}, + {"model", Json(model)}, {"text", Json(text)}, {"duration_ms", Json(durationMs)}, + {"usage", usage}}); + if (extra.truthy()) frame.update(extra); + return frame; +} + +Json CapStreamJson::resultError(const std::string& sessionId, const std::string& model, const std::string& message) { + return Json::object({{"type", Json("result")}, {"subtype", Json("error")}, {"session_id", Json(sessionId)}, + {"model", Json(model)}, {"error", Json(message)}}); +} + +Json CapStreamJson::artifactFrame(const Json& artifact) { + return Json::object({{"type", Json("artifact")}, {"artifact", artifact}}); +} + +std::string CapStreamJson::parseUserText(const std::string& line) { + Json frame; + try { + frame = Json::parse(line); + } catch (const detz::wire::JsonError& exc) { + throw ValueError(std::string("not JSON: ") + exc.what()); + } + if (!frame.isObject() || frame.get("type") != Json("user")) throw ValueError("expected a {type: user} frame"); + Json message = frame.get("message", Json("")); + std::string text; + if (message.isString()) { + text = message.str(); + } else if (message.isObject()) { + std::vector parts; + Json blocks = message.get("content", Json::array()); + if (blocks.truthy()) { + // `for block in message.get("content", []) or []`: a str iterates by + // character and a dict by key, exactly like Python. + std::vector iterated; + if (blocks.isArray()) { + iterated = blocks.items(); + } else if (blocks.isString()) { + for (char32_t cp : WireText::codepoints(blocks.str())) iterated.push_back(Json(WireText::encode(cp))); + } else if (blocks.isObject()) { + for (const auto& member : blocks.members()) iterated.push_back(Json(member.first)); + } else { + throw detz::wire::PyTypeError(std::string("'") + (blocks.isFloat() ? "float" : blocks.isBool() ? "bool" : "int") + + "' object is not iterable"); + } + for (const Json& block : iterated) { + if (block.isObject() && block.get("type") == Json("text")) { + Json t = block.get("text"); + parts.push_back(t.truthy() ? (t.isString() ? t.str() : t.pyStr()) : ""); + } else if (block.isString()) { + parts.push_back(block.str()); + } + } + } + std::vector nonEmpty; + for (const auto& p : parts) + if (!p.empty()) nonEmpty.push_back(p); + text = WireText::join(nonEmpty, "\n"); + } else { + throw ValueError("user frame has no usable message"); + } + if (WireText::strip(text).empty()) throw ValueError("user frame carries no text"); + return text; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_stream_json.h b/taicpp/src/cap/cap_stream_json.h new file mode 100644 index 0000000..746979b --- /dev/null +++ b/taicpp/src/cap/cap_stream_json.h @@ -0,0 +1,31 @@ +#pragma once +// CapStreamJson: stream-json wire shapes (Claude SDK compatible): constructors +// for outbound frames plus the inbound user-frame parser. Unknown inbound +// frame types throw ValueError; receivers MUST ignore unknown outbound +// extension fields and the tai `artifact` frame type. +#include +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +class CapStreamJson { + public: + using ToolResult = std::tuple; // call id, text, failed + + static Json systemInit(const std::string& sessionId, const std::string& model); + // `calls` are tool call dicts {id, name, arguments(JSON text)}. Null when + // the message would carry no content (Python returns None). + static Json assistant(const std::string& messageId, const std::string& text, const Json& calls); + static Json toolResults(const std::vector& results); + static Json resultOk(const std::string& sessionId, const std::string& model, const std::string& text, + long long durationMs, const Json& usage, const Json& extra = Json()); + static Json resultError(const std::string& sessionId, const std::string& model, const std::string& message); + static Json artifactFrame(const Json& artifact); + // Throws ValueError with Python's messages. + static std::string parseUserText(const std::string& line); +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_turn_result.cpp b/taicpp/src/cap/cap_turn_result.cpp new file mode 100644 index 0000000..afd879b --- /dev/null +++ b/taicpp/src/cap/cap_turn_result.cpp @@ -0,0 +1,14 @@ +#include "cap_turn_result.h" + +#include "cap_py.h" + +namespace detz::cap { + +std::string CapTurnResult::text() const { + std::vector parts; + for (const Json& event : events_) + if (event.get("kind") == Json("cap.text_chunk")) parts.push_back(CapPy::orStr(event.get("text"), "")); + return WireText::join(parts, "\n"); +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_turn_result.h b/taicpp/src/cap/cap_turn_result.h new file mode 100644 index 0000000..df127d7 --- /dev/null +++ b/taicpp/src/cap/cap_turn_result.h @@ -0,0 +1,36 @@ +#pragma once +// CapTurnResult: one blocking turn: the collected events, the end state, +// summed usage, and the assistant text joined for routing. +#include +#include + +#include "cap_types.h" + +namespace detz::cap { + +// The usage counters a session sums: tokens in and out plus the estimated +// cost (`cost_usd_estimate`), exactly the three keys of CapSession.usage. +struct CapUsage { + long long inputTokens{0}; + long long outputTokens{0}; + double costUsdEstimate{0.0}; +}; + +class CapTurnResult { + public: + CapTurnResult(std::vector events, std::string state, CapUsage usage = CapUsage()) + : events_(std::move(events)), state_(std::move(state)), usage_(usage) {} + + const std::vector& events() const { return events_; } + const std::string& state() const { return state_; } + const CapUsage& usage() const { return usage_; } + // "\n".join(event.get("text", "") for text_chunk events) + std::string text() const; + + private: + std::vector events_; + std::string state_; + CapUsage usage_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_types.h b/taicpp/src/cap/cap_types.h new file mode 100644 index 0000000..3ede4f1 --- /dev/null +++ b/taicpp/src/cap/cap_types.h @@ -0,0 +1,16 @@ +#pragma once +// Shared aliases for the CAP classes. +#include +#include + +#include "wire_json.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::cap { + +using detz::wire::Json; +using detz::wire::ValueError; +using detz::wire::WireText; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_vt_screen.cpp b/taicpp/src/cap/cap_vt_screen.cpp new file mode 100644 index 0000000..4621291 --- /dev/null +++ b/taicpp/src/cap/cap_vt_screen.cpp @@ -0,0 +1,635 @@ +#include "cap_vt_screen.h" + +#include +#include + +#include "wire_text.h" + +namespace detz::cap { + +using detz::wire::WireText; + +namespace { + +constexpr size_t kNeedMore = std::u32string::npos; + +char32_t graphicsFor(char32_t c) { + static const std::map table = { + {'`', 0x25c6}, {'a', 0x2592}, {'f', 0x00b0}, {'g', 0x00b1}, {'j', 0x2518}, {'k', 0x2510}, {'l', 0x250c}, + {'m', 0x2514}, {'n', 0x253c}, {'o', 0x23ba}, {'p', 0x23bb}, {'q', 0x2500}, {'r', 0x23bc}, {'s', 0x23bd}, + {'t', 0x251c}, {'u', 0x2524}, {'v', 0x2534}, {'w', 0x252c}, {'x', 0x2502}, {'y', 0x2264}, {'z', 0x2265}, + {'{', 0x03c0}, {'|', 0x2260}, {'}', 0x00a3}, {'~', 0x00b7}}; + auto it = table.find(c); + return it == table.end() ? c : it->second; +} + +bool isAsciiDigit(char32_t c) { return c >= '0' && c <= '9'; } + +} // namespace + +CapVtScreen::CapVtScreen(int rows, int cols) : rows_(std::max(1, rows)), cols_(std::max(1, cols)) { + grid_.assign(static_cast(rows_), Row(static_cast(cols_))); + regionBottom_ = rows_ - 1; + marginRight_ = cols_ - 1; +} + +CapVtScreen::Cell CapVtScreen::blank() const { + Cell cell; + cell.ch = U' '; + cell.written = true; + return cell; +} + +CapVtScreen::Row CapVtScreen::blankRow() const { return Row(static_cast(cols_), blank()); } + +std::string CapVtScreen::takeReplies() { + std::string out; + out.swap(replies_); + return out; +} + +std::vector CapVtScreen::textLines() const { + std::vector lines; + for (const Row& row : grid_) { + size_t end = row.size(); + while (end > 0 && !row[end - 1].written) --end; + std::string text; + for (size_t i = 0; i < end; ++i) text += row[i].written ? WireText::encode(row[i].ch) : " "; + lines.push_back(end < row.size() ? text : WireText::rstrip(text)); + } + return lines; +} + +std::string CapVtScreen::text(int limit) const { + std::vector lines; + size_t first = 0; + if (limit > 0 && static_cast(limit) < grid_.size()) first = grid_.size() - static_cast(limit); + for (size_t r = first; r < grid_.size(); ++r) { + std::string line; + for (const Cell& cell : grid_[r]) line += cell.written ? WireText::encode(cell.ch) : " "; + lines.push_back(WireText::rstrip(line)); + } + return WireText::join(lines, "\n"); +} + +void CapVtScreen::feed(const std::string& bytes) { + if (bytes.empty()) return; + pendingBytes_ += bytes; + std::vector decoded; + WireText::decodeChunk(pendingBytes_, decoded); + buf_.append(decoded.begin(), decoded.end()); + if (buf_.size() > TAIL_GUARD) buf_ = buf_.substr(buf_.size() - TAIL_GUARD); + const std::u32string buf = buf_; + size_t pos = 0; + const size_t end = buf.size(); + while (pos < end) { + const char32_t c = buf[pos]; + if (c == 0x1b) { + const size_t next = parseEsc(buf, pos, end); + if (next == kNeedMore) break; + pos = next; + } else if (c == 0x9b) { + const size_t next = parseCsi(buf, pos + 1, end); + if (next == kNeedMore) break; + pos = next; + } else if (c == 0x90 || c == 0x9d || c == 0x9e || c == 0x9f) { + const size_t next = parseUntilSt(buf, pos + 1, end); + if (next == kNeedMore) break; + pos = next; + } else { + ground(c); + ++pos; + } + } + buf_ = buf.substr(pos); +} + +void CapVtScreen::ground(char32_t c) { + if (c == '\n' || c == 0x0b || c == 0x0c) { + wrap_ = false; + lineFeed(); + } else if (c == '\r') { + wrap_ = false; + cx_ = 0; + } else if (c == '\b') { + wrap_ = false; + cx_ = std::max(0, cx_ - 1); + } else if (c == '\t') { + wrap_ = false; + cx_ = std::min(cols_ - 1, (cx_ / TAB_WIDTH + 1) * TAB_WIDTH); + } else if (c == 0x07 || c == 0x00 || c == 0x0e || c == 0x0f) { + } else if (c < 0x20 || c == 0x7f) { + } else { + if (wrap_) { + wrap_ = false; + cx_ = 0; + lineFeed(); + } + if (graphics_) c = graphicsFor(c); + lastChar_ = c; + Row& row = grid_[static_cast(cy_)]; + Cell cell; + cell.ch = c; + cell.written = true; + row[static_cast(cx_)] = cell; + if (cx_ == cols_ - 1) wrap_ = true; + else ++cx_; + } +} + +size_t CapVtScreen::parseEsc(const std::u32string& buf, size_t pos, size_t end) { + if (pos + 1 >= end) return kNeedMore; + const char32_t nxt = buf[pos + 1]; + if (nxt == '[') return parseCsi(buf, pos + 2, end); + if (nxt == ']') { + const size_t stop = parseUntilSt(buf, pos + 2, end); + if (stop != kNeedMore) osc(buf.substr(pos + 2, stop - (pos + 2))); + return stop; + } + if (nxt == 'P' || nxt == 'X' || nxt == '^' || nxt == '_') return parseUntilSt(buf, pos + 2, end); + if (nxt == '(' || nxt == ')' || nxt == '#' || nxt == '%') { + if (pos + 2 >= end) return kNeedMore; + if (nxt == '(') graphics_ = buf[pos + 2] == '0'; + return pos + 3; + } + if (nxt == '7') { + saved_ = Saved{cx_, cy_}; + } else if (nxt == '8') { + if (saved_) { + cx_ = saved_->cx; + cy_ = saved_->cy; + clampCursor(); + } + } else if (nxt == 'M') { + wrap_ = false; + if (cy_ == regionTop_) { + if (insideMargins()) scrollDown(regionTop_, regionBottom_); + } else { + cy_ = std::max(0, cy_ - 1); + } + } else if (nxt == 'c') { + reset(); + } else if (nxt == 'D') { + lineFeed(); + } else if (nxt == 'E') { + cx_ = 0; + lineFeed(); + } + return pos + 2; +} + +void CapVtScreen::osc(const std::u32string& body) { + size_t stop = body.size(); + while (stop > 0 && (body[stop - 1] == 0x07 || body[stop - 1] == 0x1b || body[stop - 1] == '\\')) --stop; + const std::u32string trimmed = body.substr(0, stop); + const size_t semi = trimmed.find(U';'); + const std::u32string code = trimmed.substr(0, semi); + if (code == U"0" || code == U"2") { + const std::u32string text = semi == std::u32string::npos ? U"" : trimmed.substr(semi + 1); + std::vector cps(text.begin(), text.end()); + if (cps.size() > 256) cps.resize(256); + title_ = WireText::fromCodepoints(cps); + } +} + +size_t CapVtScreen::parseCsi(const std::u32string& buf, size_t pos, size_t end) { + size_t cur = pos; + const std::u32string extra = U";:?> 256) return pos + 2; + } + if (cur >= end) return kNeedMore; + const char32_t final = buf[cur]; + if (!(final >= '@' && final <= '~')) return pos + 2; + dispatchCsi(buf.substr(pos, cur - pos), final); + return cur + 1; +} + +size_t CapVtScreen::parseUntilSt(const std::u32string& buf, size_t pos, size_t end) const { + size_t cur = pos; + while (cur < end) { + if (buf[cur] == 0x07) return cur + 1; + if (buf[cur] == 0x1b) { + if (cur + 1 >= end) return kNeedMore; + if (buf[cur + 1] == '\\') return cur + 2; + cur += 2; + continue; + } + ++cur; + if (cur - pos > 4096) return cur; + } + return kNeedMore; +} + +std::vector CapVtScreen::params(const std::u32string& bodyIn, int count, long long fallback) const { + std::u32string body = bodyIn; + if (!body.empty() && (body[0] == U'?' || body[0] == U'>' || body[0] == U'=')) body = body.substr(1); + else if (!body.empty() && (body[0] == U'!' || body[0] == U'"' || body[0] == U'$')) body.clear(); + for (char32_t& c : body) + if (c == U':') c = U';'; + std::vector parts; + size_t start = 0; + while (true) { + const size_t semi = body.find(U';', start); + if (semi == std::u32string::npos) { + parts.push_back(body.substr(start)); + break; + } + parts.push_back(body.substr(start, semi - start)); + start = semi + 1; + } + if (parts.size() > 64) parts.resize(64); + std::vector values; + for (std::u32string part : parts) { + size_t a = 0, b = part.size(); + while (a < b && WireText::isSpace(part[a])) ++a; + while (b > a && WireText::isSpace(part[b - 1])) --b; + part = part.substr(a, b - a); + if (part.empty()) { + values.push_back(fallback); + continue; + } + bool ok = true; + long long v = 0; + for (char32_t c : part) { + if (!isAsciiDigit(c)) { + ok = false; + break; + } + v = std::min(v * 10 + (c - '0'), 1LL << 30); + } + values.push_back(ok ? v : fallback); + } + if (count >= 0) + while (static_cast(values.size()) < count) values.push_back(fallback); + return values; +} + +void CapVtScreen::dispatchCsi(const std::u32string& body, char32_t final) { + const bool priv = !body.empty() && (body[0] == U'?' || body[0] == U'>'); + if (final == 'm') return; // SGR: style is not tracked (see header) + if (final == 'c' && (body.empty() || body == U"0" || body == U">" || body == U">0")) { + reply(!body.empty() && body[0] == U'>' ? "\x1b[>0;0;0c" : "\x1b[?1;2c"); + return; + } + if (final == 'p' && !body.empty() && body.back() == U'$') { + const long long m = params(body.substr(0, body.size() - 1), 1, 0)[0]; + int state = -1; // -1 None, 0 false, 1 true + if (priv) { + switch (m) { + case 25: state = cursorVisible_; break; + case 1: state = appCursor_; break; + case 2004: state = bracketedPaste_; break; + case 69: state = lrMode_; break; + case 1049: state = alt_.has_value(); break; + default: break; + } + } + reply(std::string("\x1b[") + (priv ? "?" : "") + std::to_string(m) + ";" + + std::to_string(state < 0 ? 0 : state ? 1 : 2) + "$y"); + return; + } + if (final == 'n' && !priv) { + const long long code = params(body, 1, 0)[0]; + if (code == 5) reply("\x1b[0n"); + else if (code == 6) reply("\x1b[" + std::to_string(cy_ + 1) + ";" + std::to_string(cx_ + 1) + "R"); + return; + } + if (final == 'h' || final == 'l') { + mode(body, final == 'h'); + return; + } + if (priv) return; + auto first = [&](long long fallback = 1) { return params(body, 1, fallback)[0]; }; + auto clampI = [](long long v) { return static_cast(std::min(v, 1 << 30)); }; + if (final == 'A') { + wrap_ = false; + const int floorRow = inRegion() ? regionTop_ : 0; + cy_ = std::max(floorRow, cy_ - clampI(first())); + } else if (final == 'B') { + wrap_ = false; + const int ceilRow = inRegion() ? regionBottom_ : rows_ - 1; + cy_ = std::min(ceilRow, cy_ + clampI(first())); + } else if (final == 'C') { + wrap_ = false; + cx_ = std::min(cols_ - 1, cx_ + clampI(first())); + } else if (final == 'D') { + wrap_ = false; + cx_ = std::max(0, cx_ - clampI(first())); + } else if (final == 'E') { + wrap_ = false; + cy_ = std::min(rows_ - 1, cy_ + clampI(first())); + cx_ = 0; + } else if (final == 'F') { + wrap_ = false; + cy_ = std::max(0, cy_ - clampI(first())); + cx_ = 0; + } else if (final == 'G') { + wrap_ = false; + cx_ = std::min(cols_ - 1, std::max(0, clampI(first()) - 1)); + } else if (final == 'H' || final == 'f') { + wrap_ = false; + const auto rc = params(body, 2); + cy_ = std::min(rows_ - 1, std::max(0, clampI(rc[0]) - 1)); + cx_ = std::min(cols_ - 1, std::max(0, clampI(rc[1]) - 1)); + } else if (final == 'd') { + wrap_ = false; + cy_ = std::min(rows_ - 1, std::max(0, clampI(first()) - 1)); + } else if (final == 'J') { + wrap_ = false; + eraseDisplay(first(0)); + } else if (final == 'K') { + wrap_ = false; + eraseLine(first(0)); + } else if (final == 'L') { + wrap_ = false; + insertLines(first()); + } else if (final == 'M') { + wrap_ = false; + deleteLines(first()); + } else if (final == 'P') { + wrap_ = false; + deleteChars(first()); + } else if (final == '@') { + wrap_ = false; + insertChars(first()); + } else if (final == 'X') { + wrap_ = false; + eraseChars(first()); + } else if (final == 'S') { + wrap_ = false; + const long long n = std::min(first(), rows_); + for (long long i = 0; i < n; ++i) scrollUp(regionTop_, regionBottom_); + } else if (final == 'T') { + wrap_ = false; + const long long n = std::min(first(), rows_); + for (long long i = 0; i < n; ++i) scrollDown(regionTop_, regionBottom_); + } else if (final == 'r') { + const auto tb = params(body, 2); + const int top = std::min(rows_, std::max(1, clampI(tb[0]))); + const int bottom = std::min(rows_, std::max(1, clampI(tb[1]))); + if (top < bottom) { + regionTop_ = top - 1; + regionBottom_ = bottom - 1; + cx_ = 0; + cy_ = regionTop_; + } + } else if (final == 'b') { + if (lastChar_) { + const long long n = std::min(first(), static_cast(cols_) * rows_); + for (long long i = 0; i < n; ++i) ground(lastChar_); + } + } else if (final == 's' && lrMode_) { + const auto lr = params(body, 2); + const int left = std::min(cols_, std::max(1, clampI(lr[0]))); + const bool hasSemi = body.find(U';') != std::u32string::npos; + const int right = std::min(cols_, std::max(1, hasSemi ? clampI(lr[1]) : cols_)); + if (left < right) { + marginLeft_ = left - 1; + marginRight_ = right - 1; + cx_ = 0; + cy_ = 0; + } + } else if (final == 's') { + saved_ = Saved{cx_, cy_}; + } else if (final == 'u') { + if (saved_) { + cx_ = saved_->cx; + cy_ = saved_->cy; + clampCursor(); + } + } +} + +void CapVtScreen::mode(const std::u32string& body, bool state) { + const bool priv = !body.empty() && body[0] == U'?'; + for (long long value : params(body, -1, 0)) { + if (value == 25 && priv) { + cursorVisible_ = state; + } else if (value == 1 && priv) { + appCursor_ = state; + } else if (value == 2004 && priv) { + bracketedPaste_ = state; + } else if (value == 69 && priv) { + lrMode_ = state; + marginLeft_ = 0; + marginRight_ = cols_ - 1; + } else if ((value == 47 || value == 1047 || value == 1049) && priv) { + altScreen(state); + } + } +} + +void CapVtScreen::altScreen(bool state) { + if (state && !alt_) { + Alt alt; + alt.grid = grid_; + alt.cx = cx_; + alt.cy = cy_; + alt.regionTop = regionTop_; + alt.regionBottom = regionBottom_; + alt.saved = saved_; + alt.cursorVisible = cursorVisible_; + alt_ = alt; + grid_.assign(static_cast(rows_), Row(static_cast(cols_))); + cx_ = 0; + cy_ = 0; + regionTop_ = 0; + regionBottom_ = rows_ - 1; + saved_.reset(); + wrap_ = false; + } else if (!state && alt_) { + grid_ = alt_->grid; + cx_ = alt_->cx; + cy_ = alt_->cy; + regionTop_ = alt_->regionTop; + regionBottom_ = alt_->regionBottom; + saved_ = alt_->saved; + cursorVisible_ = alt_->cursorVisible; + alt_.reset(); + wrap_ = false; + } +} + +void CapVtScreen::lineFeed() { + if (cy_ == regionBottom_) { + if (insideMargins()) scrollUp(regionTop_, regionBottom_); + } else { + cy_ = std::min(rows_ - 1, cy_ + 1); + } +} + +std::pair CapVtScreen::margins() const { + if (lrMode_) return {marginLeft_, marginRight_}; + return {0, cols_ - 1}; +} + +bool CapVtScreen::insideMargins() const { + const auto [left, right] = margins(); + return left <= cx_ && cx_ <= right; +} + +void CapVtScreen::scrollUp(int top, int bottom) { + const auto [left, right] = margins(); + if (left > 0 || right < cols_ - 1) { + for (int row = top; row < bottom; ++row) { + Row merged(grid_[static_cast(row)].begin(), + grid_[static_cast(row)].begin() + std::min(static_cast(left), grid_[static_cast(row)].size())); + const Row& below = grid_[static_cast(row) + 1]; + for (int c = left; c <= right && static_cast(c) < below.size(); ++c) merged.push_back(below[static_cast(c)]); + const Row& own = grid_[static_cast(row)]; + for (size_t c = static_cast(right) + 1; c < own.size(); ++c) merged.push_back(own[c]); + grid_[static_cast(row)] = merged; + } + Row& last = grid_[static_cast(bottom)]; + Row merged(last.begin(), last.begin() + std::min(static_cast(left), last.size())); + for (int c = left; c <= right; ++c) merged.push_back(blank()); + for (size_t c = static_cast(right) + 1; c < last.size(); ++c) merged.push_back(last[c]); + last = merged; + return; + } + for (int row = top; row < bottom; ++row) grid_[static_cast(row)] = grid_[static_cast(row) + 1]; + grid_[static_cast(bottom)] = blankRow(); +} + +void CapVtScreen::scrollDown(int top, int bottom) { + const auto [left, right] = margins(); + if (left > 0 || right < cols_ - 1) { + for (int row = bottom; row > top; --row) { + const Row& own = grid_[static_cast(row)]; + Row merged(own.begin(), own.begin() + std::min(static_cast(left), own.size())); + const Row& above = grid_[static_cast(row) - 1]; + for (int c = left; c <= right && static_cast(c) < above.size(); ++c) merged.push_back(above[static_cast(c)]); + for (size_t c = static_cast(right) + 1; c < own.size(); ++c) merged.push_back(own[c]); + grid_[static_cast(row)] = merged; + } + Row& first = grid_[static_cast(top)]; + Row merged(first.begin(), first.begin() + std::min(static_cast(left), first.size())); + for (int c = left; c <= right; ++c) merged.push_back(blank()); + for (size_t c = static_cast(right) + 1; c < first.size(); ++c) merged.push_back(first[c]); + first = merged; + return; + } + for (int row = bottom; row > top; --row) grid_[static_cast(row)] = grid_[static_cast(row) - 1]; + grid_[static_cast(top)] = blankRow(); +} + +void CapVtScreen::eraseDisplay(long long modeValue) { + if (modeValue == 2 || modeValue == 3) { + for (int row = 0; row < rows_; ++row) grid_[static_cast(row)] = blankRow(); + cx_ = 0; + cy_ = 0; + } else if (modeValue == 1) { + for (int row = 0; row < cy_; ++row) grid_[static_cast(row)] = blankRow(); + for (int col = 0; col <= cx_; ++col) grid_[static_cast(cy_)][static_cast(col)] = blank(); + } else { + for (int col = cx_; col < cols_; ++col) grid_[static_cast(cy_)][static_cast(col)] = blank(); + for (int row = cy_ + 1; row < rows_; ++row) grid_[static_cast(row)] = blankRow(); + } +} + +void CapVtScreen::eraseLine(long long modeValue) { + if (modeValue == 1) { + for (int col = 0; col <= cx_; ++col) grid_[static_cast(cy_)][static_cast(col)] = blank(); + } else if (modeValue == 2) { + grid_[static_cast(cy_)] = blankRow(); + } else { + for (int col = cx_; col < cols_; ++col) grid_[static_cast(cy_)][static_cast(col)] = blank(); + } +} + +void CapVtScreen::insertLines(long long count) { + count = std::min(std::max(1, count), regionBottom_ - cy_ + 1); + if (cy_ < regionTop_ || cy_ > regionBottom_ || !insideMargins()) return; + for (long long i = 0; i < count; ++i) scrollDown(cy_, regionBottom_); +} + +void CapVtScreen::deleteLines(long long count) { + count = std::min(std::max(1, count), regionBottom_ - cy_ + 1); + if (cy_ < regionTop_ || cy_ > regionBottom_ || !insideMargins()) return; + for (long long i = 0; i < count; ++i) scrollUp(cy_, regionBottom_); +} + +void CapVtScreen::deleteChars(long long count) { + Row& row = grid_[static_cast(cy_)]; + Row out(row.begin(), row.begin() + std::min(static_cast(cx_), row.size())); + const unsigned long long from = static_cast(cx_) + static_cast(count); + for (unsigned long long c = from; c < row.size(); ++c) out.push_back(row[static_cast(c)]); + const long long pad = std::min(std::max(1, count), cols_ - cx_); + for (long long i = 0; i < pad; ++i) out.push_back(blank()); + row = out; +} + +void CapVtScreen::insertChars(long long count) { + Row& row = grid_[static_cast(cy_)]; + const int room = cols_ - cx_; + count = std::min(std::max(1, count), room); + Row out(row.begin(), row.begin() + std::min(static_cast(cx_), row.size())); + Row tail; + for (long long i = 0; i < count; ++i) tail.push_back(blank()); + for (size_t c = static_cast(cx_); c < row.size(); ++c) tail.push_back(row[c]); + if (tail.size() > static_cast(room)) tail.resize(static_cast(room)); + out.insert(out.end(), tail.begin(), tail.end()); + row = out; +} + +void CapVtScreen::eraseChars(long long count) { + const long long stop = std::min(cols_, cx_ + std::max(1, count)); + for (long long col = cx_; col < stop; ++col) grid_[static_cast(cy_)][static_cast(col)] = blank(); +} + +void CapVtScreen::clampCursor() { + cx_ = std::min(cols_ - 1, std::max(0, cx_)); + cy_ = std::min(rows_ - 1, std::max(0, cy_)); +} + +void CapVtScreen::reset() { + for (int row = 0; row < rows_; ++row) grid_[static_cast(row)] = blankRow(); + cx_ = 0; + cy_ = 0; + cursorVisible_ = true; + regionTop_ = 0; + regionBottom_ = rows_ - 1; + lrMode_ = false; + marginLeft_ = 0; + marginRight_ = cols_ - 1; + wrap_ = false; + saved_.reset(); + alt_.reset(); + appCursor_ = false; + bracketedPaste_ = false; + graphics_ = false; +} + +void CapVtScreen::resize(int rows, int cols) { + rows = std::max(1, rows); + cols = std::max(1, cols); + std::vector grid; + for (int row = 0; row < rows; ++row) { + if (row < rows_) { + Row old = grid_[static_cast(row)]; + if (cols <= cols_) { + if (old.size() > static_cast(cols)) old.resize(static_cast(cols)); + grid.push_back(old); + } else { + old.resize(old.size() + static_cast(cols - cols_)); + grid.push_back(old); + } + } else { + grid.push_back(Row(static_cast(cols))); + } + } + grid_ = grid; + rows_ = rows; + cols_ = cols; + regionTop_ = 0; + regionBottom_ = rows - 1; + lrMode_ = false; + marginLeft_ = 0; + marginRight_ = cols - 1; + clampCursor(); + wrap_ = false; +} + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_vt_screen.h b/taicpp/src/cap/cap_vt_screen.h new file mode 100644 index 0000000..7458e3f --- /dev/null +++ b/taicpp/src/cap/cap_vt_screen.h @@ -0,0 +1,105 @@ +#pragma once +// CapVtScreen: in-memory VT100/ANSI screen, a port of tai.py's TuiVtScreen +// for the PTY driver. It tracks the character grid and the cursor, regions, +// margins, alternate screen and terminal replies exactly like the Python +// class. SGR colors and attributes are parsed but not stored: the PTY driver +// only reads text (`textLines`), and no text-visible behavior depends on +// style. Wide characters count as one column each, like the original. +#include +#include +#include +#include + +#include "cap_screen.h" + +namespace detz::cap { + +class CapVtScreen : public CapScreen { + public: + static constexpr int TAB_WIDTH = 8; + static constexpr size_t TAIL_GUARD = 65536; + + CapVtScreen(int rows, int cols); + + int rows() const override { return rows_; } + int cols() const override { return cols_; } + void feed(const std::string& bytes) override; + std::string takeReplies() override; + std::vector textLines() const override; + void resize(int rows, int cols) override; + + // Introspection used by tests (TuiVtScreen attributes). + int cursorX() const { return cx_; } + int cursorY() const { return cy_; } + bool altActive() const { return alt_.has_value(); } + bool bracketedPaste() const { return bracketedPaste_; } + bool appCursor() const { return appCursor_; } + const std::string& title() const { return title_; } + std::string text(int limit = 0) const; + + private: + struct Cell { + char32_t ch{U' '}; + bool written{false}; + }; + using Row = std::vector; + struct Saved { + int cx, cy; + }; + struct Alt { + std::vector grid; + int cx, cy, regionTop, regionBottom; + std::optional saved; + bool cursorVisible; + }; + + Cell blank() const; + Row blankRow() const; + void ground(char32_t c); + // Parsers return the next position, or npos when more input is needed. + size_t parseEsc(const std::u32string& buf, size_t pos, size_t end); + size_t parseCsi(const std::u32string& buf, size_t pos, size_t end); + size_t parseUntilSt(const std::u32string& buf, size_t pos, size_t end) const; + void osc(const std::u32string& body); + std::vector params(const std::u32string& body, int count, long long fallback = 1) const; + void dispatchCsi(const std::u32string& body, char32_t final); + bool inRegion() const { return regionTop_ <= cy_ && cy_ <= regionBottom_; } + void mode(const std::u32string& body, bool state); + void altScreen(bool state); + void lineFeed(); + std::pair margins() const; + bool insideMargins() const; + void scrollUp(int top, int bottom); + void scrollDown(int top, int bottom); + void eraseDisplay(long long mode); + void eraseLine(long long mode); + void insertLines(long long count); + void deleteLines(long long count); + void deleteChars(long long count); + void insertChars(long long count); + void eraseChars(long long count); + void clampCursor(); + void reset(); + void reply(const std::string& bytes) { replies_ += bytes; } + + int rows_, cols_; + std::vector grid_; + int cx_{0}, cy_{0}; + bool cursorVisible_{true}; + int regionTop_{0}, regionBottom_{0}; + bool lrMode_{false}; + int marginLeft_{0}, marginRight_{0}; + bool wrap_{false}; + std::optional saved_; + std::optional alt_; + std::u32string buf_; + std::string pendingBytes_; + bool appCursor_{false}; + bool bracketedPaste_{false}; + std::string title_; + std::string replies_; + bool graphics_{false}; + char32_t lastChar_{0}; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/wire_app.h b/taicpp/src/cap/wire_app.h new file mode 100644 index 0000000..dcbc0f5 --- /dev/null +++ b/taicpp/src/cap/wire_app.h @@ -0,0 +1,33 @@ +#pragma once +// WireApp: facts about the hosting application that CAP and MCP put on the +// wire (tai.py's VERSION and the audit/debug hooks). The default version is +// tai.py's VERSION at the time of the port; the integrator calls setVersion() +// at boot with the generated version of the C++ app. +#include +#include + +namespace detz::wire { + +class WireApp { + public: + static std::string version() { + std::lock_guard lock(mutex()); + return slot(); + } + static void setVersion(const std::string& value) { + std::lock_guard lock(mutex()); + slot() = value; + } + + private: + static std::string& slot() { + static std::string v = "1.44.2"; + return v; + } + static std::mutex& mutex() { + static std::mutex m; + return m; + } +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_argparse.cpp b/taicpp/src/cap/wire_argparse.cpp new file mode 100644 index 0000000..c8cdf4a --- /dev/null +++ b/taicpp/src/cap/wire_argparse.cpp @@ -0,0 +1,648 @@ +#include "wire_argparse.h" + +#include +#include + +#include +#include +#include + +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::wire { + +namespace { + +int terminalColumns() { + const char* env = ::getenv("COLUMNS"); + if (env && *env) { + char* end = nullptr; + long v = std::strtol(env, &end, 10); + if (end && *end == '\0' && v > 0) return static_cast(v); + } + struct winsize ws; + if (::ioctl(1, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) return ws.ws_col; + return 80; +} + +size_t cpLen(const std::string& s) { return WireText::length(s); } + +bool isLetterByte(unsigned char c) { return std::isalpha(c) || c == '_' || c >= 0x80; } + +// textwrap.TextWrapper._split with break_on_hyphens, for the text argparse +// hands over (whitespace already collapsed to single spaces). +std::vector wrapChunks(const std::string& text) { + std::vector chunks; + size_t i = 0; + while (i < text.size()) { + if (text[i] == ' ') { + size_t j = i; + while (j < text.size() && text[j] == ' ') ++j; + chunks.push_back(text.substr(i, j - i)); + i = j; + continue; + } + size_t j = i; + std::string word; + while (j < text.size() && text[j] != ' ') { + word += text[j]; + if (text[j] == '-' && j > i) { + // split after this hyphen when two letters (or letter-letter-) precede it + // and letter, optional hyphen, letter follow. + auto at = [&](long k) -> int { return (k >= 0 && k < static_cast(text.size())) ? static_cast(text[static_cast(k)]) : -1; }; + const long h = static_cast(j); + const bool before = (at(h - 1) >= 0 && isLetterByte(static_cast(at(h - 1))) && at(h - 2) >= 0 && + isLetterByte(static_cast(at(h - 2)))) || + (at(h - 1) >= 0 && isLetterByte(static_cast(at(h - 1))) && at(h - 2) == '-' && + at(h - 3) >= 0 && isLetterByte(static_cast(at(h - 3)))); + bool after = false; + if (at(h + 1) >= 0 && isLetterByte(static_cast(at(h + 1)))) { + long k = h + 2; + if (at(k) == '-') ++k; + after = at(k) >= 0 && isLetterByte(static_cast(at(k))); + } + if (before && after) { + chunks.push_back(word); + word.clear(); + } + } + ++j; + } + if (!word.empty()) chunks.push_back(word); + i = j; + } + return chunks; +} + +std::vector textwrapWrap(const std::string& text, int widthIn) { + std::vector chunks = wrapChunks(text); + std::reverse(chunks.begin(), chunks.end()); + std::vector lines; + while (!chunks.empty()) { + std::vector cur; + long curLen = 0; + const long width = widthIn; + if (WireText::strip(chunks.back()).empty() && !lines.empty()) chunks.pop_back(); + while (!chunks.empty()) { + const long l = static_cast(cpLen(chunks.back())); + if (curLen + l <= width) { + cur.push_back(chunks.back()); + chunks.pop_back(); + curLen += l; + } else { + break; + } + } + if (!chunks.empty() && static_cast(cpLen(chunks.back())) > width) { + // _handle_long_word + long spaceLeft = width < 1 ? 1 : width - curLen; + const std::string chunk = chunks.back(); + long end = spaceLeft; + if (static_cast(cpLen(chunk)) > spaceLeft) { + const size_t hyphen = chunk.substr(0, static_cast(spaceLeft)).rfind('-'); + if (hyphen != std::string::npos && hyphen > 0) { + bool any = false; + for (size_t k = 0; k < hyphen; ++k) + if (chunk[k] != '-') any = true; + if (any) end = static_cast(hyphen) + 1; + } + } + cur.push_back(WireText::head(chunk, static_cast(end))); + chunks.back() = WireText::slice(chunk, static_cast(end)); + curLen = 0; + for (const auto& c : cur) curLen += static_cast(cpLen(c)); + } + if (!cur.empty() && WireText::strip(cur.back()).empty()) { + curLen -= static_cast(cpLen(cur.back())); + cur.pop_back(); + } + if (!cur.empty()) lines.push_back(WireText::join(cur, "")); + } + return lines; +} + +std::string collapseWhitespace(const std::string& text) { + std::string out; + bool space = false; + for (unsigned char c : text) { + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v') { + space = true; + } else { + if (space) out += ' '; + space = false; + out += static_cast(c); + } + } + if (space) out += ' '; + return WireText::strip(out); +} + +std::string padRight(const std::string& s, size_t width) { + const size_t n = cpLen(s); + return n >= width ? s : s + std::string(width - n, ' '); +} + +} // namespace + +WireArgParse::WireArgParse(std::string prog) : prog_(std::move(prog)) { + Action help; + help.flags = {"-h", "--help"}; + help.dest = "help"; + help.kind = Kind::Help; + help.help = "show this help message and exit"; + actions_.push_back(help); +} + +void WireArgParse::addPositional(const std::string& dest, const std::string& help) { + Action a; + a.dest = dest; + a.help = help; + a.required = true; + actions_.push_back(a); +} + +void WireArgParse::addOption(const std::vector& flags, Kind kind, const Json& defaultValue, + const std::string& help, const std::string& metavar, bool required, + const std::vector& choices, bool hasChoices, bool isInt) { + Action a; + a.flags = flags; + // dest: first long flag without dashes, dashes to underscores + std::string base = flags[0]; + for (const auto& f : flags) + if (f.size() > 1 && f[0] == '-' && f[1] == '-') { + base = f; + break; + } + base = WireText::lstripChars(base, "-"); + a.dest = WireText::replaceAll(base, "-", "_"); + a.kind = kind; + a.defaultValue = defaultValue; + a.help = help; + a.metavar = metavar; + a.required = required; + a.choices = choices; + a.hasChoices = hasChoices; + a.isInt = isInt; + actions_.push_back(a); +} + +std::string WireArgParse::actionName(const Action& action) const { + if (!action.flags.empty()) return WireText::join(action.flags, "/"); + return action.metavar.empty() ? action.dest : action.metavar; +} + +namespace { + +struct Formatter { + int width; + int maxHelpPosition; + int currentIndent{0}; + int actionMaxLength{0}; + + explicit Formatter(int columns) { + width = (columns > 0 ? columns : terminalColumns()) - 2; + maxHelpPosition = std::min(24, std::max(width - 20, 4)); + } +}; + +std::string metavarFor(const WireArgParse::Action& a, bool positional) { + if (!a.metavar.empty()) return a.metavar; + if (a.hasChoices) return "{" + WireText::join(a.choices, ",") + "}"; + if (positional) return a.dest; + return WireText::upper(a.dest); +} + +std::string invocation(const WireArgParse::Action& a) { + if (a.flags.empty()) return metavarFor(a, true); + if (a.kind == WireArgParse::Kind::StoreTrue || a.kind == WireArgParse::Kind::Help) return WireText::join(a.flags, ", "); + return WireText::join(a.flags, ", ") + " " + metavarFor(a, false); +} + +std::string usagePart(const WireArgParse::Action& a) { + if (a.flags.empty()) return metavarFor(a, true); + std::string part; + if (a.kind == WireArgParse::Kind::StoreTrue || a.kind == WireArgParse::Kind::Help) part = a.flags[0]; + else part = a.flags[0] + " " + metavarFor(a, false); + return a.required ? part : "[" + part + "]"; +} + +} // namespace + +std::string WireArgParse::formatUsage(int columns) const { + Formatter f(columns); + const std::string prefix = "usage: "; + std::vector optParts, posParts; + for (const Action& a : actions_) + if (!a.flags.empty()) optParts.push_back(usagePart(a)); + for (const Action& a : actions_) + if (a.flags.empty()) posParts.push_back(usagePart(a)); + std::vector parts = optParts; + parts.insert(parts.end(), posParts.begin(), posParts.end()); + std::vector joined = {prog_}; + joined.insert(joined.end(), parts.begin(), parts.end()); + std::string usage = WireText::join(joined, " "); + const int textWidth = f.width - f.currentIndent; + if (static_cast(cpLen(prefix) + cpLen(usage)) > textWidth) { + auto getLines = [&](const std::vector& ps, const std::string& indent, bool withPrefix) { + std::vector lines, line; + const long indentLength = static_cast(indent.size()); + long lineLen = withPrefix ? static_cast(prefix.size()) - 1 : indentLength - 1; + for (const std::string& part : ps) { + const long partLen = static_cast(cpLen(part)); + if (lineLen + 1 + partLen > textWidth && !line.empty()) { + lines.push_back(indent + WireText::join(line, " ")); + line.clear(); + lineLen = indentLength - 1; + } + line.push_back(part); + lineLen += partLen + 1; + } + if (!line.empty()) lines.push_back(indent + WireText::join(line, " ")); + if (withPrefix && !lines.empty()) lines[0] = lines[0].substr(static_cast(indentLength)); + return lines; + }; + std::vector lines; + const long progLen = static_cast(cpLen(prog_)); + if (static_cast(prefix.size() + static_cast(progLen)) <= 0.75 * textWidth) { + const std::string indent(prefix.size() + static_cast(progLen) + 1, ' '); + if (!optParts.empty()) { + std::vector first = {prog_}; + first.insert(first.end(), optParts.begin(), optParts.end()); + lines = getLines(first, indent, true); + for (auto& l : getLines(posParts, indent, false)) lines.push_back(l); + } else if (!posParts.empty()) { + std::vector first = {prog_}; + first.insert(first.end(), posParts.begin(), posParts.end()); + lines = getLines(first, indent, true); + } else { + lines = {prog_}; + } + } else { + const std::string indent(prefix.size(), ' '); + lines = getLines(parts, indent, false); + if (lines.size() > 1) { + lines = getLines(optParts, indent, false); + for (auto& l : getLines(posParts, indent, false)) lines.push_back(l); + } + lines.insert(lines.begin(), prog_); + } + usage = WireText::join(lines, "\n"); + } + if (WireText::startsWith(usage, prog_)) usage = usage.substr(prog_.size()); + usage = prog_ + usage; + return prefix + usage + "\n"; +} + +std::string WireArgParse::formatHelp(int columns) const { + Formatter f(columns); + std::string out = formatUsage(columns) + "\n"; + // sections: positionals, then options + std::vector positionals, optionals; + for (const Action& a : actions_) (a.flags.empty() ? positionals : optionals).push_back(&a); + // _action_max_length: computed with the section indent (2) + const int indent = 2; + for (const Action& a : actions_) + f.actionMaxLength = std::max(f.actionMaxLength, static_cast(cpLen(invocation(a))) + indent); + auto section = [&](const std::string& title, const std::vector& items) { + if (items.empty()) return; + std::string body; + const int helpPosition = std::min(f.actionMaxLength + 2, f.maxHelpPosition); + const int helpWidth = std::max(f.width - helpPosition, 11); + const int actionWidth = helpPosition - indent - 2; + for (const Action* a : items) { + std::string header = invocation(*a); + const bool hasHelp = !WireText::strip(a->help).empty() || !a->help.empty(); + std::string text; + int indentFirst = 0; + if (a->help.empty()) { + body += std::string(static_cast(indent), ' ') + header + "\n"; + continue; + } else if (static_cast(cpLen(header)) <= actionWidth) { + text = std::string(static_cast(indent), ' ') + padRight(header, static_cast(actionWidth)) + " "; + indentFirst = 0; + } else { + text = std::string(static_cast(indent), ' ') + header + "\n"; + indentFirst = helpPosition; + } + (void)hasHelp; + body += text; + if (!WireText::strip(a->help).empty()) { + const std::vector helpLines = textwrapWrap(collapseWhitespace(a->help), helpWidth); + body += std::string(static_cast(indentFirst), ' ') + helpLines[0] + "\n"; + for (size_t k = 1; k < helpLines.size(); ++k) + body += std::string(static_cast(helpPosition), ' ') + helpLines[k] + "\n"; + } else if (!WireText::endsWith(text, "\n")) { + body += "\n"; + } + } + out += title + ":\n" + body + "\n"; + }; + section("positional arguments", positionals); + section("options", optionals); + // help = help.strip('\n') + '\n' + return WireText::stripChars(out, "\n") + "\n"; +} + +std::string WireArgParse::error(const std::string& message, int columns) const { + return formatUsage(columns) + prog_ + ": error: " + message + "\n"; +} + +// -------------------------------------------------------------------------- +// parsing + +struct WireArgParse::Parsed {}; + +Json WireArgParse::parseArgs(const std::vector& argv) const { + const int columns = 0; + auto fail = [&](const std::string& message) -> void { throw ArgParseExit(2, "", error(message, columns)); }; + auto argFail = [&](const Action& a, const std::string& message) -> void { + fail("argument " + actionName(a) + ": " + message); + }; + + // option string -> action index, insertion ordered + std::vector> optionActions; + for (size_t i = 0; i < actions_.size(); ++i) + for (const std::string& flag : actions_[i].flags) optionActions.emplace_back(flag, i); + auto findOption = [&](const std::string& s) -> const std::pair* { + for (const auto& entry : optionActions) + if (entry.first == s) return &entry; + return nullptr; + }; + + struct OptTuple { + bool known{false}; + size_t action{0}; + std::string optionString; + bool hasSep{false}; + std::string explicitArg; + bool hasExplicit{false}; + }; + + auto isNegativeNumber = [](const std::string& s) { + // ^-\d+$|^-\d*\.\d+$ + if (s.size() < 2 || s[0] != '-') return false; + size_t i = 1; + size_t digits = 0; + while (i < s.size() && std::isdigit(static_cast(s[i]))) { + ++i; + ++digits; + } + if (i == s.size()) return digits > 0; + if (s[i] != '.') return false; + ++i; + size_t frac = 0; + while (i < s.size() && std::isdigit(static_cast(s[i]))) { + ++i; + ++frac; + } + return i == s.size() && frac > 0; + }; + + // _parse_optional: returns false when the string is positional + auto parseOptional = [&](const std::string& arg, OptTuple& out) -> bool { + if (arg.empty() || arg[0] != '-') return false; + if (const auto* hit = findOption(arg)) { + out.known = true; + out.action = hit->second; + out.optionString = arg; + return true; + } + if (arg.size() == 1) return false; + const size_t eq = arg.find('='); + if (eq != std::string::npos) { + const std::string optionString = arg.substr(0, eq); + if (const auto* hit = findOption(optionString)) { + out.known = true; + out.action = hit->second; + out.optionString = optionString; + out.hasSep = true; + out.hasExplicit = true; + out.explicitArg = arg.substr(eq + 1); + return true; + } + } + // _get_option_tuples + std::vector tuples; + if (arg.size() >= 2 && arg[0] == '-' && arg[1] == '-') { + std::string prefix = arg; + std::string explicitArg; + bool sep = false; + if (eq != std::string::npos) { + prefix = arg.substr(0, eq); + explicitArg = arg.substr(eq + 1); + sep = true; + } + for (const auto& entry : optionActions) { + if (WireText::startsWith(entry.first, prefix)) { + OptTuple t; + t.known = true; + t.action = entry.second; + t.optionString = entry.first; + t.hasSep = sep; + t.hasExplicit = sep; + t.explicitArg = explicitArg; + tuples.push_back(t); + } + } + } else if (arg.size() >= 2 && arg[0] == '-' && arg[1] != '-') { + const std::string shortPrefix = arg.substr(0, 2); + const std::string shortExplicit = arg.substr(2); + for (const auto& entry : optionActions) { + if (entry.first == shortPrefix) { + OptTuple t; + t.known = true; + t.action = entry.second; + t.optionString = entry.first; + t.hasSep = true; + t.hasExplicit = true; + t.explicitArg = shortExplicit; + tuples.push_back(t); + } else if (WireText::startsWith(entry.first, arg)) { + OptTuple t; + t.known = true; + t.action = entry.second; + t.optionString = entry.first; + tuples.push_back(t); + } + } + } + if (tuples.size() > 1) { + std::vector names; + for (const auto& t : tuples) names.push_back(t.optionString); + fail("ambiguous option: " + arg + " could match " + WireText::join(names, ", ")); + } + if (tuples.size() == 1) { + out = tuples[0]; + return true; + } + if (isNegativeNumber(arg)) return false; // no negative-number-like options exist + if (arg.find(' ') != std::string::npos) return false; + out = OptTuple(); + out.known = false; + out.optionString = arg; + return true; + }; + + // classify + std::string pattern; + std::map optionIndices; + for (size_t i = 0; i < argv.size(); ++i) { + if (argv[i] == "--") { + pattern += '-'; + for (size_t k = i + 1; k < argv.size(); ++k) pattern += 'A'; + break; + } + OptTuple t; + if (parseOptional(argv[i], t)) { + pattern += 'O'; + optionIndices[i] = t; + } else { + pattern += 'A'; + } + } + + // namespace with defaults + Json ns = Json::object(); + for (const Action& a : actions_) { + if (a.kind == Kind::Help) continue; + Json def = a.defaultValue; + if (a.kind == Kind::StoreTrue) def = Json(false); + if (a.kind == Kind::Append) def = a.defaultValue.isArray() ? a.defaultValue : Json::array(); + ns.set(a.dest, def); + } + std::vector seen(actions_.size(), false); + std::vector extras; + + auto takeAction = [&](size_t idx, std::vector args) { + const Action& a = actions_[idx]; + seen[idx] = true; + if (a.kind == Kind::Help) throw ArgParseExit(0, formatHelp(columns), ""); + if (a.kind == Kind::StoreTrue) { + ns.set(a.dest, Json(true)); + return; + } + // remove one '--' + auto dd = std::find(args.begin(), args.end(), "--"); + if (dd != args.end()) args.erase(dd); + if (args.size() != 1) argFail(a, "expected one argument"); + const std::string& value = args[0]; + Json converted(value); + if (a.isInt) { + long long v = 0; + try { + size_t used = 0; + v = std::stoll(value, &used); + if (used != value.size()) throw std::invalid_argument("x"); + } catch (const std::exception&) { + argFail(a, "invalid int value: " + WireText::repr(value)); + } + converted = Json(v); + } + if (a.hasChoices) { + if (std::find(a.choices.begin(), a.choices.end(), value) == a.choices.end()) + argFail(a, "invalid choice: " + WireText::repr(value) + " (choose from " + WireText::join(a.choices, ", ") + ")"); + } + if (a.kind == Kind::Append) { + Json list = ns.get(a.dest, Json::array()); + if (!list.isArray()) list = Json::array(); + list.push(converted); + ns.set(a.dest, list); + } else { + ns.set(a.dest, converted); + } + }; + + // positionals still to fill (indexes into actions_) + std::vector positionals; + for (size_t i = 0; i < actions_.size(); ++i) + if (actions_[i].flags.empty()) positionals.push_back(i); + + // consume_positionals: each positional has nargs None -> pattern -*A-* + auto consumePositionals = [&](size_t start) -> size_t { + const std::string selected = pattern.substr(start); + // match_partial: as many leading positionals as the pattern satisfies + size_t pos = 0; + std::vector counts; + for (size_t k = 0; k < positionals.size(); ++k) { + size_t p = pos; + while (p < selected.size() && selected[p] == '-') ++p; + if (p >= selected.size() || selected[p] != 'A') break; + ++p; + while (p < selected.size() && selected[p] == '-') ++p; + counts.push_back(p - pos); + pos = p; + } + size_t at = start; + for (size_t k = 0; k < counts.size(); ++k) { + std::vector args(argv.begin() + static_cast(at), + argv.begin() + static_cast(at + counts[k])); + takeAction(positionals[k], args); + at += counts[k]; + } + positionals.erase(positionals.begin(), positionals.begin() + static_cast(counts.size())); + return at; + }; + + auto consumeOptional = [&](size_t start) -> size_t { + const OptTuple& t = optionIndices[start]; + if (!t.known) { + extras.push_back(argv[start]); + return start + 1; + } + const Action& a = actions_[t.action]; + const bool noArgs = a.kind == Kind::StoreTrue || a.kind == Kind::Help; + if (t.hasExplicit) { + if (noArgs && t.optionString.size() > 1 && t.optionString[1] != '-' && !t.explicitArg.empty()) { + // single-dash flag cluster: only -h exists, the rest is not an option. + const std::string next = std::string(1, t.optionString[0]) + t.explicitArg.substr(0, 1); + if (!findOption(next)) argFail(a, "ignored explicit argument " + WireText::repr(t.explicitArg)); + } + if (noArgs) argFail(a, "ignored explicit argument " + WireText::repr(t.explicitArg)); + takeAction(t.action, {t.explicitArg}); + return start + 1; + } + if (noArgs) { + takeAction(t.action, {}); + return start + 1; + } + // nargs None: the next string must be an 'A' + if (start + 1 >= pattern.size() || pattern[start + 1] != 'A') argFail(a, "expected one argument"); + takeAction(t.action, {argv[start + 1]}); + return start + 2; + }; + + size_t startIndex = 0; + const long maxOptionIndex = optionIndices.empty() ? -1 : static_cast(optionIndices.rbegin()->first); + while (static_cast(startIndex) <= maxOptionIndex) { + size_t nextOption = startIndex; + while (static_cast(nextOption) <= maxOptionIndex) { + if (optionIndices.count(nextOption)) break; + ++nextOption; + } + if (startIndex != nextOption) { + const size_t end = consumePositionals(startIndex); + if (end > startIndex) { + startIndex = end; + continue; + } + startIndex = end; + } + if (!optionIndices.count(startIndex)) { + for (size_t k = startIndex; k < nextOption; ++k) extras.push_back(argv[k]); + startIndex = nextOption; + } + startIndex = consumeOptional(startIndex); + } + const size_t stop = consumePositionals(startIndex); + for (size_t k = stop; k < argv.size(); ++k) extras.push_back(argv[k]); + + std::vector required; + for (size_t i = 0; i < actions_.size(); ++i) + if (!seen[i] && actions_[i].required) required.push_back(actionName(actions_[i])); + if (!required.empty()) fail("the following arguments are required: " + WireText::join(required, ", ")); + if (!extras.empty()) fail("unrecognized arguments: " + WireText::join(extras, " ")); + return ns; +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_argparse.h b/taicpp/src/cap/wire_argparse.h new file mode 100644 index 0000000..dc1f72c --- /dev/null +++ b/taicpp/src/cap/wire_argparse.h @@ -0,0 +1,66 @@ +#pragma once +// WireArgParse: the part of Python's argparse the `--cap` sub-commands use, +// ported from CPython 3.14: option/positional classification, abbreviations, +// `--`, store / store_true / append / help actions, choices, required options +// and the exact usage, help and error texts (including HelpFormatter's line +// wrapping at COLUMNS). Color output of 3.14 is not produced. +#include +#include +#include + +#include "wire_json.h" + +namespace detz::wire { + +// SystemExit raised by parse_args: carries the exit code and what argparse +// printed (help on stdout, usage + error on stderr). +class ArgParseExit : public std::runtime_error { + public: + ArgParseExit(int code, std::string out, std::string err) + : std::runtime_error("SystemExit"), exitCode(code), stdoutText(std::move(out)), stderrText(std::move(err)) {} + int exitCode; + std::string stdoutText; + std::string stderrText; +}; + +class WireArgParse { + public: + enum class Kind { Store, StoreTrue, Append, Help }; + struct Action { + std::vector flags; // empty for a positional + std::string dest; + Kind kind{Kind::Store}; + std::string metavar; + std::vector choices; + bool hasChoices{false}; + std::string help; + bool required{false}; + bool isInt{false}; + Json defaultValue; + }; + + explicit WireArgParse(std::string prog); + + // add_argument("name", help=...) + void addPositional(const std::string& dest, const std::string& help = ""); + // add_argument("--flag", ...) with the supported keyword subset. + void addOption(const std::vector& flags, Kind kind, const Json& defaultValue = Json(), + const std::string& help = "", const std::string& metavar = "", bool required = false, + const std::vector& choices = {}, bool hasChoices = false, bool isInt = false); + + // parse_args(argv): returns the namespace as an object; throws ArgParseExit. + Json parseArgs(const std::vector& argv) const; + std::string formatUsage(int columns = 0) const; + std::string formatHelp(int columns = 0) const; + const std::string& prog() const { return prog_; } + + private: + struct Parsed; + std::string actionName(const Action& action) const; + std::string error(const std::string& message, int columns) const; + + std::string prog_; + std::vector actions_; +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_difflib.cpp b/taicpp/src/cap/wire_difflib.cpp new file mode 100644 index 0000000..8a6b9dd --- /dev/null +++ b/taicpp/src/cap/wire_difflib.cpp @@ -0,0 +1,184 @@ +#include "wire_difflib.h" + +#include +#include +#include + +namespace detz::wire { + +WireDifflib::WireDifflib(const std::vector& a, const std::vector& b) : a_(a), b_(b) { + // __chain_b: b2j with the autojunk "popular" removal. + std::unordered_map slot; + std::vector order; + for (size_t i = 0; i < b_.size(); ++i) { + auto it = slot.find(b_[i]); + if (it == slot.end()) { + it = slot.emplace(b_[i], static_cast(b2jLists_.size())).first; + b2jLists_.emplace_back(); + order.push_back(b_[i]); + } + b2jLists_[static_cast(it->second)].push_back(static_cast(i)); + } + const int n = static_cast(b_.size()); + std::unordered_set popular; + if (n >= 200) { + const int ntest = n / 100 + 1; + for (const auto& entry : slot) + if (static_cast(b2jLists_[static_cast(entry.second)].size()) > ntest) popular.insert(entry.first); + } + aIndex_.assign(a_.size(), -1); + for (size_t i = 0; i < a_.size(); ++i) { + auto it = slot.find(a_[i]); + if (it != slot.end() && !popular.count(a_[i])) aIndex_[i] = it->second; + } +} + +WireDifflib::Block WireDifflib::findLongestMatch(int alo, int ahi, int blo, int bhi) const { + int besti = alo, bestj = blo, bestsize = 0; + std::unordered_map j2len; + for (int i = alo; i < ahi; ++i) { + std::unordered_map newj2len; + const int idx = aIndex_[static_cast(i)]; + if (idx >= 0) { + for (int j : b2jLists_[static_cast(idx)]) { + if (j < blo) continue; + if (j >= bhi) break; + auto prev = j2len.find(j - 1); + const int k = (prev == j2len.end() ? 0 : prev->second) + 1; + newj2len[j] = k; + if (k > bestsize) { + besti = i - k + 1; + bestj = j - k + 1; + bestsize = k; + } + } + } + j2len.swap(newj2len); + } + // No junk (isjunk=None): only the plain extension loops apply. + while (besti > alo && bestj > blo && a_[static_cast(besti - 1)] == b_[static_cast(bestj - 1)]) { + --besti; + --bestj; + ++bestsize; + } + while (besti + bestsize < ahi && bestj + bestsize < bhi && + a_[static_cast(besti + bestsize)] == b_[static_cast(bestj + bestsize)]) + ++bestsize; + return {besti, bestj, bestsize}; +} + +std::vector WireDifflib::getMatchingBlocks() { + const int la = static_cast(a_.size()), lb = static_cast(b_.size()); + std::vector> queue = {{0, la, 0, lb}}; + std::vector matching; + while (!queue.empty()) { + auto [alo, ahi, blo, bhi] = queue.back(); + queue.pop_back(); + Block x = findLongestMatch(alo, ahi, blo, bhi); + const auto [i, j, k] = x; + if (k) { + matching.push_back(x); + if (alo < i && blo < j) queue.emplace_back(alo, i, blo, j); + if (i + k < ahi && j + k < bhi) queue.emplace_back(i + k, ahi, j + k, bhi); + } + } + std::sort(matching.begin(), matching.end()); + int i1 = 0, j1 = 0, k1 = 0; + std::vector nonAdjacent; + for (const auto& [i2, j2, k2] : matching) { + if (i1 + k1 == i2 && j1 + k1 == j2) { + k1 += k2; + } else { + if (k1) nonAdjacent.emplace_back(i1, j1, k1); + i1 = i2; + j1 = j2; + k1 = k2; + } + } + if (k1) nonAdjacent.emplace_back(i1, j1, k1); + nonAdjacent.emplace_back(la, lb, 0); + return nonAdjacent; +} + +std::vector WireDifflib::getOpcodes() { + int i = 0, j = 0; + std::vector answer; + for (const auto& [ai, bj, size] : getMatchingBlocks()) { + std::string tag; + if (i < ai && j < bj) tag = "replace"; + else if (i < ai) tag = "delete"; + else if (j < bj) tag = "insert"; + if (!tag.empty()) answer.emplace_back(tag, i, ai, j, bj); + i = ai + size; + j = bj + size; + if (size) answer.emplace_back("equal", ai, i, bj, j); + } + return answer; +} + +std::vector> WireDifflib::getGroupedOpcodes(int n) { + std::vector codes = getOpcodes(); + if (codes.empty()) codes.emplace_back("equal", 0, 1, 0, 1); + if (std::get<0>(codes.front()) == "equal") { + auto [tag, i1, i2, j1, j2] = codes.front(); + codes.front() = {tag, std::max(i1, i2 - n), i2, std::max(j1, j2 - n), j2}; + } + if (std::get<0>(codes.back()) == "equal") { + auto [tag, i1, i2, j1, j2] = codes.back(); + codes.back() = {tag, i1, std::min(i2, i1 + n), j1, std::min(j2, j1 + n)}; + } + const int nn = n + n; + std::vector> groups; + std::vector group; + for (auto [tag, i1, i2, j1, j2] : codes) { + if (tag == "equal" && i2 - i1 > nn) { + group.emplace_back(tag, i1, std::min(i2, i1 + n), j1, std::min(j2, j1 + n)); + groups.push_back(group); + group.clear(); + i1 = std::max(i1, i2 - n); + j1 = std::max(j1, j2 - n); + } + group.emplace_back(tag, i1, i2, j1, j2); + } + if (!group.empty() && !(group.size() == 1 && std::get<0>(group[0]) == "equal")) groups.push_back(group); + return groups; +} + +static std::string formatRange(int start, int stop) { + int beginning = start + 1; + const int length = stop - start; + if (length == 1) return std::to_string(beginning); + if (!length) --beginning; + return std::to_string(beginning) + "," + std::to_string(length); +} + +std::string WireDifflib::unifiedDiff(const std::vector& a, const std::vector& b, + const std::string& fromfile, const std::string& tofile) { + WireDifflib matcher(a, b); + std::string out; + bool started = false; + for (const auto& group : matcher.getGroupedOpcodes(3)) { + if (!started) { + started = true; + out += "--- " + fromfile + "\n"; + out += "+++ " + tofile + "\n"; + } + const auto& first = group.front(); + const auto& last = group.back(); + out += "@@ -" + formatRange(std::get<1>(first), std::get<2>(last)) + " +" + + formatRange(std::get<3>(first), std::get<4>(last)) + " @@\n"; + for (const auto& [tag, i1, i2, j1, j2] : group) { + if (tag == "equal") { + for (int k = i1; k < i2; ++k) out += " " + a[static_cast(k)]; + continue; + } + if (tag == "replace" || tag == "delete") + for (int k = i1; k < i2; ++k) out += "-" + a[static_cast(k)]; + if (tag == "replace" || tag == "insert") + for (int k = j1; k < j2; ++k) out += "+" + b[static_cast(k)]; + } + } + return out; +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_difflib.h b/taicpp/src/cap/wire_difflib.h new file mode 100644 index 0000000..6e3a08e --- /dev/null +++ b/taicpp/src/cap/wire_difflib.h @@ -0,0 +1,34 @@ +#pragma once +// WireDifflib: difflib.SequenceMatcher + unified_diff, ported from CPython so +// the no-git diff preview of the --cap-speak path is byte-identical. +#include +#include +#include + +namespace detz::wire { + +class WireDifflib { + public: + using Opcode = std::tuple; + using Block = std::tuple; + + // SequenceMatcher(None, a, b) with autojunk=True. + WireDifflib(const std::vector& a, const std::vector& b); + + std::vector getMatchingBlocks(); + std::vector getOpcodes(); + std::vector> getGroupedOpcodes(int n = 3); + + // difflib.unified_diff(a, b, fromfile, tofile, n=3, lineterm="\n"), joined. + static std::string unifiedDiff(const std::vector& a, const std::vector& b, + const std::string& fromfile, const std::string& tofile); + + private: + Block findLongestMatch(int alo, int ahi, int blo, int bhi) const; + + std::vector a_, b_; + std::vector> b2jLists_; + std::vector aIndex_; // per a element: index into b2jLists_ or -1 +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_hash.cpp b/taicpp/src/cap/wire_hash.cpp new file mode 100644 index 0000000..06e4bb0 --- /dev/null +++ b/taicpp/src/cap/wire_hash.cpp @@ -0,0 +1,260 @@ +#include "wire_hash.h" + +#include +#include + +#include +#include +#include +#include + +namespace detz::wire { + +namespace { + +inline uint32_t rotl(uint32_t v, int n) { return (v << n) | (v >> (32 - n)); } +inline uint32_t rotr(uint32_t v, int n) { return (v >> n) | (v << (32 - n)); } + +std::string pad(const std::string& data, bool bigEndianLength) { + std::string msg = data; + msg += static_cast(0x80); + while (msg.size() % 64 != 56) msg += '\0'; + const uint64_t bits = static_cast(data.size()) * 8; + for (int i = 7; i >= 0; --i) msg += static_cast((bits >> (i * 8)) & 0xFF); + (void)bigEndianLength; + return msg; +} + +} // namespace + +std::string WireHash::hex(const std::string& raw) { + static const char* digits = "0123456789abcdef"; + std::string out; + out.reserve(raw.size() * 2); + for (unsigned char c : raw) { + out += digits[c >> 4]; + out += digits[c & 15]; + } + return out; +} + +std::string WireHash::sha1Hex(const std::string& data) { + uint32_t h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0; + const std::string msg = pad(data, true); + for (size_t off = 0; off < msg.size(); off += 64) { + uint32_t w[80]; + for (int i = 0; i < 16; ++i) { + w[i] = (static_cast(static_cast(msg[off + i * 4])) << 24) | + (static_cast(static_cast(msg[off + i * 4 + 1])) << 16) | + (static_cast(static_cast(msg[off + i * 4 + 2])) << 8) | + static_cast(static_cast(msg[off + i * 4 + 3])); + } + for (int i = 16; i < 80; ++i) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + uint32_t a = h0, b = h1, c = h2, d = h3, e = h4; + for (int i = 0; i < 80; ++i) { + uint32_t f, k; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5A827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + const uint32_t temp = rotl(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = rotl(b, 30); + b = a; + a = temp; + } + h0 += a; + h1 += b; + h2 += c; + h3 += d; + h4 += e; + } + std::string raw; + for (uint32_t h : {h0, h1, h2, h3, h4}) + for (int i = 3; i >= 0; --i) raw += static_cast((h >> (i * 8)) & 0xFF); + return hex(raw); +} + +std::string WireHash::sha256Raw(const std::string& data) { + static const uint32_t K[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; + uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + const std::string msg = pad(data, true); + for (size_t off = 0; off < msg.size(); off += 64) { + uint32_t w[64]; + for (int i = 0; i < 16; ++i) { + w[i] = (static_cast(static_cast(msg[off + i * 4])) << 24) | + (static_cast(static_cast(msg[off + i * 4 + 1])) << 16) | + (static_cast(static_cast(msg[off + i * 4 + 2])) << 8) | + static_cast(static_cast(msg[off + i * 4 + 3])); + } + for (int i = 16; i < 64; ++i) { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7]; + for (int i = 0; i < 64; ++i) { + const uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const uint32_t ch = (e & f) ^ (~e & g); + const uint32_t t1 = hh + S1 + ch + K[i] + w[i]; + const uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const uint32_t t2 = S0 + maj; + hh = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh; + } + std::string raw; + for (uint32_t v : h) + for (int i = 3; i >= 0; --i) raw += static_cast((v >> (i * 8)) & 0xFF); + return raw; +} + +std::string WireHash::sha256Hex(const std::string& data) { return hex(sha256Raw(data)); } + +static std::string b64With(const std::string& raw, const char* alphabet) { + std::string out; + size_t i = 0; + while (i + 2 < raw.size()) { + const uint32_t v = (static_cast(raw[i]) << 16) | (static_cast(raw[i + 1]) << 8) | + static_cast(raw[i + 2]); + out += alphabet[(v >> 18) & 63]; + out += alphabet[(v >> 12) & 63]; + out += alphabet[(v >> 6) & 63]; + out += alphabet[v & 63]; + i += 3; + } + if (i + 1 == raw.size()) { + const uint32_t v = static_cast(raw[i]) << 16; + out += alphabet[(v >> 18) & 63]; + out += alphabet[(v >> 12) & 63]; + out += "=="; + } else if (i + 2 == raw.size()) { + const uint32_t v = (static_cast(raw[i]) << 16) | (static_cast(raw[i + 1]) << 8); + out += alphabet[(v >> 18) & 63]; + out += alphabet[(v >> 12) & 63]; + out += alphabet[(v >> 6) & 63]; + out += '='; + } + return out; +} + +std::string WireHash::b64encode(const std::string& raw) { + return b64With(raw, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"); +} + +std::string WireHash::urlsafeB64encode(const std::string& raw) { + return b64With(raw, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"); +} + +bool WireHash::b64decode(const std::string& text, std::string& out) { + // binascii.a2b_base64 semantics: non-alphabet bytes are skipped, '=' padding + // ends a quad, incomplete quads raise. + std::string result; + uint32_t acc = 0; + int bits = 0, quad = 0, pad = 0; + for (unsigned char c : text) { + int v; + if (c >= 'A' && c <= 'Z') v = c - 'A'; + else if (c >= 'a' && c <= 'z') v = c - 'a' + 26; + else if (c >= '0' && c <= '9') v = c - '0' + 52; + else if (c == '+') v = 62; + else if (c == '/') v = 63; + else if (c == '=') { + if (quad >= 2) { + ++pad; + ++quad; + if (quad == 4) { + quad = 0; + acc = 0; + bits = 0; + pad = 0; + } + } + continue; + } else continue; + if (pad) continue; + acc = (acc << 6) | static_cast(v); + bits += 6; + ++quad; + if (bits >= 8) { + bits -= 8; + result += static_cast((acc >> bits) & 0xFF); + } + if (quad == 4) { + quad = 0; + acc = 0; + bits = 0; + } + } + if (quad != 0) return false; + out = result; + return true; +} + +std::string WireHash::randomBytes(size_t count) { + std::string out(count, '\0'); + int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC); + size_t got = 0; + if (fd >= 0) { + while (got < count) { + ssize_t n = ::read(fd, &out[got], count - got); + if (n <= 0) break; + got += static_cast(n); + } + ::close(fd); + } + if (got < count) throw std::runtime_error("no entropy source"); + return out; +} + +std::string WireHash::tokenUrlsafe(size_t nbytes) { + std::string enc = urlsafeB64encode(randomBytes(nbytes)); + while (!enc.empty() && enc.back() == '=') enc.pop_back(); + return enc; +} + +std::string WireHash::uuid4Hex() { + std::string raw = randomBytes(16); + raw[6] = static_cast((static_cast(raw[6]) & 0x0F) | 0x40); + raw[8] = static_cast((static_cast(raw[8]) & 0x3F) | 0x80); + return hex(raw); +} + +bool WireHash::compareDigest(const std::string& a, const std::string& b) { + for (unsigned char c : a) + if (c >= 0x80) throw std::invalid_argument("comparing strings with non-ASCII characters is not supported"); + for (unsigned char c : b) + if (c >= 0x80) throw std::invalid_argument("comparing strings with non-ASCII characters is not supported"); + unsigned diff = a.size() == b.size() ? 0 : 1; + const size_t n = a.size() < b.size() ? a.size() : b.size(); + for (size_t i = 0; i < n; ++i) diff |= static_cast(a[i]) ^ static_cast(b[i]); + return diff == 0; +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_hash.h b/taicpp/src/cap/wire_hash.h new file mode 100644 index 0000000..8e062b3 --- /dev/null +++ b/taicpp/src/cap/wire_hash.h @@ -0,0 +1,31 @@ +#pragma once +// WireHash: the digest, encoding and randomness primitives tai.py takes from +// hashlib, base64, secrets, uuid and hmac. No dependency beyond libc. +#include + +namespace detz::wire { + +class WireHash { + public: + // hashlib.sha1(data).hexdigest() / sha256. + static std::string sha1Hex(const std::string& data); + static std::string sha256Hex(const std::string& data); + // hashlib.sha256(data).digest() as raw bytes. + static std::string sha256Raw(const std::string& data); + static std::string hex(const std::string& raw); + // base64.b64encode / urlsafe_b64encode. + static std::string b64encode(const std::string& raw); + static std::string urlsafeB64encode(const std::string& raw); + // base64.b64decode(text) with validate=False. False on binascii.Error. + static bool b64decode(const std::string& text, std::string& out); + // os.urandom. + static std::string randomBytes(size_t count); + // secrets.token_urlsafe(nbytes). + static std::string tokenUrlsafe(size_t nbytes = 32); + // uuid.uuid4().hex + static std::string uuid4Hex(); + // hmac.compare_digest on str values (constant time over the longer). + static bool compareDigest(const std::string& a, const std::string& b); +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_http.cpp b/taicpp/src/cap/wire_http.cpp new file mode 100644 index 0000000..d089a15 --- /dev/null +++ b/taicpp/src/cap/wire_http.cpp @@ -0,0 +1,845 @@ +#include "wire_http.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::wire { + +std::string HttpError::header(const std::string& name) const { + auto it = headers.find(WireText::lower(name)); + return it == headers.end() ? "" : it->second; +} + +std::string HttpReply::header(const std::string& name) const { + auto it = headers.find(WireText::lower(name)); + return it == headers.end() ? "" : it->second; +} + +namespace { + +void curlInit() { + static std::once_flag once; + std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); +} + +struct Transfer { + std::string body; + size_t limit{0}; // 0 = unlimited, else keep at most limit+1 bytes + bool truncated{false}; + std::map headers; + std::function sink; // streaming mode + std::atomic* abort{nullptr}; + std::function onHeadersDone; + bool headersSeen{false}; + int httpStatus{0}; // from the status line of the response being read +}; + +size_t writeCb(char* ptr, size_t size, size_t nmemb, void* user) { + auto* t = static_cast(user); + const size_t n = size * nmemb; + if (t->abort && t->abort->load()) return 0; + if (t->sink) { + t->sink(ptr, n); + return n; + } + if (t->limit == 0) { + t->body.append(ptr, n); + return n; + } + const size_t room = t->limit + 1 - std::min(t->limit + 1, t->body.size()); + if (n > room) { + t->body.append(ptr, room); + t->truncated = true; + return 0; // stop the download; reading stops like response.read(limit + 1) + } + t->body.append(ptr, n); + return n; +} + +size_t headerCb(char* ptr, size_t size, size_t nmemb, void* user) { + auto* t = static_cast(user); + const size_t n = size * nmemb; + std::string line(ptr, n); + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + if (WireText::startsWith(line, "HTTP/")) { + t->headers.clear(); + t->headersSeen = false; + const size_t sp = line.find(' '); + t->httpStatus = sp == std::string::npos ? 0 : std::atoi(line.c_str() + sp + 1); + return n; + } + if (line.empty()) { + if (!t->headersSeen) { + t->headersSeen = true; + if (t->onHeadersDone) t->onHeadersDone(); + } + return n; + } + const size_t colon = line.find(':'); + if (colon != std::string::npos) { + std::string key = WireText::lower(line.substr(0, colon)); + std::string val = WireText::strip(line.substr(colon + 1)); + t->headers[key] = val; + } + return n; +} + +int xferCb(void* user, curl_off_t, curl_off_t, curl_off_t, curl_off_t) { + auto* t = static_cast(user); + return (t->abort && t->abort->load()) ? 1 : 0; +} + +std::string transportText(CURL* handle, CURLcode rc, bool urllibStyle) { + long osErr = 0; + curl_easy_getinfo(handle, CURLINFO_OS_ERRNO, &osErr); + double connectTime = 0; + curl_easy_getinfo(handle, CURLINFO_CONNECT_TIME, &connectTime); + std::string core; + bool wrap = urllibStyle; + switch (rc) { + case CURLE_COULDNT_RESOLVE_HOST: + case CURLE_COULDNT_RESOLVE_PROXY: + core = "[Errno -2] Name or service not known"; + break; + case CURLE_COULDNT_CONNECT: + core = osErr ? WireOs::errnoText(static_cast(osErr)) : "[Errno 111] Connection refused"; + break; + case CURLE_OPERATION_TIMEDOUT: + core = "timed out"; + wrap = urllibStyle && connectTime <= 0.0; + break; + case CURLE_UNSUPPORTED_PROTOCOL: + return urllibStyle ? "" + : curl_easy_strerror(rc); + case CURLE_RECV_ERROR: + case CURLE_GOT_NOTHING: + core = osErr ? WireOs::errnoText(static_cast(osErr)) : "Remote end closed connection without response"; + wrap = false; + break; + default: + core = curl_easy_strerror(rc); + break; + } + return wrap ? "" : core; +} + +struct CurlHandle { + CURL* h{nullptr}; + curl_slist* list{nullptr}; + CurlHandle() { h = curl_easy_init(); } + ~CurlHandle() { + if (list) curl_slist_free_all(list); + if (h) curl_easy_cleanup(h); + } +}; + +bool hasHeader(const HeaderList& headers, const std::string& name) { + const std::string want = WireText::lower(name); + for (const auto& h : headers) + if (WireText::lower(h.first) == want) return true; + return false; +} + +// Configures and performs one request; status/headers/body land in `out`. +// Throws UrlError on transport failure. +void performRequest(const std::string& method, const std::string& url, const HeaderList& headers, + const std::string* body, double timeout, Transfer& transfer, bool urllibStyle, int& statusOut) { + curlInit(); + CurlHandle ch; + if (!ch.h) throw UrlError("cannot start http client"); + CURL* c = ch.h; + curl_easy_setopt(c, CURLOPT_URL, url.c_str()); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, writeCb); + curl_easy_setopt(c, CURLOPT_WRITEDATA, &transfer); + curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, headerCb); + curl_easy_setopt(c, CURLOPT_HEADERDATA, &transfer); + curl_easy_setopt(c, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(c, CURLOPT_XFERINFOFUNCTION, xferCb); + curl_easy_setopt(c, CURLOPT_XFERINFODATA, &transfer); + const long secs = std::max(1L, static_cast(timeout)); + curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, secs); + curl_easy_setopt(c, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(c, CURLOPT_LOW_SPEED_TIME, secs); + curl_easy_setopt(c, CURLOPT_ACCEPT_ENCODING, nullptr); + if (method == "GET") { + curl_easy_setopt(c, CURLOPT_HTTPGET, 1L); + } else if (method == "POST") { + curl_easy_setopt(c, CURLOPT_POST, 1L); + curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body->data() : ""); + curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(body ? body->size() : 0)); + } else { + curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, method.c_str()); + if (body) { + curl_easy_setopt(c, CURLOPT_POSTFIELDS, body->data()); + curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(body->size())); + } + } + for (const auto& h : headers) ch.list = curl_slist_append(ch.list, (h.first + ": " + h.second).c_str()); + // Suppress the headers curl adds on its own so the wire matches Python's. + if (!hasHeader(headers, "Accept")) ch.list = curl_slist_append(ch.list, "Accept:"); + if (!hasHeader(headers, "Expect")) ch.list = curl_slist_append(ch.list, "Expect:"); + if (method == "POST" && !hasHeader(headers, "Content-Type")) ch.list = curl_slist_append(ch.list, "Content-Type:"); + if (urllibStyle) { + if (!hasHeader(headers, "User-Agent")) ch.list = curl_slist_append(ch.list, "User-Agent: Python-urllib/3.14"); + } else if (!hasHeader(headers, "User-Agent")) { + ch.list = curl_slist_append(ch.list, "User-Agent:"); + } + curl_easy_setopt(c, CURLOPT_HTTPHEADER, ch.list); + const CURLcode rc = curl_easy_perform(c); + long code = 0; + curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code); + statusOut = static_cast(code); + if (rc == CURLE_WRITE_ERROR && transfer.truncated) return; + if (rc == CURLE_ABORTED_BY_CALLBACK || (rc == CURLE_WRITE_ERROR && transfer.abort && transfer.abort->load())) return; + if (rc != CURLE_OK) throw UrlError(transportText(c, rc, urllibStyle)); +} + +std::string reason(int code) { + switch (code) { + case 100: return "Continue"; + case 200: return "OK"; + case 201: return "Created"; + case 202: return "Accepted"; + case 204: return "No Content"; + case 301: return "Moved Permanently"; + case 302: return "Found"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 408: return "Request Timeout"; + case 411: return "Length Required"; + case 413: return "Content Too Large"; + case 414: return "URI Too Long"; + case 431: return "Request Header Fields Too Large"; + case 500: return "Internal Server Error"; + case 501: return "Not Implemented"; + case 505: return "HTTP Version Not Supported"; + default: return ""; + } +} + +std::string explain(int code) { + switch (code) { + case 400: return "Bad request syntax or unsupported method"; + case 404: return "Nothing matches the given URI"; + case 405: return "Specified method is invalid for this resource"; + case 413: return "Entity body too large"; + case 414: return "URI is too long"; + case 431: return "The server is unable to process the request because the header fields are too large"; + case 501: return "Server does not support this operation"; + case 505: return "Cannot fulfill request"; + default: return ""; + } +} + +std::string htmlEscape(const std::string& s) { + std::string out; + for (char c : s) { + if (c == '&') out += "&"; + else if (c == '<') out += "<"; + else if (c == '>') out += ">"; + else out += c; + } + return out; +} + +} // namespace + +// -------------------------------------------------------------------------- +// URL helpers + +WireHttp::Url WireHttp::split(const std::string& url) { + Url out; + std::string rest = url; + const size_t hash = rest.find('#'); + if (hash != std::string::npos) { + out.fragment = rest.substr(hash + 1); + rest.resize(hash); + } + const size_t colon = rest.find(':'); + if (colon != std::string::npos && colon > 0) { + bool ok = std::isalpha(static_cast(rest[0])); + for (size_t i = 0; i < colon && ok; ++i) + if (!(std::isalnum(static_cast(rest[i])) || rest[i] == '+' || rest[i] == '-' || rest[i] == '.')) ok = false; + if (ok) { + out.scheme = WireText::lower(rest.substr(0, colon)); + rest = rest.substr(colon + 1); + } + } + if (WireText::startsWith(rest, "//")) { + size_t end = rest.find_first_of("/?", 2); + out.netloc = rest.substr(2, end == std::string::npos ? std::string::npos : end - 2); + rest = end == std::string::npos ? "" : rest.substr(end); + } + const size_t q = rest.find('?'); + if (q != std::string::npos) { + out.query = rest.substr(q + 1); + rest.resize(q); + } + out.path = rest; + // hostname / port + std::string hostPort = out.netloc; + const size_t at = hostPort.rfind('@'); + if (at != std::string::npos) hostPort = hostPort.substr(at + 1); + std::string host = hostPort; + if (!host.empty() && host[0] == '[') { + const size_t close = host.find(']'); + if (close != std::string::npos) { + std::string tail = host.substr(close + 1); + host = host.substr(1, close - 1); + if (WireText::startsWith(tail, ":") && WireText::isDigits(tail.substr(1))) out.port = std::atoi(tail.c_str() + 1); + } + } else { + const size_t pc = host.rfind(':'); + if (pc != std::string::npos) { + const std::string p = host.substr(pc + 1); + if (WireText::isDigits(p)) out.port = std::atoi(p.c_str()); + host = host.substr(0, pc); + } + } + out.hostname = WireText::lower(host); + return out; +} + +std::string WireHttp::unsplit(const Url& url) { + std::string out; + if (!url.scheme.empty()) out += url.scheme + ":"; + if (!url.netloc.empty() || url.scheme == "http" || url.scheme == "https") out += "//" + url.netloc; + out += url.path; + if (!url.query.empty()) out += "?" + url.query; + if (!url.fragment.empty()) out += "#" + url.fragment; + return out; +} + +std::string WireHttp::urljoin(const std::string& base, const std::string& ref) { + if (base.empty()) return ref; + if (ref.empty()) return base; + Url b = split(base); + Url r = split(ref); + if (!r.scheme.empty() && r.scheme != b.scheme) return ref; + Url out = b; + out.fragment = r.fragment; + if (!r.netloc.empty()) { + out.netloc = r.netloc; + out.path = r.path; + out.query = r.query; + return unsplit(out); + } + if (r.path.empty()) { + if (!r.query.empty()) out.query = r.query; + return unsplit(out); + } + std::string path; + if (r.path[0] == '/') { + path = r.path; + } else { + const size_t slash = b.path.rfind('/'); + path = (slash == std::string::npos ? std::string("/") : b.path.substr(0, slash + 1)) + r.path; + } + // resolve dot segments + std::vector segs; + for (const std::string& piece : WireText::split(path, "/")) { + if (piece == ".") continue; + if (piece == "..") { + if (segs.size() > 1) segs.pop_back(); + continue; + } + segs.push_back(piece); + } + out.path = WireText::join(segs, "/"); + if (!WireText::endsWith(out.path, "/") && (WireText::endsWith(r.path, "/.") || WireText::endsWith(r.path, "/.."))) out.path += "/"; + out.query = r.query; + return unsplit(out); +} + +// -------------------------------------------------------------------------- +// WireHttp + +HttpReply WireHttp::exchange(const std::string& method, const std::string& url, const HeaderList& headers, + const std::string* body, double timeout) { + Transfer t; + int status = 0; + performRequest(method, url, headers, body, timeout, t, false, status); + HttpReply reply; + reply.status = status; + reply.headers = t.headers; + reply.body = t.body; + reply.truncated = t.truncated; + return reply; +} + +HttpReply WireHttp::urlopen(const std::string& method, const std::string& url, const HeaderList& headers, + const std::string* body, double timeout, size_t maxBody) { + std::string curUrl = url; + std::string curMethod = method; + HeaderList curHeaders = headers; + const std::string* curBody = body; + int redirections = 0; + std::vector visited; + while (true) { + Transfer t; + t.limit = maxBody; + int status = 0; + performRequest(curMethod, curUrl, curHeaders, curBody, timeout, t, true, status); + if (status >= 200 && status < 300) { + HttpReply reply; + reply.status = status; + reply.headers = t.headers; + reply.body = t.body; + reply.truncated = t.truncated; + if (maxBody && reply.body.size() > maxBody + 1) reply.body.resize(maxBody + 1); + return reply; + } + const bool redirect = status == 301 || status == 302 || status == 303 || status == 307 || status == 308; + std::string location; + if (redirect) { + auto it = t.headers.find("location"); + if (it != t.headers.end()) location = it->second; + else { + auto u = t.headers.find("uri"); + if (u != t.headers.end()) location = u->second; + } + } + if (!redirect || location.empty()) throw HttpError(status, t.headers); + const bool getLike = curMethod == "GET" || curMethod == "HEAD"; + if (!((getLike && (status == 301 || status == 302 || status == 303 || status == 307 || status == 308)) || + (!getLike && (status == 301 || status == 302 || status == 303)))) + throw HttpError(status, t.headers); + const std::string next = urljoin(curUrl, WireText::replaceAll(location, " ", "%20")); + const std::string scheme = split(next).scheme; + if (scheme != "http" && scheme != "https") throw HttpError(status, t.headers); + ++redirections; + visited.push_back(curUrl); + if (redirections > 10 || std::count(visited.begin(), visited.end(), next) >= 4) throw HttpError(status, t.headers); + // A redirected POST turns into a bodiless GET without the content headers. + HeaderList nextHeaders; + for (const auto& h : curHeaders) { + const std::string low = WireText::lower(h.first); + if (low == "content-length" || low == "content-type") continue; + nextHeaders.push_back(h); + } + if (!getLike) { + curMethod = "GET"; + curBody = nullptr; + } + curHeaders = nextHeaders; + curUrl = next; + } +} + +// -------------------------------------------------------------------------- +// WireHttpStream + +struct WireHttpStream::State { + std::mutex mutex; + std::condition_variable cv; + std::string buffer; + bool headersDone{false}; + bool finished{false}; + std::string error; + int status{0}; + std::map headers; + std::atomic abort{false}; +}; + +WireHttpStream::~WireHttpStream() { close(); } + +std::unique_ptr WireHttpStream::open(const std::string& method, const std::string& url, + const HeaderList& headers, const std::string* body, + double timeout) { + std::unique_ptr stream(new WireHttpStream()); + stream->state_ = std::make_shared(); + auto state = stream->state_; + const std::string bodyCopy = body ? *body : std::string(); + const bool hasBody = body != nullptr; + stream->worker_ = std::thread([state, method, url, headers, bodyCopy, hasBody, timeout]() { + Transfer t; + t.abort = &state->abort; + t.sink = [state](const char* p, size_t n) { + std::lock_guard lock(state->mutex); + state->buffer.append(p, n); + state->cv.notify_all(); + }; + t.onHeadersDone = [state, &t]() { + std::lock_guard lock(state->mutex); + state->headers = t.headers; + state->status = t.httpStatus; + state->headersDone = true; + state->cv.notify_all(); + }; + int status = 0; + std::string error; + try { + performRequest(method, url, headers, hasBody ? &bodyCopy : nullptr, timeout, t, false, status); + } catch (const UrlError& exc) { + error = exc.what(); + } + std::lock_guard lock(state->mutex); + if (state->status == 0) state->status = status; + state->error = error; + state->finished = true; + state->headersDone = state->headersDone || !error.empty(); + state->cv.notify_all(); + }); + // Wait for headers or failure. + { + std::unique_lock lock(state->mutex); + state->cv.wait(lock, [&] { return state->headersDone || state->finished; }); + } + std::string error; + { + std::lock_guard lock(state->mutex); + error = state->error; + } + if (!error.empty()) { + stream->close(); + throw UrlError(error); + } + { + std::lock_guard lock(state->mutex); + stream->status_ = state->status; + stream->headers_ = state->headers; + } + return stream; +} + +std::string WireHttpStream::header(const std::string& name) const { + std::lock_guard lock(state_->mutex); + auto it = state_->headers.find(WireText::lower(name)); + return it == state_->headers.end() ? "" : it->second; +} + +std::string WireHttpStream::readAll() { + std::unique_lock lock(state_->mutex); + state_->cv.wait(lock, [&] { return state_->finished; }); + std::string out; + out.swap(state_->buffer); + return out; +} + +bool WireHttpStream::readLine(std::string& line) { + std::unique_lock lock(state_->mutex); + while (true) { + const size_t nl = state_->buffer.find('\n'); + if (nl != std::string::npos) { + line = state_->buffer.substr(0, nl + 1); + state_->buffer.erase(0, nl + 1); + return true; + } + if (state_->finished) { + line.swap(state_->buffer); + state_->buffer.clear(); + return !line.empty(); + } + state_->cv.wait(lock); + } +} + +void WireHttpStream::close() { + if (state_) state_->abort = true; + if (worker_.joinable()) worker_.join(); +} + +// -------------------------------------------------------------------------- +// WireHttpServer + +std::string WireHttpServer::Request::header(const std::string& name) const { + auto it = headers.find(WireText::lower(name)); + return it == headers.end() ? "" : it->second; +} + +WireHttpServer::WireHttpServer(const std::string& host, int port, std::string serverVersion) + : serverVersion_(std::move(serverVersion)) { + struct addrinfo hints; + std::memset(&hints, 0, sizeof hints); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_PASSIVE; + struct addrinfo* res = nullptr; + const std::string bindHost = host.empty() ? "0.0.0.0" : host; + const int gai = ::getaddrinfo(bindHost.c_str(), std::to_string(port).c_str(), &hints, &res); + if (gai != 0 || !res) throw UrlError("[Errno -2] " + std::string(::gai_strerror(gai))); + listenFd_ = ::socket(res->ai_family, res->ai_socktype | SOCK_CLOEXEC, 0); + if (listenFd_ < 0) { + const int err = errno; + ::freeaddrinfo(res); + throw WireOs::makeError(err); + } + int one = 1; + ::setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + if (::bind(listenFd_, res->ai_addr, res->ai_addrlen) != 0) { + const int err = errno; + ::freeaddrinfo(res); + ::close(listenFd_); + listenFd_ = -1; + throw WireOs::makeError(err); + } + ::freeaddrinfo(res); + if (::listen(listenFd_, 5) != 0) { + const int err = errno; + ::close(listenFd_); + listenFd_ = -1; + throw WireOs::makeError(err); + } + struct sockaddr_in addr; + socklen_t len = sizeof addr; + ::getsockname(listenFd_, reinterpret_cast(&addr), &len); + port_ = ntohs(addr.sin_port); +} + +WireHttpServer::~WireHttpServer() { close(); } + +void WireHttpServer::close() { + if (listenFd_ >= 0) { + ::close(listenFd_); + listenFd_ = -1; + } +} + +bool WireHttpServer::handleRequest(double timeout) { + if (listenFd_ < 0) return false; + struct pollfd p; + p.fd = listenFd_; + p.events = POLLIN; + p.revents = 0; + int r; + do { + r = ::poll(&p, 1, static_cast(std::max(0.0, timeout) * 1000)); + } while (r < 0 && errno == EINTR); + if (r <= 0) return false; + const int fd = ::accept4(listenFd_, nullptr, nullptr, SOCK_CLOEXEC); + if (fd < 0) return false; + struct timeval tv; + tv.tv_sec = 60; // hard cap: Python would wait forever on a stalled client + tv.tv_usec = 0; + ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + try { + serve(fd); + } catch (...) { + } + ::shutdown(fd, SHUT_RDWR); + ::close(fd); + return true; +} + +void WireHttpServer::serveForever(double pollInterval) { + while (!stop_.load()) handleRequest(pollInterval); +} + +static bool readUntilHeaderEnd(int fd, std::string& buf, size_t cap, std::string& leftover) { + char chunk[4096]; + while (true) { + size_t at = buf.find("\r\n\r\n"); + size_t skip = 4; + const size_t at2 = buf.find("\n\n"); + if (at == std::string::npos || (at2 != std::string::npos && at2 < at)) { + at = at2; + skip = 2; + } + if (at != std::string::npos) { + leftover = buf.substr(at + skip); + buf.resize(at + skip); + return true; + } + if (buf.size() > cap) return false; + ssize_t n = ::recv(fd, chunk, sizeof chunk, 0); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return false; + buf.append(chunk, static_cast(n)); + } +} + +void WireHttpServer::sendError(int fd, int code, const std::string& message) { + const std::string shortMsg = reason(code); + const std::string longMsg = explain(code); + const std::string msg = message.empty() ? shortMsg : message; + std::string body = + "\n\n \n \n Error " + "response\n \n \n

Error response

\n

Error code: " + + std::to_string(code) + "

\n

Message: " + htmlEscape(msg) + ".

\n

Error code explanation: " + + std::to_string(code) + " - " + htmlEscape(longMsg) + ".

\n \n\n"; + if (logger_) logger_("", ""); + Response r; + r.status = code; + r.headers = {{"Connection", "close"}, {"Content-Type", "text/html;charset=utf-8"}, {"Content-Length", std::to_string(body.size())}}; + r.body = body; + // Status line uses the message text, like send_response(code, message). + std::string head = "HTTP/1.0 " + std::to_string(code) + " " + msg + "\r\n"; + char date[64]; + std::time_t now = std::time(nullptr); + struct tm gm; + ::gmtime_r(&now, &gm); + std::strftime(date, sizeof date, "%a, %d %b %Y %H:%M:%S GMT", &gm); + head += "Server: " + serverVersion_ + "\r\nDate: " + date + "\r\n"; + for (const auto& h : r.headers) head += h.first + ": " + h.second + "\r\n"; + head += "\r\n"; + const std::string wire = head + body; + size_t off = 0; + while (off < wire.size()) { + ssize_t n = ::send(fd, wire.data() + off, wire.size() - off, MSG_NOSIGNAL); + if (n <= 0) break; + off += static_cast(n); + } + if (logger_) logger_("", ""); +} + +void WireHttpServer::sendResponse(int fd, const Response& response) { + std::string head = "HTTP/1.0 " + std::to_string(response.status) + " " + reason(response.status) + "\r\n"; + char date[64]; + std::time_t now = std::time(nullptr); + struct tm gm; + ::gmtime_r(&now, &gm); + std::strftime(date, sizeof date, "%a, %d %b %Y %H:%M:%S GMT", &gm); + head += "Server: " + serverVersion_ + "\r\nDate: " + date + "\r\n"; + for (const auto& h : response.headers) head += h.first + ": " + h.second + "\r\n"; + head += "\r\n"; + const std::string wire = head + response.body; + size_t off = 0; + while (off < wire.size()) { + ssize_t n = ::send(fd, wire.data() + off, wire.size() - off, MSG_NOSIGNAL); + if (n <= 0) break; + off += static_cast(n); + } +} + +void WireHttpServer::serve(int fd) { + std::string buf, leftover; + if (!readUntilHeaderEnd(fd, buf, maxHeader_ + 4096, leftover)) { + if (buf.empty()) return; + if (buf.find('\n') == std::string::npos && buf.size() > maxHeader_) { + sendError(fd, 414, "Request-URI Too Long"); + return; + } + return; + } + std::vector lines = WireText::split(buf, "\n"); + for (auto& l : lines) + while (!l.empty() && l.back() == '\r') l.pop_back(); + std::string requestLine = lines.empty() ? "" : lines[0]; + if (requestLine.size() > 65536) { + sendError(fd, 414, "Request-URI Too Long"); + return; + } + std::vector words = WireText::splitWhitespace(requestLine); + Request req; + if (words.empty()) return; + if (words.size() >= 3) { + const std::string version = words.back(); + bool good = WireText::startsWith(version, "HTTP/"); + int major = 0, minor = 0; + if (good) { + const std::string num = version.substr(5); + const size_t dot = num.find('.'); + std::string a = dot == std::string::npos ? num : num.substr(0, dot); + std::string b = dot == std::string::npos ? "" : num.substr(dot + 1); + if (dot == std::string::npos || !WireText::isDigits(a) || !WireText::isDigits(b)) good = false; + else { + major = std::atoi(a.c_str()); + minor = std::atoi(b.c_str()); + } + } + if (!good) { + sendError(fd, 400, "Bad request version (" + WireText::repr(version) + ")"); + return; + } + if (major >= 2) { + sendError(fd, 505, "Invalid HTTP version (" + std::to_string(major) + "." + std::to_string(minor) + ")"); + return; + } + if (words.size() > 3) { + sendError(fd, 400, "Bad request syntax (" + WireText::repr(requestLine) + ")"); + return; + } + req.version = version; + } else if (words.size() == 2) { + if (words[0] != "GET") { + sendError(fd, 400, "Bad HTTP/0.9 request type (" + WireText::repr(words[0]) + ")"); + return; + } + req.version = "HTTP/0.9"; + } else { + sendError(fd, 400, "Bad request syntax (" + WireText::repr(requestLine) + ")"); + return; + } + req.method = words[0]; + req.path = words[1]; + if (WireText::startsWith(req.path, "//")) req.path = "/" + WireText::lstripChars(req.path, "/"); + // headers + size_t count = 0; + for (size_t i = 1; i < lines.size(); ++i) { + if (lines[i].empty()) continue; + if (lines[i].size() > 65536) { + sendError(fd, 431, "Line too long"); + return; + } + if (++count > 100) { + sendError(fd, 431, "Too many headers"); + return; + } + const size_t colon = lines[i].find(':'); + if (colon == std::string::npos) continue; + req.headers[WireText::lower(lines[i].substr(0, colon))] = WireText::strip(lines[i].substr(colon + 1)); + } + if (!methods_.count(req.method)) { + sendError(fd, 501, "Unsupported method (" + WireText::repr(req.method) + ")"); + return; + } + if (req.method == "POST") { + // int(self.headers.get("Content-Length") or 0): sign, digits, underscores; + // anything else (or negative) reads no body. + long long length = 0; + { + const std::string cl = WireText::strip(req.header("Content-Length")); + size_t i = 0; + if (i < cl.size() && cl[i] == '+') ++i; + std::string digits; + bool prevUnderscore = true, valid = i < cl.size(); + for (; i < cl.size() && valid; ++i) { + if (cl[i] >= '0' && cl[i] <= '9') { + digits += cl[i]; + prevUnderscore = false; + } else if (cl[i] == '_' && !prevUnderscore) { + prevUnderscore = true; + } else { + valid = false; + } + } + if (valid && !prevUnderscore && !digits.empty()) length = std::atoll(digits.c_str()); + } + if (length > 0 && length <= static_cast(maxBody_)) { + req.body = leftover; + char chunk[65536]; + while (static_cast(req.body.size()) < length) { + ssize_t n = ::recv(fd, chunk, sizeof chunk, 0); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + req.body.append(chunk, static_cast(n)); + } + if (static_cast(req.body.size()) > length) req.body.resize(static_cast(length)); + } + } + Response resp; + if (!handler_ || !handler_(req, resp)) return; + if (logger_) logger_(req.method, req.path); + sendResponse(fd, resp); +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_http.h b/taicpp/src/cap/wire_http.h new file mode 100644 index 0000000..8ed2035 --- /dev/null +++ b/taicpp/src/cap/wire_http.h @@ -0,0 +1,153 @@ +#pragma once +// WireHttp: the HTTP client and server legs behind MCP and A2A. +// - WireHttp::urlopen urllib.request.urlopen (redirect rules, HTTPError for non-2xx) +// - WireHttp::exchange http.client style single exchange (no redirects, any status) +// - WireHttpStream a response read incrementally (SSE) on a worker thread +// - WireHttpServer http.server.HTTPServer + BaseHTTPRequestHandler (HTTP/1.0, +// single-threaded, one request per connection) +// The client uses libcurl; the server is plain POSIX sockets. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace detz::wire { + +using HeaderList = std::vector>; + +// urllib.error.HTTPError +class HttpError : public std::runtime_error { + public: + HttpError(int code, std::map headers) + : std::runtime_error("HTTP Error " + std::to_string(code)), code(code), headers(std::move(headers)) {} + int code; + std::map headers; // lower-case names + std::string header(const std::string& name) const; +}; + +// urllib.error.URLError / OSError / socket.timeout: what() is str(exc). +class UrlError : public std::runtime_error { + public: + explicit UrlError(const std::string& text) : std::runtime_error(text) {} +}; + +struct HttpReply { + int status{0}; + std::map headers; // lower-case names, last value wins + std::string body; + bool truncated{false}; // body hit the read limit + std::string header(const std::string& name) const; +}; + +class WireHttp { + public: + // urllib.request.urlopen(Request(url, data, headers, method), timeout) then + // response.read(maxBody + 1). maxBody 0 means unlimited. Throws HttpError + // for every non-2xx answer and UrlError for transport failures. + static HttpReply urlopen(const std::string& method, const std::string& url, const HeaderList& headers, + const std::string* body, double timeout, size_t maxBody = 0); + // One exchange without redirects or status checks. + static HttpReply exchange(const std::string& method, const std::string& url, const HeaderList& headers, + const std::string* body, double timeout); + // urllib.parse.urljoin for redirects (http/https only). + static std::string urljoin(const std::string& base, const std::string& ref); + // urllib.parse.urlsplit pieces. + struct Url { + std::string scheme, netloc, path, query, fragment; + std::string hostname; + int port{0}; + }; + static Url split(const std::string& url); + static std::string unsplit(const Url& url); +}; + +class WireHttpStream { + public: + ~WireHttpStream(); + // Starts the request and blocks until the status line and headers arrived. + static std::unique_ptr open(const std::string& method, const std::string& url, + const HeaderList& headers, const std::string* body, double timeout); + int status() const { return status_; } + std::string header(const std::string& name) const; + // Whole body (http.client resp.read()); blocks until the stream ends. + std::string readAll(); + // resp.readline(): "" at end of stream. Includes the trailing newline. + bool readLine(std::string& line); + // Aborts the transfer and joins the worker. + void close(); + + private: + WireHttpStream() = default; + struct State; + std::shared_ptr state_; + std::thread worker_; + int status_{0}; + std::map headers_; +}; + +class WireHttpServer { + public: + struct Request { + std::string method, path, version; + std::map headers; // lower-case names + std::string body; + std::string header(const std::string& name) const; + }; + struct Response { + int status{200}; + HeaderList headers; + std::string body; + }; + // Handler for one parsed request. Return false to drop the connection + // without answering (a handler that raised in Python). + using Handler = std::function; + // Called for each request line, like BaseHTTPRequestHandler.log_message. + using Logger = std::function; + + WireHttpServer(const std::string& host, int port, std::string serverVersion); + ~WireHttpServer(); + WireHttpServer(const WireHttpServer&) = delete; + WireHttpServer& operator=(const WireHttpServer&) = delete; + + int port() const { return port_; } + void setHandler(Handler handler) { handler_ = std::move(handler); } + void setLogger(Logger logger) { logger_ = std::move(logger); } + void setMaxHeaderBytes(size_t n) { maxHeader_ = n; } + // Request methods the handler implements (do_GET, do_POST, do_DELETE ...); + // every other method is answered 501 like BaseHTTPRequestHandler. + void setMethods(std::set methods) { methods_ = std::move(methods); } + // Largest POST body the server reads before the handler runs. + void setMaxBody(size_t n) { maxBody_ = n; } + // Waits up to `timeout` seconds for a connection and serves one request. + // Returns true when a connection was handled. + bool handleRequest(double timeout); + // serve_forever(poll_interval): returns after shutdown(). + void serveForever(double pollInterval = 0.5); + void shutdown() { stop_ = true; } + void close(); + + private: + void serve(int fd); + void sendResponse(int fd, const Response& response); + void sendError(int fd, int code, const std::string& message); + + int listenFd_{-1}; + int port_{0}; + std::string serverVersion_; + Handler handler_; + Logger logger_; + size_t maxHeader_{65536}; + size_t maxBody_{10485760}; + std::set methods_{"GET", "POST", "DELETE"}; + std::atomic stop_{false}; +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_json.cpp b/taicpp/src/cap/wire_json.cpp new file mode 100644 index 0000000..99c552b --- /dev/null +++ b/taicpp/src/cap/wire_json.cpp @@ -0,0 +1,539 @@ +#include "wire_json.h" + +#include +#include +#include +#include +#include + +#include "wire_text.h" + +namespace detz::wire { + +namespace { + +// json.loads over UTF-8 text. Positions in messages are code point offsets, +// exactly like json.JSONDecodeError. +class JsonParser { + public: + explicit JsonParser(const std::string& text) : s_(text) {} + + Json run() { + if (s_.compare(0, 3, "\xEF\xBB\xBF") == 0) fail("Unexpected UTF-8 BOM (decode using utf-8-sig)", 0); + size_t idx = skipWs(0); + Json value = scanOnce(idx, idx); + size_t end = skipWs(idx); + if (end != s_.size()) fail("Extra data", end); + return value; + } + + private: + const std::string& s_; + + [[noreturn]] void fail(const std::string& msg, size_t bytePos) const { + size_t cp = 0, line = 1, lastNl = std::string::npos; + size_t i = 0; + while (i < bytePos && i < s_.size()) { + const unsigned char c = static_cast(s_[i]); + size_t step = 1; + if (c >= 0xF0) step = 4; + else if (c >= 0xE0) step = 3; + else if (c >= 0xC0) step = 2; + if (c == '\n') { + ++line; + lastNl = cp; + } + i += step; + ++cp; + } + const size_t col = lastNl == std::string::npos ? cp + 1 : cp - lastNl; + throw JsonError(msg + ": line " + std::to_string(line) + " column " + std::to_string(col) + " (char " + + std::to_string(cp) + ")"); + } + + size_t skipWs(size_t i) const { + while (i < s_.size() && (s_[i] == ' ' || s_[i] == '\t' || s_[i] == '\n' || s_[i] == '\r')) ++i; + return i; + } + + // Scans one value starting at i (no leading whitespace). `next` receives the + // index after it. `errPos` is where "Expecting value" is reported. + Json scanOnce(size_t i, size_t& next) { + if (i >= s_.size()) fail("Expecting value", i); + const char c = s_[i]; + if (c == '"') return Json(scanString(i + 1, next)); + if (c == '{') return scanObject(i + 1, next); + if (c == '[') return scanArray(i + 1, next); + if (c == 'n' && s_.compare(i, 4, "null") == 0) { + next = i + 4; + return Json(); + } + if (c == 't' && s_.compare(i, 4, "true") == 0) { + next = i + 4; + return Json(true); + } + if (c == 'f' && s_.compare(i, 5, "false") == 0) { + next = i + 5; + return Json(false); + } + if (c == 'N' && s_.compare(i, 3, "NaN") == 0) { + next = i + 3; + return Json(std::nan("")); + } + if (c == 'I' && s_.compare(i, 8, "Infinity") == 0) { + next = i + 8; + return Json(INFINITY); + } + if (c == '-' && s_.compare(i, 9, "-Infinity") == 0) { + next = i + 9; + return Json(-INFINITY); + } + if (c == '-' || (c >= '0' && c <= '9')) return scanNumber(i, next); + fail("Expecting value", i); + } + + Json scanNumber(size_t i, size_t& next) { + size_t p = i; + if (s_[p] == '-') ++p; + if (p >= s_.size()) fail("Expecting value", i); + if (s_[p] == '0') { + ++p; + } else if (s_[p] >= '1' && s_[p] <= '9') { + while (p < s_.size() && s_[p] >= '0' && s_[p] <= '9') ++p; + } else { + fail("Expecting value", i); + } + const size_t intEnd = p; + bool isFloat = false; + if (p + 1 < s_.size() && s_[p] == '.' && s_[p + 1] >= '0' && s_[p + 1] <= '9') { + p += 2; + while (p < s_.size() && s_[p] >= '0' && s_[p] <= '9') ++p; + isFloat = true; + } + if (p < s_.size() && (s_[p] == 'e' || s_[p] == 'E')) { + size_t q = p + 1; + if (q < s_.size() && (s_[q] == '+' || s_[q] == '-')) ++q; + if (q < s_.size() && s_[q] >= '0' && s_[q] <= '9') { + while (q < s_.size() && s_[q] >= '0' && s_[q] <= '9') ++q; + p = q; + isFloat = true; + } + } + next = p; + if (isFloat) return Json(std::strtod(s_.substr(i, p - i).c_str(), nullptr)); + std::string digits = s_.substr(i, intEnd - i); + if (digits == "-0") return Json(0); + const bool neg = digits[0] == '-'; + const size_t ndig = digits.size() - (neg ? 1 : 0); + if (ndig <= 18) return Json(std::strtoll(digits.c_str(), nullptr, 10)); + // May overflow int64: compare via strtoll errno. + errno = 0; + long long v = std::strtoll(digits.c_str(), nullptr, 10); + if (errno == ERANGE) return Json::bigInt(digits); + return Json(v); + } + + static int hexVal(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + bool readHex4(size_t at, unsigned& out) const { + if (at + 4 > s_.size()) return false; + unsigned v = 0; + for (size_t k = 0; k < 4; ++k) { + int h = hexVal(s_[at + k]); + if (h < 0) return false; + v = (v << 4) | static_cast(h); + } + out = v; + return true; + } + + std::string scanString(size_t i, size_t& next) { + const size_t begin = i - 1; + std::string out; + while (true) { + if (i >= s_.size()) fail("Unterminated string starting at", begin); + const unsigned char c = static_cast(s_[i]); + if (c == '"') { + next = i + 1; + return out; + } + if (c < 0x20) fail("Invalid control character at", i); + if (c != '\\') { + out += static_cast(c); + ++i; + continue; + } + ++i; + if (i >= s_.size()) fail("Unterminated string starting at", begin); + const char e = s_[i]; + if (e != 'u') { + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + default: fail("Invalid \\escape", i - 1); + } + ++i; + continue; + } + unsigned cp = 0; + // CPython needs at least one more character after the four digits. + if (i + 5 >= s_.size() || !readHex4(i + 1, cp)) fail("Invalid \\uXXXX escape", i); + i += 5; + if (cp >= 0xD800 && cp <= 0xDBFF && i + 6 < s_.size() && s_[i] == '\\' && s_[i + 1] == 'u') { + unsigned lo = 0; + if (!readHex4(i + 2, lo)) fail("Invalid \\uXXXX escape", i + 1); + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + (((cp - 0xD800) << 10) | (lo - 0xDC00)); + i += 6; + } + } + if (cp >= 0xD800 && cp <= 0xDFFF) { + // Lone surrogate: Python keeps it in the str. Carry it as WTF-8 so a + // later dumps() can write the escape back. + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += WireText::encode(cp); + } + } + } + + Json scanObject(size_t i, size_t& next) { + Json obj = Json::object(); + i = skipWs(i); + if (i >= s_.size() || s_[i] != '}') { + while (true) { + if (i >= s_.size() || s_[i] != '"') fail("Expecting property name enclosed in double quotes", i); + std::string key = scanString(i + 1, i); + i = skipWs(i); + if (i >= s_.size() || s_[i] != ':') fail("Expecting ':' delimiter", i); + i = skipWs(i + 1); + Json value = scanOnce(i, i); + obj.set(key, value); + i = skipWs(i); + if (i < s_.size() && s_[i] == '}') break; + if (i >= s_.size() || s_[i] != ',') fail("Expecting ',' delimiter", i); + const size_t comma = i; + i = skipWs(i + 1); + if (i < s_.size() && s_[i] == '}') fail("Illegal trailing comma before end of object", comma); + } + } + next = i + 1; + return obj; + } + + Json scanArray(size_t i, size_t& next) { + Json arr = Json::array(); + i = skipWs(i); + if (i >= s_.size() || s_[i] != ']') { + while (true) { + Json value = scanOnce(i, i); + arr.push(value); + i = skipWs(i); + if (i < s_.size() && s_[i] == ']') break; + if (i >= s_.size() || s_[i] != ',') fail("Expecting ',' delimiter", i); + const size_t comma = i; + i = skipWs(i + 1); + if (i < s_.size() && s_[i] == ']') fail("Illegal trailing comma before end of array", comma); + } + } + next = i + 1; + return arr; + } +}; + +void appendEscaped(std::string& out, const std::string& text) { + out += '"'; + char buf[16]; + size_t i = 0; + while (i < text.size()) { + const unsigned char c = static_cast(text[i]); + if (c < 0x80) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (c < 0x20 || c == 0x7F) { + std::snprintf(buf, sizeof buf, "\\u%04x", c); + out += buf; + } else { + out += static_cast(c); + } + } + ++i; + continue; + } + // Decode one code point (invalid bytes become U+FFFD like decode "replace"). + char32_t cp = 0xFFFD; + size_t used = 1; + if (c >= 0xC2 && c <= 0xDF && i + 1 < text.size()) { + cp = ((c & 0x1F) << 6) | (text[i + 1] & 0x3F); + used = 2; + } else if (c >= 0xE0 && c <= 0xEF && i + 2 < text.size()) { + cp = ((c & 0x0F) << 12) | ((text[i + 1] & 0x3F) << 6) | (text[i + 2] & 0x3F); + used = 3; + } else if (c >= 0xF0 && c <= 0xF4 && i + 3 < text.size()) { + cp = ((c & 0x07) << 18) | ((text[i + 1] & 0x3F) << 12) | ((text[i + 2] & 0x3F) << 6) | (text[i + 3] & 0x3F); + used = 4; + } + i += used; + if (cp >= 0x10000) { + const unsigned v = cp - 0x10000; + std::snprintf(buf, sizeof buf, "\\u%04x\\u%04x", 0xD800 + (v >> 10), 0xDC00 + (v & 0x3FF)); + } else { + std::snprintf(buf, sizeof buf, "\\u%04x", static_cast(cp)); + } + out += buf; + } + out += '"'; +} + +void dumpValue(std::string& out, const Json& v, bool sortKeys) { + switch (v.type()) { + case Json::Type::Null: out += "null"; break; + case Json::Type::Bool: out += v.truthy() ? "true" : "false"; break; + case Json::Type::Int: out += std::to_string(v.asInt()); break; + case Json::Type::BigInt: out += v.str(); break; + case Json::Type::Float: { + const double d = v.asDouble(); + if (std::isnan(d)) out += "NaN"; + else if (std::isinf(d)) out += d < 0 ? "-Infinity" : "Infinity"; + else out += WireText::reprFloat(d); + break; + } + case Json::Type::String: appendEscaped(out, v.str()); break; + case Json::Type::Array: { + out += '['; + bool first = true; + for (const Json& item : v.items()) { + if (!first) out += ", "; + first = false; + dumpValue(out, item, sortKeys); + } + out += ']'; + break; + } + case Json::Type::Object: { + out += '{'; + std::vector order; + for (const auto& m : v.members()) order.push_back(&m); + if (sortKeys) + std::stable_sort(order.begin(), order.end(), + [](const Json::Member* a, const Json::Member* b) { return a->first < b->first; }); + bool first = true; + for (const Json::Member* m : order) { + if (!first) out += ", "; + first = false; + appendEscaped(out, m->first); + out += ": "; + dumpValue(out, m->second, sortKeys); + } + out += '}'; + break; + } + } +} + +} // namespace + +Json Json::fromStrings(const std::vector& items) { + Json out = array(); + for (const auto& item : items) out.push(Json(item)); + return out; +} + +Json Json::bigInt(const std::string& decimal) { + Json out; + out.type_ = Type::BigInt; + out.str_ = decimal; + return out; +} + +long long Json::asInt() const { + switch (type_) { + case Type::Int: + case Type::Bool: return int_; + case Type::Float: return static_cast(float_); + case Type::BigInt: return std::strtoll(str_.c_str(), nullptr, 10); + default: return 0; + } +} + +double Json::asDouble() const { + switch (type_) { + case Type::Float: return float_; + case Type::Int: + case Type::Bool: return static_cast(int_); + case Type::BigInt: return std::strtod(str_.c_str(), nullptr); + default: return 0.0; + } +} + +bool Json::truthy() const { + switch (type_) { + case Type::Null: return false; + case Type::Bool: + case Type::Int: return int_ != 0; + case Type::BigInt: return true; + case Type::Float: return float_ != 0.0; + case Type::String: return !str_.empty(); + case Type::Array: return !items_.empty(); + case Type::Object: return !members_.empty(); + } + return false; +} + +const Json* Json::find(const std::string& key) const { + if (type_ != Type::Object) return nullptr; + for (const auto& m : members_) + if (m.first == key) return &m.second; + return nullptr; +} + +Json* Json::find(const std::string& key) { + if (type_ != Type::Object) return nullptr; + for (auto& m : members_) + if (m.first == key) return &m.second; + return nullptr; +} + +Json Json::get(const std::string& key) const { return get(key, Json()); } + +Json Json::get(const std::string& key, const Json& fallback) const { + const Json* hit = find(key); + return hit ? *hit : fallback; +} + +void Json::set(const std::string& key, const Json& value) { + if (type_ != Type::Object) { + type_ = Type::Object; + members_.clear(); + } + for (auto& m : members_) { + if (m.first == key) { + m.second = value; + return; + } + } + members_.emplace_back(key, value); +} + +void Json::setDefault(const std::string& key, const Json& value) { + if (!find(key)) set(key, value); +} + +Json Json::pop(const std::string& key) { + if (type_ != Type::Object) return Json(); + for (auto it = members_.begin(); it != members_.end(); ++it) { + if (it->first == key) { + Json out = it->second; + members_.erase(it); + return out; + } + } + return Json(); +} + +void Json::update(const Json& other) { + for (const auto& m : other.members_) set(m.first, m.second); +} + +std::vector Json::keys() const { + std::vector out; + for (const auto& m : members_) out.push_back(m.first); + return out; +} + +size_t Json::size() const { + switch (type_) { + case Type::String: return WireText::length(str_); + case Type::Array: return items_.size(); + case Type::Object: return members_.size(); + default: return 0; + } +} + +std::string Json::dumps(bool sortKeys) const { + std::string out; + dumpValue(out, *this, sortKeys); + return out; +} + +Json Json::parse(const std::string& text) { + JsonParser parser(text); + return parser.run(); +} + +std::string Json::pyRepr() const { + switch (type_) { + case Type::Null: return "None"; + case Type::Bool: return int_ ? "True" : "False"; + case Type::Int: return std::to_string(int_); + case Type::BigInt: return str_; + case Type::Float: return WireText::reprFloat(float_); + case Type::String: return WireText::repr(str_); + case Type::Array: { + std::string out = "["; + for (size_t i = 0; i < items_.size(); ++i) { + if (i) out += ", "; + out += items_[i].pyRepr(); + } + return out + "]"; + } + case Type::Object: { + std::string out = "{"; + for (size_t i = 0; i < members_.size(); ++i) { + if (i) out += ", "; + out += WireText::repr(members_[i].first) + ": " + members_[i].second.pyRepr(); + } + return out + "}"; + } + } + return ""; +} + +std::string Json::pyStr() const { return type_ == Type::String ? str_ : pyRepr(); } + +bool Json::operator==(const Json& other) const { + const bool lnum = isIntLike() || isFloat(), rnum = other.isIntLike() || other.isFloat(); + if (lnum && rnum) { + if (type_ == Type::BigInt || other.type_ == Type::BigInt) + return type_ == other.type_ ? str_ == other.str_ : asDouble() == other.asDouble(); + if (isFloat() || other.isFloat()) return asDouble() == other.asDouble(); + return int_ == other.int_; + } + if (type_ != other.type_) return false; + switch (type_) { + case Type::Null: return true; + case Type::String: return str_ == other.str_; + case Type::Array: return items_ == other.items_; + case Type::Object: { + if (members_.size() != other.members_.size()) return false; + for (const auto& m : members_) { + const Json* hit = other.find(m.first); + if (!hit || !(*hit == m.second)) return false; + } + return true; + } + default: return false; + } +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_json.h b/taicpp/src/cap/wire_json.h new file mode 100644 index 0000000..195017a --- /dev/null +++ b/taicpp/src/cap/wire_json.h @@ -0,0 +1,134 @@ +#pragma once +// Json: the dynamic value every CAP event, MCP message and manifest table is +// built from. It mirrors the Python objects tai.py passes around: ordered +// dicts (insertion order is wire order), ints that are distinct from floats, +// and json.dumps / json.loads byte-for-byte (separators, ensure_ascii, +// float repr, error messages). +#include +#include +#include +#include +#include +#include + +namespace detz::wire { + +// Python ValueError raised by json.loads (json.JSONDecodeError). +class JsonError : public std::runtime_error { + public: + explicit JsonError(const std::string& message) : std::runtime_error(message) {} +}; + +class Json { + public: + enum class Type { Null, Bool, Int, BigInt, Float, String, Array, Object }; + using Member = std::pair; + + Json() = default; + Json(std::nullptr_t) {} + Json(bool value) : type_(Type::Bool), int_(value ? 1 : 0) {} + Json(int value) : type_(Type::Int), int_(value) {} + Json(long value) : type_(Type::Int), int_(value) {} + Json(long long value) : type_(Type::Int), int_(value) {} + Json(unsigned long value) : type_(Type::Int), int_(static_cast(value)) {} + Json(unsigned long long value) : type_(Type::Int), int_(static_cast(value)) {} + Json(unsigned value) : type_(Type::Int), int_(value) {} + Json(double value) : type_(Type::Float), float_(value) {} + Json(const char* value) : type_(Type::String), str_(value) {} + Json(const std::string& value) : type_(Type::String), str_(value) {} + Json(std::string&& value) : type_(Type::String), str_(std::move(value)) {} + + static Json array() { + Json out; + out.type_ = Type::Array; + return out; + } + static Json array(std::initializer_list items) { + Json out = array(); + out.items_.assign(items.begin(), items.end()); + return out; + } + static Json object() { + Json out; + out.type_ = Type::Object; + return out; + } + static Json object(std::initializer_list members) { + Json out = object(); + for (const auto& member : members) out.set(member.first, member.second); + return out; + } + static Json fromStrings(const std::vector& items); + static Json bigInt(const std::string& decimal); + + Type type() const { return type_; } + bool isNull() const { return type_ == Type::Null; } + bool isBool() const { return type_ == Type::Bool; } + // Python isinstance(x, int): bool counts. + bool isIntLike() const { return type_ == Type::Int || type_ == Type::BigInt || type_ == Type::Bool; } + // int, not bool. + bool isInt() const { return type_ == Type::Int || type_ == Type::BigInt; } + bool isFloat() const { return type_ == Type::Float; } + bool isNumber() const { return isInt() || isFloat(); } + bool isString() const { return type_ == Type::String; } + bool isArray() const { return type_ == Type::Array; } + bool isObject() const { return type_ == Type::Object; } + + const std::string& str() const { return str_; } + long long asInt() const; + double asDouble() const; + bool asBool() const { return truthy(); } + // Python truthiness: None, False, 0, 0.0, "", [], {} are false. + bool truthy() const; + + // dict access. find returns nullptr when absent or not an object. + const Json* find(const std::string& key) const; + Json* find(const std::string& key); + bool has(const std::string& key) const { return find(key) != nullptr; } + // dict.get(key, default): never raises. + Json get(const std::string& key) const; + Json get(const std::string& key, const Json& fallback) const; + // d[key] = value (keeps position when the key exists). + void set(const std::string& key, const Json& value); + // d.setdefault(key, value). + void setDefault(const std::string& key, const Json& value); + // d.pop(key, None): returns the removed value or Null. + Json pop(const std::string& key); + // d.update(other). + void update(const Json& other); + const std::vector& members() const { return members_; } + std::vector& members() { return members_; } + std::vector keys() const; + + // list access. + void push(const Json& value) { items_.push_back(value); } + const std::vector& items() const { return items_; } + std::vector& items() { return items_; } + const Json& at(size_t index) const { return items_.at(index); } + // len() for strings (code points), arrays and objects. + size_t size() const; + size_t count() const { return isObject() ? members_.size() : items_.size(); } + + // json.dumps(self, sort_keys=...) with default separators and ensure_ascii. + std::string dumps(bool sortKeys = false) const; + // json.loads; throws JsonError carrying the Python message. + static Json parse(const std::string& text); + + // Python str(obj) / repr(obj). + std::string pyStr() const; + std::string pyRepr() const; + + // Python == (1 == 1.0, True == 1, dict equality ignores order). + bool operator==(const Json& other) const; + bool operator!=(const Json& other) const { return !(*this == other); } + + private: + Type type_{Type::Null}; + long long int_{0}; + double float_{0.0}; + std::string str_; // String value, or the decimal digits of a BigInt + std::vector items_; + std::vector members_; +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_log.h b/taicpp/src/cap/wire_log.h new file mode 100644 index 0000000..29fcc86 --- /dev/null +++ b/taicpp/src/cap/wire_log.h @@ -0,0 +1,44 @@ +#pragma once +// WireLog: the seam between CAP/MCP and the app's debug log (logging.md, +// `LOG.event(level, subsystem, name, message, profile=, meta=)`). The logging +// module installs the sink at boot; until then events are dropped, exactly +// like tai.py's LOG when --debug is off. +#include +#include +#include + +#include "wire_json.h" + +namespace detz::wire { + +class WireLog { + public: + using Sink = std::function; + + static void setSink(Sink sink) { + std::lock_guard lock(mutex()); + slot() = std::move(sink); + } + static void event(const std::string& level, const std::string& subsystem, const std::string& name, + const std::string& message, const std::string& profile = "", const Json& meta = Json::object()) { + Sink copy; + { + std::lock_guard lock(mutex()); + copy = slot(); + } + if (copy) copy(level, subsystem, name, message, profile, meta); + } + + private: + static Sink& slot() { + static Sink sink; + return sink; + } + static std::mutex& mutex() { + static std::mutex m; + return m; + } +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_os.cpp b/taicpp/src/cap/wire_os.cpp new file mode 100644 index 0000000..6ba35e0 --- /dev/null +++ b/taicpp/src/cap/wire_os.cpp @@ -0,0 +1,259 @@ +#include "wire_os.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "wire_text.h" + +extern char** environ; + +namespace detz::wire { + +namespace fs = std::filesystem; + +double WireOs::now() { + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +void WireOs::sleep(double seconds) { + if (seconds > 0) std::this_thread::sleep_for(std::chrono::duration(seconds)); +} + +std::string WireOs::errnoText(int err, const std::string& filename) { + std::string out = "[Errno " + std::to_string(err) + "] " + std::strerror(err); + if (!filename.empty()) out += ": " + WireText::repr(filename); + return out; +} + +OsError WireOs::makeError(int err, const std::string& filename) { return OsError(err, errnoText(err, filename)); } + +std::map WireOs::environ() { + std::map out; + for (char** e = ::environ; e && *e; ++e) { + const char* eq = std::strchr(*e, '='); + if (!eq) continue; + out[std::string(*e, eq - *e)] = eq + 1; + } + return out; +} + +std::string WireOs::getenv(const std::string& name, const std::string& fallback) { + const char* v = ::getenv(name.c_str()); + return v ? std::string(v) : fallback; +} + +bool WireOs::hasEnv(const std::string& name) { return ::getenv(name.c_str()) != nullptr; } + +std::string WireOs::home() { + const char* h = ::getenv("HOME"); + if (h && *h) return h; + struct passwd* pw = ::getpwuid(::getuid()); + return pw && pw->pw_dir ? pw->pw_dir : "~"; +} + +std::string WireOs::getcwd() { + char buf[4096]; + return ::getcwd(buf, sizeof buf) ? std::string(buf) : std::string(); +} + +bool WireOs::chdir(const std::string& dir, std::string* error) { + if (::chdir(dir.c_str()) == 0) return true; + if (error) *error = errnoText(errno, dir); + return false; +} + +static std::string normpath(const std::string& path) { + if (path.empty()) return "."; + const bool abs = path[0] == '/'; + std::vector parts; + for (const std::string& piece : WireText::split(path, "/")) { + if (piece.empty() || piece == ".") continue; + if (piece == "..") { + if (!parts.empty() && parts.back() != "..") + parts.pop_back(); + else if (!abs) + parts.push_back(".."); + continue; + } + parts.push_back(piece); + } + std::string out = WireText::join(parts, "/"); + if (abs) out = "/" + out; + return out.empty() ? "." : out; +} + +std::string WireOs::abspath(const std::string& path) { + if (isAbs(path)) return normpath(path); + return normpath(WireText::pathJoin(getcwd(), path)); +} + +std::string WireOs::realpath(const std::string& path) { + std::error_code ec; + fs::path joined = isAbs(path) ? fs::path(path) : fs::path(getcwd()) / path; + fs::path out = fs::weakly_canonical(joined, ec); + if (ec) return normpath(joined.string()); + std::string text = out.string(); + while (text.size() > 1 && text.back() == '/') text.pop_back(); + return text; +} + +bool WireOs::isAbs(const std::string& path) { return !path.empty() && path[0] == '/'; } + +bool WireOs::exists(const std::string& path) { + struct stat st; + return ::stat(path.c_str(), &st) == 0; +} + +bool WireOs::lexists(const std::string& path) { + struct stat st; + return ::lstat(path.c_str(), &st) == 0; +} + +bool WireOs::isDir(const std::string& path) { + struct stat st; + return ::stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +} + +bool WireOs::isFile(const std::string& path) { + struct stat st; + return ::stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode); +} + +bool WireOs::isLink(const std::string& path) { + struct stat st; + return ::lstat(path.c_str(), &st) == 0 && S_ISLNK(st.st_mode); +} + +long long WireOs::fileSize(const std::string& path) { + struct stat st; + if (::stat(path.c_str(), &st) != 0) throw makeError(errno, path); + return st.st_size; +} + +std::vector WireOs::listdir(const std::string& path) { + DIR* dir = ::opendir(path.c_str()); + if (!dir) throw makeError(errno, path); + std::vector out; + while (struct dirent* ent = ::readdir(dir)) { + const std::string name = ent->d_name; + if (name == "." || name == "..") continue; + out.push_back(name); + } + ::closedir(dir); + return out; +} + +void WireOs::makedirs(const std::string& path) { + if (path.empty()) return; + std::error_code ec; + fs::create_directories(path, ec); + if (ec && !isDir(path)) throw makeError(ec.value(), path); +} + +void WireOs::rmtree(const std::string& path, bool ignoreErrors) { + std::error_code ec; + fs::remove_all(path, ec); + if (ec && !ignoreErrors) throw makeError(ec.value(), path); +} + +void WireOs::rmdir(const std::string& path) { + if (::rmdir(path.c_str()) != 0) throw makeError(errno, path); +} + +void WireOs::remove(const std::string& path) { + if (::unlink(path.c_str()) != 0) throw makeError(errno, path); +} + +std::string WireOs::readBytes(const std::string& path) { + int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) throw makeError(errno, path); + std::string raw; + char buf[65536]; + while (true) { + ssize_t n = ::read(fd, buf, sizeof buf); + if (n < 0) { + const int err = errno; + ::close(fd); + throw makeError(err, path); + } + if (n == 0) break; + raw.append(buf, static_cast(n)); + } + ::close(fd); + return raw; +} + +std::string WireOs::readFile(const std::string& path) { + int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) throw makeError(errno, path); + std::string raw; + char buf[65536]; + while (true) { + ssize_t n = ::read(fd, buf, sizeof buf); + if (n < 0) { + const int err = errno; + ::close(fd); + throw makeError(err, path); + } + if (n == 0) break; + raw.append(buf, static_cast(n)); + } + ::close(fd); + std::string text, message; + if (!WireText::decodeStrict(raw, text, message)) throw ValueError(message); + // universal newlines: \r\n and lone \r become \n. + std::string out; + out.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == '\r') { + out += '\n'; + if (i + 1 < text.size() && text[i + 1] == '\n') ++i; + } else { + out += text[i]; + } + } + return out; +} + +void WireOs::writeFile(const std::string& path, const std::string& data) { + int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0666); + if (fd < 0) throw makeError(errno, path); + size_t off = 0; + while (off < data.size()) { + ssize_t n = ::write(fd, data.data() + off, data.size() - off); + if (n < 0) { + const int err = errno; + ::close(fd); + throw makeError(err, path); + } + off += static_cast(n); + } + ::close(fd); +} + +std::string WireOs::which(const std::string& command) { + if (command.empty()) return ""; + auto executable = [](const std::string& p) { + struct stat st; + return ::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0; + }; + if (command.find('/') != std::string::npos) return executable(command) ? command : ""; + std::string path = hasEnv("PATH") ? getenv("PATH") : "/bin:/usr/bin"; + for (const std::string& dir : WireText::split(path, ":")) { + const std::string candidate = WireText::pathJoin(dir.empty() ? "." : dir, command); + if (executable(candidate)) return candidate; + } + return ""; +} + +std::string WireOs::dirnameAbs(const std::string& path) { return WireText::dirname(abspath(path)); } + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_os.h b/taicpp/src/cap/wire_os.h new file mode 100644 index 0000000..753974c --- /dev/null +++ b/taicpp/src/cap/wire_os.h @@ -0,0 +1,83 @@ +#pragma once +// WireOs: the os / os.path / shutil / time pieces tai.py's CAP and MCP code +// leans on, with Python's behavior and error text. +#include +#include +#include +#include + +namespace detz::wire { + +// Python OSError. what() is str(exc): "[Errno 2] No such file or directory: 'x'". +class OsError : public std::runtime_error { + public: + OsError(int err, const std::string& text) : std::runtime_error(text), errnoValue(err) {} + int errnoValue; + bool notFound() const { return errnoValue == 2; } +}; + +// Python ValueError (and UnicodeDecodeError, whose str is the codec message). +class ValueError : public std::runtime_error { + public: + explicit ValueError(const std::string& text) : std::runtime_error(text) {} +}; + +// Python TypeError (a crash in tai.py, kept loud here too). +class PyTypeError : public std::runtime_error { + public: + explicit PyTypeError(const std::string& text) : std::runtime_error(text) {} +}; + +// subprocess.TimeoutExpired / SubprocessError. +class SubprocessError : public std::runtime_error { + public: + explicit SubprocessError(const std::string& text) : std::runtime_error(text) {} +}; + +class WireOs { + public: + // time.time() + static double now(); + // time.sleep() + static void sleep(double seconds); + // str(OSError(errno, strerror, filename)) + static std::string errnoText(int err, const std::string& filename = ""); + static OsError makeError(int err, const std::string& filename = ""); + // os.environ as a map (insertion order irrelevant). + static std::map environ(); + static std::string getenv(const std::string& name, const std::string& fallback = ""); + static bool hasEnv(const std::string& name); + // os.path.expanduser("~") + static std::string home(); + static std::string getcwd(); + static bool chdir(const std::string& dir, std::string* error = nullptr); + // os.path.abspath / realpath / isabs / exists / lexists / isdir / isfile / islink + static std::string abspath(const std::string& path); + static std::string realpath(const std::string& path); + static bool isAbs(const std::string& path); + static bool exists(const std::string& path); + static bool lexists(const std::string& path); + static bool isDir(const std::string& path); + static bool isFile(const std::string& path); + static bool isLink(const std::string& path); + static long long fileSize(const std::string& path); + // os.listdir (unsorted order of the directory, like Python), throws OsError. + static std::vector listdir(const std::string& path); + // os.makedirs(path, exist_ok=True) + static void makedirs(const std::string& path); + static void rmtree(const std::string& path, bool ignoreErrors = false); + static void rmdir(const std::string& path); + static void remove(const std::string& path); + // open(path, "r", encoding="utf-8").read(): universal newlines, strict + // decoding. Throws OsError, or ValueError carrying the codec message. + static std::string readFile(const std::string& path); + // Raw bytes of a file (open(path, "rb").read()); throws OsError. + static std::string readBytes(const std::string& path); + static void writeFile(const std::string& path, const std::string& data); + // shutil.which + static std::string which(const std::string& command); + // os.path.dirname(os.path.abspath(path)) + static std::string dirnameAbs(const std::string& path); +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_proc.cpp b/taicpp/src/cap/wire_proc.cpp new file mode 100644 index 0000000..abe74b8 --- /dev/null +++ b/taicpp/src/cap/wire_proc.cpp @@ -0,0 +1,552 @@ +#include "wire_proc.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "wire_text.h" + +namespace detz::wire { + +namespace { + +void ignoreSigpipeOnce() { + static std::once_flag once; + std::call_once(once, [] { ::signal(SIGPIPE, SIG_IGN); }); +} + +// close_fds=True: close every fd >= first except `keep` (the error report pipe). +void closeAllExcept(int first, int keep) { +#ifdef SYS_close_range + bool ok = true; + if (keep > first) ok = ::syscall(SYS_close_range, static_cast(first), static_cast(keep - 1), 0U) == 0; + if (ok && ::syscall(SYS_close_range, static_cast(keep >= first ? keep + 1 : first), ~0U, 0U) == 0) return; +#endif + for (int fd = first; fd < 4096; ++fd) + if (fd != keep) ::close(fd); +} + +std::vector toArgv(const std::vector& items) { + std::vector out; + for (const auto& item : items) out.push_back(const_cast(item.c_str())); + out.push_back(nullptr); + return out; +} + +std::vector envStrings(const WireProc::Options& options) { + std::vector out; + if (options.replaceEnv) { + for (const auto& kv : options.env) out.push_back(kv.first + "=" + kv.second); + } else { + for (const auto& kv : WireOs::environ()) out.push_back(kv.first + "=" + kv.second); + } + return out; +} + +std::string formatTimeout(double t) { + if (t == std::floor(t)) return std::to_string(static_cast(t)); + return WireText::reprFloat(t); +} + +std::string argvRepr(const std::vector& argv) { + std::string out = "["; + for (size_t i = 0; i < argv.size(); ++i) { + if (i) out += ", "; + out += WireText::repr(argv[i]); + } + return out + "]"; +} + +} // namespace + +int WireProc::decodeStatus(int status) { + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return -WTERMSIG(status); + return status; +} + +std::string WireProc::resolveExecutable(const Options& options, int& errOut) { + errOut = 0; + const std::string& name = options.argv.at(0); + if (name.find('/') != std::string::npos) return name; + std::string path = "/bin:/usr/bin"; + if (options.replaceEnv) { + auto it = options.env.find("PATH"); + if (it != options.env.end()) path = it->second; + } else if (WireOs::hasEnv("PATH")) { + path = WireOs::getenv("PATH"); + } + int lastErr = ENOENT; + for (const std::string& dir : WireText::split(path, ":")) { + const std::string candidate = WireText::pathJoin(dir.empty() ? "." : dir, name); + struct stat st; + if (::stat(candidate.c_str(), &st) != 0) continue; + if (S_ISDIR(st.st_mode)) { + lastErr = EACCES; + continue; + } + if (::access(candidate.c_str(), X_OK) != 0) { + lastErr = EACCES; + continue; + } + return candidate; + } + errOut = lastErr; + return ""; +} + +std::unique_ptr WireProc::spawn(const Options& options) { + ignoreSigpipeOnce(); + if (options.argv.empty()) throw ValueError("empty argv"); + int resolveErr = 0; + const std::string exe = resolveExecutable(options, resolveErr); + if (resolveErr) throw WireOs::makeError(resolveErr, options.argv[0]); + + int inPipe[2] = {-1, -1}, outPipe[2] = {-1, -1}, errPipe[2] = {-1, -1}, report[2]; + auto closePair = [](int* p) { + if (p[0] >= 0) ::close(p[0]); + if (p[1] >= 0) ::close(p[1]); + p[0] = p[1] = -1; + }; + auto fail = [&](int err, const std::string& file) { + closePair(inPipe); + closePair(outPipe); + closePair(errPipe); + throw WireOs::makeError(err, file); + }; + if (options.in == Stream::Pipe && ::pipe2(inPipe, O_CLOEXEC) != 0) fail(errno, ""); + if (options.out == Stream::Pipe && ::pipe2(outPipe, O_CLOEXEC) != 0) fail(errno, ""); + if (options.err == Stream::Pipe && ::pipe2(errPipe, O_CLOEXEC) != 0) fail(errno, ""); + if (::pipe2(report, O_CLOEXEC) != 0) fail(errno, ""); + + const std::vector envList = envStrings(options); + std::vector envp = toArgv(envList); + std::vector argv = toArgv(options.argv); + const bool chdirNeeded = !options.cwd.empty(); + + const pid_t pid = ::fork(); + if (pid < 0) { + const int err = errno; + ::close(report[0]); + ::close(report[1]); + fail(err, ""); + } + if (pid == 0) { + // Child: async-signal-safe calls only. + sigset_t none; + sigemptyset(&none); + ::sigprocmask(SIG_SETMASK, &none, nullptr); + ::signal(SIGPIPE, SIG_DFL); + ::signal(SIGXFSZ, SIG_DFL); + int devnull = -1; + auto target = [&](Stream s, int pipeEnd, int fd) { + if (s == Stream::Pipe) { + ::dup2(pipeEnd, fd); + } else if (s == Stream::Devnull) { + if (devnull < 0) devnull = ::open("/dev/null", O_RDWR); + ::dup2(devnull, fd); + } + }; + target(options.in, inPipe[0], 0); + target(options.out, outPipe[1], 1); + if (options.err == Stream::ToStdout) + ::dup2(1, 2); + else + target(options.err, errPipe[1], 2); + int code = 0; + if (chdirNeeded && ::chdir(options.cwd.c_str()) != 0) { + code = errno; + const int flag = 1; + if (::write(report[1], &code, sizeof code) < 0 || ::write(report[1], &flag, sizeof flag) < 0) _exit(255); + _exit(255); + } + closeAllExcept(3, report[1]); + ::execve(exe.c_str(), argv.data(), envp.data()); + code = errno; + const int flag = 0; + if (::write(report[1], &code, sizeof code) < 0 || ::write(report[1], &flag, sizeof flag) < 0) _exit(255); + _exit(255); + } + ::close(report[1]); + if (inPipe[0] >= 0) ::close(inPipe[0]); + if (outPipe[1] >= 0) ::close(outPipe[1]); + if (errPipe[1] >= 0) ::close(errPipe[1]); + + int got[2] = {0, 0}; + size_t have = 0; + while (have < sizeof got) { + ssize_t n = ::read(report[0], reinterpret_cast(got) + have, sizeof got - have); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + have += static_cast(n); + } + ::close(report[0]); + if (have == sizeof got) { + int status = 0; + ::waitpid(pid, &status, 0); + closePair(inPipe); + closePair(outPipe); + closePair(errPipe); + throw WireOs::makeError(got[0], got[1] ? options.cwd : options.argv[0]); + } + std::unique_ptr proc(new WireProc()); + proc->pid_ = pid; + proc->stdin_ = inPipe[1]; + proc->stdout_ = outPipe[0]; + proc->stderr_ = errPipe[0]; + return proc; +} + +WireProc::~WireProc() { + closePipes(); + if (pid_ > 0 && !returncode_) { + int status = 0; + if (::waitpid(pid_, &status, WNOHANG) == pid_) returncode_ = decodeStatus(status); + } +} + +std::optional WireProc::poll() { + if (returncode_) return returncode_; + int status = 0; + pid_t r; + do { + r = ::waitpid(pid_, &status, WNOHANG); + } while (r < 0 && errno == EINTR); + if (r == pid_) returncode_ = decodeStatus(status); + else if (r < 0 && errno == ECHILD) returncode_ = 0; + return returncode_; +} + +std::optional WireProc::wait(double timeout) { + if (returncode_) return returncode_; + if (timeout < 0) { + int status = 0; + pid_t r; + do { + r = ::waitpid(pid_, &status, 0); + } while (r < 0 && errno == EINTR); + returncode_ = r == pid_ ? decodeStatus(status) : 0; + return returncode_; + } + const double deadline = WireOs::now() + timeout; + double delay = 0.0005; + while (true) { + if (poll()) return returncode_; + const double remaining = deadline - WireOs::now(); + if (remaining <= 0) return std::nullopt; + delay = std::min(delay * 2, 0.05); + WireOs::sleep(std::min(delay, remaining)); + } +} + +void WireProc::sendSignal(int signo) { + if (poll()) return; + ::kill(pid_, signo); +} + +void WireProc::terminate() { sendSignal(SIGTERM); } +void WireProc::kill() { sendSignal(SIGKILL); } + +void WireProc::writeStdin(const std::string& data) { + size_t off = 0; + while (off < data.size()) { + ssize_t n = ::write(stdin_, data.data() + off, data.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + throw WireOs::makeError(errno); + } + off += static_cast(n); + } +} + +void WireProc::closeStdin() { + if (stdin_ >= 0) { + ::close(stdin_); + stdin_ = -1; + } +} + +void WireProc::closePipes() { + closeStdin(); + if (stdout_ >= 0) { + ::close(stdout_); + stdout_ = -1; + } + if (stderr_ >= 0) { + ::close(stderr_); + stderr_ = -1; + } +} + +std::optional WireProc::readFd(int fd, size_t max) { + std::string buf(max, '\0'); + while (true) { + ssize_t n = ::read(fd, buf.data(), max); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) return std::nullopt; + throw WireOs::makeError(errno); + } + buf.resize(static_cast(n)); + return buf; + } +} + +void WireProc::setNonBlocking(int fd) { + const int flags = ::fcntl(fd, F_GETFL); + if (flags >= 0) ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +bool WireProc::waitReadable(int fd, double timeout) { + struct pollfd p; + p.fd = fd; + p.events = POLLIN; + p.revents = 0; + const int ms = timeout <= 0 ? 0 : static_cast(std::min(timeout * 1000.0, 2147483000.0)); + int r; + do { + r = ::poll(&p, 1, ms); + } while (r < 0 && errno == EINTR); + return r > 0 && (p.revents & (POLLIN | POLLHUP | POLLERR)); +} + +WireProc::Completed WireProc::run(const Options& options, double timeout, const std::string& input) { + Options opts = options; + opts.in = input.empty() && opts.in == Stream::Inherit ? Stream::Devnull : (opts.in == Stream::Inherit ? Stream::Pipe : opts.in); + if (opts.out == Stream::Inherit) opts.out = Stream::Pipe; + if (opts.err == Stream::Inherit) opts.err = Stream::Pipe; + auto proc = spawn(opts); + Completed done; + size_t inputOff = 0; + if (proc->stdinFd() >= 0 && input.empty()) proc->closeStdin(); + int outFd = proc->stdoutFd(), errFd = proc->stderrFd(); + const double deadline = WireOs::now() + timeout; + bool outOpen = outFd >= 0, errOpen = errFd >= 0; + while (outOpen || errOpen || proc->stdinFd() >= 0) { + const double remaining = deadline - WireOs::now(); + if (remaining <= 0) { + proc->kill(); + proc->wait(); + proc->closePipes(); + throw SubprocessError("Command '" + argvRepr(options.argv) + "' timed out after " + formatTimeout(timeout) + + " seconds"); + } + struct pollfd fds[3]; + int n = 0, iOut = -1, iErr = -1, iIn = -1; + if (outOpen) { fds[n] = {outFd, POLLIN, 0}; iOut = n++; } + if (errOpen) { fds[n] = {errFd, POLLIN, 0}; iErr = n++; } + if (proc->stdinFd() >= 0) { fds[n] = {proc->stdinFd(), POLLOUT, 0}; iIn = n++; } + int r = ::poll(fds, static_cast(n), static_cast(std::min(remaining, 0.25) * 1000) + 1); + if (r < 0 && errno != EINTR) break; + if (r <= 0) continue; + char buf[65536]; + if (iOut >= 0 && fds[iOut].revents) { + ssize_t k = ::read(outFd, buf, sizeof buf); + if (k > 0) done.out.append(buf, static_cast(k)); + else if (k == 0 || errno != EINTR) outOpen = false; + } + if (iErr >= 0 && fds[iErr].revents) { + ssize_t k = ::read(errFd, buf, sizeof buf); + if (k > 0) done.err.append(buf, static_cast(k)); + else if (k == 0 || errno != EINTR) errOpen = false; + } + if (iIn >= 0 && fds[iIn].revents) { + ssize_t k = ::write(proc->stdinFd(), input.data() + inputOff, input.size() - inputOff); + if (k > 0) inputOff += static_cast(k); + if (k < 0 || inputOff >= input.size()) proc->closeStdin(); + } + } + const double remaining = deadline - WireOs::now(); + if (!proc->wait(std::max(remaining, 0.001))) { + proc->kill(); + proc->wait(); + proc->closePipes(); + throw SubprocessError("Command '" + argvRepr(options.argv) + "' timed out after " + formatTimeout(timeout) + + " seconds"); + } + done.returncode = *proc->returncode(); + proc->closePipes(); + return done; +} + +// -------------------------------------------------------------------------- +// WirePty + +bool WirePty::available() { return true; } + +std::unique_ptr WirePty::spawn(const std::vector& argv, const std::string& cwd, + const std::map& env, int rows, int cols) { + ignoreSigpipeOnce(); + if (argv.empty()) throw ValueError("empty argv"); + WireProc::Options probe; + probe.argv = argv; + probe.replaceEnv = true; + probe.env = env; + int resolveErr = 0; + const std::string exe = WireProc::resolveExecutable(probe, resolveErr); + if (resolveErr) throw WireOs::makeError(resolveErr, argv[0]); + + const int master = ::posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC); + if (master < 0) throw WireOs::makeError(errno); + if (::grantpt(master) != 0 || ::unlockpt(master) != 0) { + const int err = errno; + ::close(master); + throw WireOs::makeError(err); + } + char slaveName[256]; + if (::ptsname_r(master, slaveName, sizeof slaveName) != 0) { + const int err = errno; + ::close(master); + throw WireOs::makeError(err); + } + struct winsize ws; + std::memset(&ws, 0, sizeof ws); + ws.ws_row = static_cast(rows); + ws.ws_col = static_cast(cols); + ::ioctl(master, TIOCSWINSZ, &ws); + + std::vector envList; + for (const auto& kv : env) envList.push_back(kv.first + "=" + kv.second); + std::vector envp = toArgv(envList); + std::vector args = toArgv(argv); + int report[2]; + if (::pipe2(report, O_CLOEXEC) != 0) { + const int err = errno; + ::close(master); + throw WireOs::makeError(err); + } + const pid_t pid = ::fork(); + if (pid < 0) { + const int err = errno; + ::close(master); + ::close(report[0]); + ::close(report[1]); + throw WireOs::makeError(err); + } + if (pid == 0) { + sigset_t none; + sigemptyset(&none); + ::sigprocmask(SIG_SETMASK, &none, nullptr); + ::signal(SIGPIPE, SIG_DFL); + ::setsid(); + const int slave = ::open(slaveName, O_RDWR); + if (slave < 0) _exit(255); + ::dup2(slave, 0); + ::dup2(slave, 1); + ::dup2(slave, 2); + if (slave > 2) ::close(slave); + ::ioctl(0, TIOCSCTTY, 0); + int code = 0; + int flag = 0; + if (!cwd.empty() && ::chdir(cwd.c_str()) != 0) { + code = errno; + flag = 1; + if (::write(report[1], &code, sizeof code) < 0 || ::write(report[1], &flag, sizeof flag) < 0) _exit(255); + _exit(255); + } + closeAllExcept(3, report[1]); + ::execve(exe.c_str(), args.data(), envp.data()); + code = errno; + flag = 0; + if (::write(report[1], &code, sizeof code) < 0 || ::write(report[1], &flag, sizeof flag) < 0) _exit(255); + _exit(255); + } + ::close(report[1]); + int got[2] = {0, 0}; + size_t have = 0; + while (have < sizeof got) { + ssize_t n = ::read(report[0], reinterpret_cast(got) + have, sizeof got - have); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + have += static_cast(n); + } + ::close(report[0]); + if (have == sizeof got) { + int status = 0; + ::waitpid(pid, &status, 0); + ::close(master); + throw WireOs::makeError(got[0], got[1] ? cwd : argv[0]); + } + std::unique_ptr pty(new WirePty()); + pty->pid_ = pid; + pty->master_ = master; + return pty; +} + +WirePty::~WirePty() { close(); } + +std::string WirePty::read(double timeout, bool& eof) { + eof = false; + if (!WireProc::waitReadable(master_, std::max(0.0, timeout))) return ""; + char buf[65536]; + while (true) { + ssize_t n = ::read(master_, buf, sizeof buf); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { + eof = true; + return ""; + } + return std::string(buf, static_cast(n)); + } +} + +void WirePty::writeBytes(const std::string& data) { + size_t off = 0; + while (off < data.size()) { + ssize_t n = ::write(master_, data.data() + off, data.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + throw WireOs::makeError(errno); + } + off += static_cast(n); + } +} + +void WirePty::write(const std::string& text) { writeBytes(WireText::decodeReplace(text)); } + +void WirePty::resize(int rows, int cols) { + struct winsize ws; + std::memset(&ws, 0, sizeof ws); + ws.ws_row = static_cast(rows); + ws.ws_col = static_cast(cols); + if (::ioctl(master_, TIOCSWINSZ, &ws) != 0) throw WireOs::makeError(errno); +} + +void WirePty::kill() { + if (::killpg(pid_, SIGKILL) != 0) ::kill(pid_, SIGKILL); +} + +bool WirePty::killpgTerm() { return ::killpg(pid_, SIGTERM) == 0; } + +std::optional WirePty::poll() { + if (returncode_) return returncode_; + int status = 0; + pid_t r; + do { + r = ::waitpid(pid_, &status, WNOHANG); + } while (r < 0 && errno == EINTR); + if (r == pid_) returncode_ = WIFEXITED(status) ? WEXITSTATUS(status) : -WTERMSIG(status); + else if (r < 0 && errno == ECHILD) returncode_ = 0; + return returncode_; +} + +void WirePty::close() { + if (master_ >= 0) { + ::close(master_); + master_ = -1; + } +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_proc.h b/taicpp/src/cap/wire_proc.h new file mode 100644 index 0000000..837ec49 --- /dev/null +++ b/taicpp/src/cap/wire_proc.h @@ -0,0 +1,109 @@ +#pragma once +// WireProc: subprocess.Popen / subprocess.run and PtyProc for the CAP and MCP +// drivers. Child setup uses fork/exec with a close-on-exec error pipe, so a +// missing binary or bad cwd surfaces as the same OSError text Python gives. +#include +#include +#include +#include +#include +#include + +#include "wire_os.h" + +namespace detz::wire { + +class WireProc { + public: + enum class Stream { Inherit, Pipe, Devnull, ToStdout }; + struct Options { + std::vector argv; + bool replaceEnv{false}; // true: `env` is the whole environment + std::map env; + std::string cwd; + Stream in{Stream::Inherit}; + Stream out{Stream::Inherit}; + Stream err{Stream::Inherit}; + }; + struct Completed { + int returncode{0}; + std::string out; + std::string err; + }; + + ~WireProc(); + WireProc(const WireProc&) = delete; + WireProc& operator=(const WireProc&) = delete; + + // subprocess.Popen; throws OsError. + static std::unique_ptr spawn(const Options& options); + // subprocess.run(capture_output-like, timeout): kills the child and throws + // SubprocessError("... timed out after N seconds") on timeout. + static Completed run(const Options& options, double timeout, const std::string& input = ""); + + int pid() const { return pid_; } + int stdinFd() const { return stdin_; } + int stdoutFd() const { return stdout_; } + int stderrFd() const { return stderr_; } + // Popen.poll(): empty while running; exit code, or -signal. + std::optional poll(); + // Popen.wait(timeout): empty on timeout. + std::optional wait(double timeout = -1.0); + std::optional returncode() const { return returncode_; } + void terminate(); + void kill(); + void sendSignal(int signo); + // Write to the child's stdin; throws OsError (EPIPE etc). + void writeStdin(const std::string& data); + void closeStdin(); + void closePipes(); + // os.read on a pipe fd: "" at EOF, nullopt for EAGAIN, throws OsError. + static std::optional readFd(int fd, size_t max = 65536); + static void setNonBlocking(int fd); + // select([fd], [], [], timeout): true when readable. + static bool waitReadable(int fd, double timeout); + + // PATH lookup the way Popen does it; errOut is the errno when not found. + static std::string resolveExecutable(const Options& options, int& errOut); + + protected: + WireProc() = default; + int pid_{-1}; + int stdin_{-1}, stdout_{-1}, stderr_{-1}; + std::optional returncode_; + + static int decodeStatus(int status); +}; + +// PtyProc: a child on its own pty with the pty as controlling terminal. +class WirePty { + public: + ~WirePty(); + WirePty(const WirePty&) = delete; + WirePty& operator=(const WirePty&) = delete; + + static bool available(); + // PtyProc(argv, shell=False, cwd, env, rows, cols); throws OsError. + static std::unique_ptr spawn(const std::vector& argv, const std::string& cwd, + const std::map& env, int rows, int cols); + // bytes, "" when nothing arrived in time; `eof` set at end of output. + std::string read(double timeout, bool& eof); + void write(const std::string& text); + void writeBytes(const std::string& data); + void resize(int rows, int cols); + void kill(); // SIGKILL to the process group + void close(); + int pid() const { return pid_; } + int masterFd() const { return master_; } + std::optional poll(); + // os.killpg(pid, SIGTERM); false when it failed. + bool killpgTerm(); + + private: + WirePty() = default; + int pid_{-1}; + int master_{-1}; + std::optional returncode_; +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_regex.cpp b/taicpp/src/cap/wire_regex.cpp new file mode 100644 index 0000000..2bb90ad --- /dev/null +++ b/taicpp/src/cap/wire_regex.cpp @@ -0,0 +1,292 @@ +#include "wire_regex.h" + +#include +#include +#include +#include +#include + +#include "wire_text.h" + +namespace detz::wire { + +namespace { + +std::wstring toWide(const std::string& text) { + std::wstring out; + for (char32_t cp : WireText::codepoints(text)) out += static_cast(cp); + return out; +} + +std::string toNarrow(const std::wstring& text) { + std::string out; + for (wchar_t c : text) out += WireText::encode(static_cast(c)); + return out; +} + +struct Translated { + std::wstring pattern; + std::map names; + size_t groups{0}; + int flags{0}; +}; + +bool isQuantifierAt(const std::wstring& p, size_t i) { + // p[i] == '{': true when it opens {n}, {n,}, {n,m} or {,m} (Python also + // treats {,m} as a quantifier). + size_t j = i + 1; + size_t digitsA = 0, digitsB = 0; + while (j < p.size() && std::iswdigit(p[j])) { ++j; ++digitsA; } + if (j < p.size() && p[j] == L',') { + ++j; + while (j < p.size() && std::iswdigit(p[j])) { ++j; ++digitsB; } + } + if (j >= p.size() || p[j] != L'}') return false; + return digitsA > 0 || digitsB > 0; +} + +Translated translate(const std::wstring& src, int flags) { + Translated out; + out.flags = flags; + size_t i = 0; + // Leading inline flags, e.g. (?i) or (?s). + while (src.compare(i, 2, L"(?") == 0) { + size_t j = i + 2; + int found = 0; + while (j < src.size() && (src[j] == L'i' || src[j] == L's' || src[j] == L'm')) { + found |= src[j] == L'i' ? WireRegex::kIgnoreCase : src[j] == L's' ? WireRegex::kDotAll : WireRegex::kMultiline; + ++j; + } + if (j > i + 2 && j < src.size() && src[j] == L')') { + out.flags |= found; + i = j + 1; + } else { + break; + } + } + const bool dotall = out.flags & WireRegex::kDotAll; + const bool multiline = out.flags & WireRegex::kMultiline; + bool inClass = false; + while (i < src.size()) { + const wchar_t c = src[i]; + if (c == L'\\') { + if (i + 1 >= src.size()) throw RegexError("bad escape (end of pattern) at position " + std::to_string(i)); + const wchar_t n = src[i + 1]; + if (!inClass && n == L'A') { + out.pattern += L"^"; + } else if (!inClass && n == L'Z') { + out.pattern += L"$"; + } else { + out.pattern += c; + out.pattern += n; + } + i += 2; + continue; + } + if (inClass) { + if (c == L']') inClass = false; + out.pattern += c; + ++i; + continue; + } + if (c == L'[') { + inClass = true; + out.pattern += c; + ++i; + if (i < src.size() && src[i] == L'^') { + out.pattern += L'^'; + ++i; + } + if (i < src.size() && src[i] == L']') { // leading ] is literal in Python + out.pattern += L"\\]"; + ++i; + } + continue; + } + if (c == L'(') { + if (i + 1 < src.size() && src[i + 1] == L'?') { + if (src.compare(i, 4, L"(?P<") == 0) { + const size_t close = src.find(L'>', i + 4); + if (close == std::wstring::npos) throw RegexError("missing >, unterminated name at position " + std::to_string(i + 4)); + const std::string name = toNarrow(src.substr(i + 4, close - i - 4)); + if (name.empty()) throw RegexError("missing group name at position " + std::to_string(i + 4)); + ++out.groups; + out.names[name] = out.groups; + out.pattern += L"("; + i = close + 1; + continue; + } + // (?: (?= (?! pass through; (?<= (? 0) { + --k; + if (out.pattern[k] == L'{' && (k == 0 || out.pattern[k - 1] != L'\\')) { + inQuant = true; + break; + } + if (!(std::iswdigit(out.pattern[k]) || out.pattern[k] == L',')) break; + } + out.pattern += inQuant ? std::wstring(1, c) : std::wstring(L"\\}"); + ++i; + continue; + } + out.pattern += c; + ++i; + } + if (inClass) throw RegexError("unterminated character set"); + return out; +} + +} // namespace + +struct WireRegex::Impl { + std::wregex re; + std::map names; + size_t groups{0}; + + // Builds a Match from a std match; `base` is the code point offset of the + // searched window inside the full text. + Match make(const std::wsmatch& m, size_t base) const { + Match out; + out.ok_ = true; + out.start_ = base + static_cast(m.position(0)); + out.end_ = out.start_ + static_cast(m.length(0)); + out.names_ = names; + for (size_t g = 0; g < m.size(); ++g) { + out.texts_.push_back(m[g].matched ? toNarrow(m[g].str()) : std::string()); + out.taken_.push_back(m[g].matched); + } + while (out.texts_.size() < groups + 1) { + out.texts_.push_back(std::string()); + out.taken_.push_back(false); + } + return out; + } +}; + +WireRegex::WireRegex(const std::string& pattern, int flags) : pattern_(pattern), impl_(std::make_shared()) { + Translated tr = translate(toWide(pattern), flags); + auto syntax = std::regex_constants::ECMAScript; + if (tr.flags & kIgnoreCase) syntax |= std::regex_constants::icase; + try { + try { + impl_->re.imbue(std::locale("C.UTF-8")); + } catch (const std::runtime_error&) { + } + impl_->re.assign(tr.pattern, syntax); + } catch (const std::regex_error& exc) { + throw RegexError(exc.what()); + } + impl_->names = tr.names; + impl_->groups = tr.groups; +} + +std::string WireRegex::Match::group(size_t index) const { return index < texts_.size() ? texts_[index] : std::string(); } + +bool WireRegex::Match::hasGroup(size_t index) const { return index < taken_.size() && taken_[index]; } + +Json WireRegex::Match::groupdict() const { + Json out = Json::object(); + std::vector> ordered; + for (const auto& entry : names_) ordered.emplace_back(entry.second, entry.first); + std::sort(ordered.begin(), ordered.end()); + for (const auto& entry : ordered) out.set(entry.second, hasGroup(entry.first) ? Json(texts_[entry.first]) : Json()); + return out; +} + +WireRegex::Match WireRegex::search(const std::string& text) const { + const std::wstring wide = toWide(text); + std::wsmatch m; + try { + if (std::regex_search(wide, m, impl_->re)) return impl_->make(m, 0); + } catch (const std::regex_error& exc) { + throw RegexError(exc.what()); + } + return Match(); +} + +WireRegex::Match WireRegex::match(const std::string& text) const { + const std::wstring wide = toWide(text); + std::wsmatch m; + try { + if (std::regex_search(wide, m, impl_->re, std::regex_constants::match_continuous)) return impl_->make(m, 0); + } catch (const std::regex_error& exc) { + throw RegexError(exc.what()); + } + return Match(); +} + +std::vector WireRegex::finditer(const std::string& text) const { + std::vector out; + const std::wstring wide = toWide(text); + size_t pos = 0; + try { + while (pos <= wide.size()) { + std::wsmatch m; + auto flags = pos > 0 ? std::regex_constants::match_prev_avail : std::regex_constants::match_default; + if (!std::regex_search(wide.begin() + static_cast(pos), wide.end(), m, impl_->re, flags)) break; + Match found = impl_->make(m, pos); + const bool empty = found.end_ == found.start_; + pos = empty ? found.end_ + 1 : found.end_; + out.push_back(std::move(found)); + } + } catch (const std::regex_error& exc) { + throw RegexError(exc.what()); + } + return out; +} + +std::vector WireRegex::findallGroup1(const std::string& text) const { + std::vector out; + for (const Match& m : finditer(text)) out.push_back(m.group(1)); + return out; +} + +std::string WireRegex::sub(const std::string& text, const std::function& fn) const { + const std::vector cps = WireText::codepoints(text); + std::string out; + size_t last = 0; + for (const Match& m : finditer(text)) { + for (size_t k = last; k < m.start(); ++k) out += WireText::encode(cps[k]); + out += fn(m); + last = m.end(); + } + for (size_t k = last; k < cps.size(); ++k) out += WireText::encode(cps[k]); + return out; +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_regex.h b/taicpp/src/cap/wire_regex.h new file mode 100644 index 0000000..86880e8 --- /dev/null +++ b/taicpp/src/cap/wire_regex.h @@ -0,0 +1,78 @@ +#pragma once +// WireRegex: the slice of Python's `re` that tai.py uses, over UTF-8 text. +// Patterns are translated to ECMAScript (std::wregex over code points): +// named groups (?P...) are numbered and recorded, `$` keeps Python's +// "end or before a final newline" meaning, \A and \Z map to anchors, a bare +// `{` or `}` that is not a quantifier is literal, and (?i) leading flags are +// honored. Lookbehind and backreference-by-name are NOT supported (the CAP +// manifest gate rejects both before any pattern reaches here). +#include +#include +#include +#include +#include +#include + +#include "wire_json.h" + +namespace detz::wire { + +// Python re.error. +class RegexError : public std::runtime_error { + public: + explicit RegexError(const std::string& message) : std::runtime_error(message) {} +}; + +class WireRegex { + public: + static constexpr int kIgnoreCase = 1; + static constexpr int kDotAll = 2; + static constexpr int kMultiline = 4; + + // One match. Offsets are code point indexes into the searched text. + class Match { + public: + bool matched() const { return ok_; } + explicit operator bool() const { return ok_; } + // group(0) is the whole match. Unmatched groups are "" with hasGroup false. + std::string group(size_t index) const; + bool hasGroup(size_t index) const; + // groupdict(): name -> string, or null for a group that did not take part. + Json groupdict() const; + size_t groupCount() const { return texts_.empty() ? 0 : texts_.size() - 1; } + size_t start() const { return start_; } + size_t end() const { return end_; } + + private: + friend class WireRegex; + bool ok_{false}; + size_t start_{0}, end_{0}; + std::vector texts_; + std::vector taken_; + std::map names_; + }; + + WireRegex() = default; + // re.compile; throws RegexError. + explicit WireRegex(const std::string& pattern, int flags = 0); + static WireRegex compile(const std::string& pattern, int flags = 0) { return WireRegex(pattern, flags); } + + const std::string& pattern() const { return pattern_; } + // re.search(pattern, text) + Match search(const std::string& text) const; + // re.match(pattern, text): anchored at the start. + Match match(const std::string& text) const; + // pattern.findall for patterns with exactly one group: the group texts. + std::vector findallGroup1(const std::string& text) const; + // re.finditer + std::vector finditer(const std::string& text) const; + // pattern.sub(function, text) + std::string sub(const std::string& text, const std::function& fn) const; + + private: + struct Impl; + std::string pattern_; + std::shared_ptr impl_; +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_shlex.cpp b/taicpp/src/cap/wire_shlex.cpp new file mode 100644 index 0000000..034dd8f --- /dev/null +++ b/taicpp/src/cap/wire_shlex.cpp @@ -0,0 +1,94 @@ +#include "wire_shlex.h" + +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::wire { + +std::vector WireShlex::split(const std::string& text) { + const std::vector chars = WireText::codepoints(text); + std::vector tokens; + size_t pos = 0; + // state: ' ' between tokens, 'a' in a word, '\'' or '"' in quotes, '\\' after + // an escape, 0 at end of input. + char32_t state = ' '; + auto isWhitespace = [](char32_t c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }; + while (true) { + std::string token; + bool quoted = false; + char32_t escapedState = ' '; + bool emitted = false; + while (true) { + const bool eof = pos >= chars.size(); + const char32_t c = eof ? 0 : chars[pos++]; + if (state == 0) { + token.clear(); + break; + } else if (state == ' ') { + if (eof) { + state = 0; + break; + } else if (isWhitespace(c)) { + if (!token.empty() || quoted) { + emitted = true; + break; + } + continue; + } else if (c == '\\') { + escapedState = 'a'; + state = c; + } else if (c == '\'' || c == '"') { + state = c; + } else { + token = WireText::encode(c); + state = 'a'; + } + } else if (state == '\'' || state == '"') { + quoted = true; + if (eof) throw ValueError("No closing quotation"); + if (c == state) { + state = 'a'; + } else if (c == '\\' && state == '"') { + escapedState = state; + state = c; + } else { + token += WireText::encode(c); + } + } else if (state == '\\') { + if (eof) throw ValueError("No escaped character"); + if ((escapedState == '\'' || escapedState == '"') && c != state && c != escapedState) + token += WireText::encode(state); + token += WireText::encode(c); + state = escapedState; + } else { // 'a' + if (eof) { + state = 0; + break; + } else if (isWhitespace(c)) { + state = ' '; + if (!token.empty() || quoted) { + emitted = true; + break; + } + continue; + } else if (c == '\'' || c == '"') { + state = c; + } else if (c == '\\') { + escapedState = 'a'; + state = c; + } else { + token += WireText::encode(c); + } + } + } + if (!emitted) { + // end of input: a quoted empty word still counts, nothing else does + if (!token.empty() || quoted) tokens.push_back(token); + break; + } + tokens.push_back(token); + } + return tokens; +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_shlex.h b/taicpp/src/cap/wire_shlex.h new file mode 100644 index 0000000..288cd1e --- /dev/null +++ b/taicpp/src/cap/wire_shlex.h @@ -0,0 +1,15 @@ +#pragma once +// WireShlex: shlex.split(s) (posix mode, no comments), ported from CPython's +// shlex.read_token state machine, including its ValueError messages. +#include +#include + +namespace detz::wire { + +class WireShlex { + public: + // Throws ValueError("No closing quotation") / ValueError("No escaped character"). + static std::vector split(const std::string& text); +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_text.cpp b/taicpp/src/cap/wire_text.cpp new file mode 100644 index 0000000..b7a4cbd --- /dev/null +++ b/taicpp/src/cap/wire_text.cpp @@ -0,0 +1,547 @@ +#include "wire_text.h" + +#include +#include +#include +#include +#include + +namespace detz::wire { + +namespace { + +// One decoder step. `used` bytes consumed; `bad` true when the step is an +// ill-formed subpart (Python emits one U+FFFD per maximal subpart). +struct Step { + char32_t cp{0}; + size_t used{1}; + bool bad{false}; + const char* reason{nullptr}; // set when bad +}; + +Step decodeAt(const std::string& s, size_t i) { + Step st; + const unsigned char b0 = static_cast(s[i]); + const size_t left = s.size() - i; + if (b0 < 0x80) { + st.cp = b0; + return st; + } + size_t need = 0; + unsigned char lo = 0x80, hi = 0xBF; + char32_t cp = 0; + if (b0 >= 0xC2 && b0 <= 0xDF) { + need = 1; + cp = b0 & 0x1F; + } else if (b0 >= 0xE0 && b0 <= 0xEF) { + need = 2; + cp = b0 & 0x0F; + if (b0 == 0xE0) lo = 0xA0; + if (b0 == 0xED) hi = 0x9F; + } else if (b0 >= 0xF0 && b0 <= 0xF4) { + need = 3; + cp = b0 & 0x07; + if (b0 == 0xF0) lo = 0x90; + if (b0 == 0xF4) hi = 0x8F; + } else { + st.bad = true; + st.reason = "invalid start byte"; + return st; + } + size_t used = 1; + for (size_t k = 0; k < need; ++k) { + if (used >= left) { + st.bad = true; + st.used = used; + st.reason = "unexpected end of data"; + return st; + } + const unsigned char b = static_cast(s[i + used]); + const unsigned char l = (k == 0) ? lo : 0x80; + const unsigned char h = (k == 0) ? hi : 0xBF; + if (b < l || b > h) { + st.bad = true; + st.used = used; + st.reason = "invalid continuation byte"; + return st; + } + cp = (cp << 6) | (b & 0x3F); + ++used; + } + st.cp = cp; + st.used = used; + return st; +} + +} // namespace + +std::string WireText::encode(char32_t cp) { + std::string out; + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + return out; +} + +std::vector WireText::codepoints(const std::string& text) { + std::vector out; + size_t i = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + out.push_back(st.bad ? U'�' : st.cp); + i += st.used; + } + return out; +} + +std::string WireText::fromCodepoints(const std::vector& cps) { + std::string out; + for (char32_t cp : cps) out += encode(cp); + return out; +} + +size_t WireText::length(const std::string& text) { + size_t count = 0, i = 0; + while (i < text.size()) { + i += decodeAt(text, i).used; + ++count; + } + return count; +} + +static size_t byteOffset(const std::string& text, size_t count) { + size_t i = 0, seen = 0; + while (i < text.size() && seen < count) { + i += decodeAt(text, i).used; + ++seen; + } + return i; +} + +std::string WireText::head(const std::string& text, size_t count) { return text.substr(0, byteOffset(text, count)); } + +std::string WireText::tail(const std::string& text, size_t count) { + const size_t total = length(text); + if (count >= total) return text; + return text.substr(byteOffset(text, total - count)); +} + +std::string WireText::slice(const std::string& text, size_t start, size_t end) { + const size_t a = byteOffset(text, start); + if (end == std::string::npos) return text.substr(a); + const size_t b = byteOffset(text, end); + return b > a ? text.substr(a, b - a) : std::string(); +} + +std::string WireText::decodeReplace(const std::string& bytes) { + std::string out; + out.reserve(bytes.size()); + size_t i = 0; + while (i < bytes.size()) { + Step st = decodeAt(bytes, i); + if (st.bad) + out += "\xEF\xBF\xBD"; + else + out.append(bytes, i, st.used); + i += st.used; + } + return out; +} + +void WireText::decodeChunk(std::string& buffer, std::vector& out) { + size_t i = 0; + while (i < buffer.size()) { + Step st = decodeAt(buffer, i); + if (st.bad && st.reason && std::strcmp(st.reason, "unexpected end of data") == 0) break; // wait for more bytes + out.push_back(st.bad ? U'\uFFFD' : st.cp); + i += st.used; + } + buffer.erase(0, i); +} + +bool WireText::decodeStrict(const std::string& bytes, std::string& out, std::string& message) { + size_t i = 0; + while (i < bytes.size()) { + Step st = decodeAt(bytes, i); + if (st.bad) { + char buf[160]; + if (st.used == 1) + std::snprintf(buf, sizeof buf, "'utf-8' codec can't decode byte 0x%02x in position %zu: %s", + static_cast(bytes[i]), i, st.reason); + else + std::snprintf(buf, sizeof buf, "'utf-8' codec can't decode bytes in position %zu-%zu: %s", i, + i + st.used - 1, st.reason); + message = buf; + return false; + } + i += st.used; + } + out = bytes; + return true; +} + +bool WireText::isSpace(char32_t cp) { + if (cp >= 0x09 && cp <= 0x0D) return true; + if (cp >= 0x1C && cp <= 0x20) return true; + switch (cp) { + case 0x85: case 0xA0: case 0x1680: case 0x2028: case 0x2029: case 0x202F: case 0x205F: case 0x3000: + return true; + default: + break; + } + return cp >= 0x2000 && cp <= 0x200A; +} + +std::string WireText::lstrip(const std::string& text) { + size_t i = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + if (st.bad || !isSpace(st.cp)) break; + i += st.used; + } + return text.substr(i); +} + +std::string WireText::rstrip(const std::string& text) { + // Walk forward remembering the end of the last non-space code point. + size_t i = 0, keep = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + i += st.used; + if (st.bad || !isSpace(st.cp)) keep = i; + } + return text.substr(0, keep); +} + +std::string WireText::strip(const std::string& text) { return lstrip(rstrip(text)); } + +std::string WireText::rstripChars(const std::string& text, const std::string& chars) { + size_t end = text.size(); + while (end > 0 && chars.find(text[end - 1]) != std::string::npos) --end; + return text.substr(0, end); +} + +std::string WireText::lstripChars(const std::string& text, const std::string& chars) { + size_t start = 0; + while (start < text.size() && chars.find(text[start]) != std::string::npos) ++start; + return text.substr(start); +} + +std::string WireText::stripChars(const std::string& text, const std::string& chars) { + return lstripChars(rstripChars(text, chars), chars); +} + +static char32_t lowerCp(char32_t c) { + if (c >= 'A' && c <= 'Z') return c + 32; + if (c >= 0xC0 && c <= 0xDE && c != 0xD7) return c + 32; + if (c >= 0x391 && c <= 0x3A9 && c != 0x3A2) return c + 32; + if (c >= 0x410 && c <= 0x42F) return c + 32; + if (c >= 0x400 && c <= 0x40F) return c + 80; + return c; +} + +static char32_t upperCp(char32_t c) { + if (c >= 'a' && c <= 'z') return c - 32; + if (c >= 0xE0 && c <= 0xFE && c != 0xF7) return c - 32; + if (c >= 0x3B1 && c <= 0x3C9 && c != 0x3C2) return c - 32; + if (c >= 0x430 && c <= 0x44F) return c - 32; + if (c >= 0x450 && c <= 0x45F) return c - 80; + return c; +} + +std::string WireText::lower(const std::string& text) { + std::string out; + size_t i = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + if (st.bad) + out.append(text, i, st.used); + else + out += encode(lowerCp(st.cp)); + i += st.used; + } + return out; +} + +std::string WireText::upper(const std::string& text) { + std::string out; + size_t i = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + if (st.bad) + out.append(text, i, st.used); + else + out += encode(upperCp(st.cp)); + i += st.used; + } + return out; +} + +bool WireText::startsWith(const std::string& text, const std::string& prefix) { + return text.size() >= prefix.size() && text.compare(0, prefix.size(), prefix) == 0; +} + +bool WireText::endsWith(const std::string& text, const std::string& suffix) { + return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +bool WireText::contains(const std::string& text, const std::string& needle) { + return text.find(needle) != std::string::npos; +} + +std::string WireText::replaceAll(std::string text, const std::string& from, const std::string& to) { + if (from.empty()) return text; + size_t pos = 0; + while ((pos = text.find(from, pos)) != std::string::npos) { + text.replace(pos, from.size(), to); + pos += to.size(); + } + return text; +} + +std::vector WireText::splitWhitespace(const std::string& text) { + std::vector out; + std::string cur; + bool in = false; + size_t i = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + const bool sp = !st.bad && isSpace(st.cp); + if (sp) { + if (in) out.push_back(cur); + cur.clear(); + in = false; + } else { + cur.append(text, i, st.used); + in = true; + } + i += st.used; + } + if (in) out.push_back(cur); + return out; +} + +std::vector WireText::split(const std::string& text, const std::string& sep, int maxsplit) { + std::vector out; + size_t pos = 0; + int made = 0; + while (true) { + if (maxsplit >= 0 && made >= maxsplit) break; + size_t at = text.find(sep, pos); + if (at == std::string::npos) break; + out.push_back(text.substr(pos, at - pos)); + pos = at + sep.size(); + ++made; + } + out.push_back(text.substr(pos)); + return out; +} + +std::vector WireText::splitLines(const std::string& text, bool keepends) { + std::vector out; + std::string cur; + size_t i = 0; + while (i < text.size()) { + Step st = decodeAt(text, i); + const char32_t c = st.bad ? 0xFFFD : st.cp; + const bool brk = c == '\n' || c == '\r' || c == 0x0B || c == 0x0C || c == 0x1C || c == 0x1D || c == 0x1E || + c == 0x85 || c == 0x2028 || c == 0x2029; + if (!brk) { + cur.append(text, i, st.used); + i += st.used; + continue; + } + size_t used = st.used; + if (c == '\r' && i + used < text.size() && text[i + used] == '\n') ++used; + if (keepends) cur.append(text, i, used); + out.push_back(cur); + cur.clear(); + i += used; + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +std::string WireText::join(const std::vector& parts, const std::string& sep) { + std::string out; + for (size_t i = 0; i < parts.size(); ++i) { + if (i) out += sep; + out += parts[i]; + } + return out; +} + +bool WireText::isPrintable(char32_t cp) { + if (cp < 0x20 || cp == 0x7F) return false; + if (cp < 0x7F) return true; + if (cp <= 0xA0 || cp == 0xAD) return false; + if (cp >= 0x600 && cp <= 0x605) return false; + if (cp == 0x61C || cp == 0x6DD || cp == 0x70F || cp == 0x180E || cp == 0x1680) return false; + if (cp >= 0x2000 && cp <= 0x200F) return false; + if (cp >= 0x2028 && cp <= 0x202F) return false; + if (cp >= 0x205F && cp <= 0x206F) return false; + if (cp == 0x3000 || cp == 0xFEFF) return false; + if (cp >= 0xD800 && cp <= 0xF8FF) return false; + if (cp >= 0xFFF9 && cp <= 0xFFFB) return false; + if (cp == 0xFFFE || cp == 0xFFFF) return false; + if (cp == 0xE0001 || (cp >= 0xE0020 && cp <= 0xE007F)) return false; + if (cp >= 0xF0000) return false; + return true; +} + +std::string WireText::repr(const std::string& text) { + const bool hasSingle = text.find('\'') != std::string::npos; + const bool hasDouble = text.find('"') != std::string::npos; + const char quote = (hasSingle && !hasDouble) ? '"' : '\''; + std::string out(1, quote); + size_t i = 0; + char buf[16]; + while (i < text.size()) { + Step st = decodeAt(text, i); + i += st.used; + const char32_t c = st.bad ? 0xFFFD : st.cp; + if (c == static_cast(quote) || c == '\\') { + out += '\\'; + out += static_cast(c); + } else if (c == '\t') { + out += "\\t"; + } else if (c == '\n') { + out += "\\n"; + } else if (c == '\r') { + out += "\\r"; + } else if (isPrintable(c)) { + out += encode(c); + } else if (c < 0x100) { + std::snprintf(buf, sizeof buf, "\\x%02x", static_cast(c)); + out += buf; + } else if (c < 0x10000) { + std::snprintf(buf, sizeof buf, "\\u%04x", static_cast(c)); + out += buf; + } else { + std::snprintf(buf, sizeof buf, "\\U%08x", static_cast(c)); + out += buf; + } + } + out += quote; + return out; +} + +std::string WireText::fmtFloat(double value, int decimals) { + char buf[512]; + std::snprintf(buf, sizeof buf, "%.*f", decimals, value); + return buf; +} + +std::string WireText::reprFloat(double value) { + if (std::isnan(value)) return "nan"; + if (std::isinf(value)) return value < 0 ? "-inf" : "inf"; + char buf[64]; + auto res = std::to_chars(buf, buf + sizeof buf, value, std::chars_format::scientific); + std::string sci(buf, res.ptr); + bool neg = false; + if (!sci.empty() && sci[0] == '-') { + neg = true; + sci.erase(0, 1); + } + const size_t epos = sci.find('e'); + std::string mant = sci.substr(0, epos); + const int exp10 = std::stoi(sci.substr(epos + 1)); + std::string digits; + for (char ch : mant) + if (ch != '.') digits += ch; + std::string out; + if (exp10 >= -4 && exp10 < 16) { + if (exp10 >= 0) { + if (static_cast(digits.size()) <= exp10 + 1) { + out = digits + std::string(exp10 + 1 - digits.size(), '0') + ".0"; + } else { + out = digits.substr(0, exp10 + 1) + "." + digits.substr(exp10 + 1); + } + } else { + out = "0." + std::string(-exp10 - 1, '0') + digits; + } + } else { + out = digits.substr(0, 1); + if (digits.size() > 1) out += "." + digits.substr(1); + char eb[16]; + std::snprintf(eb, sizeof eb, "e%c%02d", exp10 < 0 ? '-' : '+', exp10 < 0 ? -exp10 : exp10); + out += eb; + } + return neg ? "-" + out : out; +} + +bool WireText::isDigits(const std::string& text) { + if (text.empty()) return false; + for (char c : text) + if (c < '0' || c > '9') return false; + return true; +} + +std::string WireText::quotePlus(const std::string& text) { + std::string out; + char buf[8]; + for (unsigned char c : text) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || + c == '-' || c == '~') { + out += static_cast(c); + } else if (c == ' ') { + out += '+'; + } else { + std::snprintf(buf, sizeof buf, "%%%02X", c); + out += buf; + } + } + return out; +} + +std::string WireText::unquotePlus(const std::string& text) { + std::string raw; + for (size_t i = 0; i < text.size(); ++i) { + const char c = text[i]; + if (c == '+') { + raw += ' '; + } else if (c == '%' && i + 2 < text.size() && std::isxdigit(static_cast(text[i + 1])) && + std::isxdigit(static_cast(text[i + 2]))) { + raw += static_cast(std::stoi(text.substr(i + 1, 2), nullptr, 16)); + i += 2; + } else { + raw += c; + } + } + return decodeReplace(raw); +} + +std::string WireText::dirname(const std::string& path) { + const size_t slash = path.rfind('/'); + if (slash == std::string::npos) return ""; + std::string head = path.substr(0, slash + 1); + if (head.find_first_not_of('/') != std::string::npos) { + while (head.size() > 1 && head.back() == '/') head.pop_back(); + } + return head; +} + +std::string WireText::basename(const std::string& path) { + const size_t slash = path.rfind('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); +} + +std::string WireText::pathJoin(const std::string& base, const std::string& tailPart) { + if (!tailPart.empty() && tailPart[0] == '/') return tailPart; + if (base.empty() || base.back() == '/') return base + tailPart; + return base + "/" + tailPart; +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_text.h b/taicpp/src/cap/wire_text.h new file mode 100644 index 0000000..9d2b33e --- /dev/null +++ b/taicpp/src/cap/wire_text.h @@ -0,0 +1,74 @@ +#pragma once +// WireText: Python str semantics over UTF-8 std::string. tai.py counts and +// slices strings by code point, strips Unicode whitespace and decodes bytes +// with errors="replace"; every CAP and MCP message cap is expressed in those +// terms, so the C++ port must count the same way. +#include +#include +#include + +namespace detz::wire { + +class WireText { + public: + // Number of code points (Python len(str)). Invalid bytes count one each. + static size_t length(const std::string& text); + // text[:count] by code points. + static std::string head(const std::string& text, size_t count); + // text[-count:] by code points. + static std::string tail(const std::string& text, size_t count); + // text[start:end] by code points (end npos = to the end). + static std::string slice(const std::string& text, size_t start, size_t end = std::string::npos); + // Python bytes.decode("utf-8", "replace"). + static std::string decodeReplace(const std::string& bytes); + // codecs.getincrementaldecoder("utf-8")(errors="replace").decode(data, False): + // consumes `buffer`, appends decoded code points to `out`, and leaves an + // incomplete trailing sequence in `buffer` for the next call. + static void decodeChunk(std::string& buffer, std::vector& out); + // Python bytes.decode("utf-8") strict: returns false and fills `message` + // with the UnicodeDecodeError text when invalid. + static bool decodeStrict(const std::string& bytes, std::string& out, std::string& message); + static bool isSpace(char32_t cp); + static std::string strip(const std::string& text); + static std::string lstrip(const std::string& text); + static std::string rstrip(const std::string& text); + // strip of a fixed set of ASCII characters (Python rstrip("\n") etc). + static std::string rstripChars(const std::string& text, const std::string& chars); + static std::string lstripChars(const std::string& text, const std::string& chars); + static std::string stripChars(const std::string& text, const std::string& chars); + static std::string lower(const std::string& text); + static std::string upper(const std::string& text); + static bool startsWith(const std::string& text, const std::string& prefix); + static bool endsWith(const std::string& text, const std::string& suffix); + static bool contains(const std::string& text, const std::string& needle); + static std::string replaceAll(std::string text, const std::string& from, const std::string& to); + // Python str.split() with no argument (runs of whitespace). + static std::vector splitWhitespace(const std::string& text); + // Python str.split(sep, maxsplit). + static std::vector split(const std::string& text, const std::string& sep, int maxsplit = -1); + // Python str.splitlines(keepends). + static std::vector splitLines(const std::string& text, bool keepends = false); + static std::string join(const std::vector& parts, const std::string& sep); + // Code points of a UTF-8 string, and the reverse. + static std::vector codepoints(const std::string& text); + static std::string encode(char32_t cp); + static std::string fromCodepoints(const std::vector& cps); + // Python repr(str): quotes, escapes, printable test. + static std::string repr(const std::string& text); + static bool isPrintable(char32_t cp); + // "%.Nf" formatting. + static std::string fmtFloat(double value, int decimals); + // Python repr(float). + static std::string reprFloat(double value); + // True when text is non-empty and only ASCII digits. + static bool isDigits(const std::string& text); + // urllib.parse.quote_plus / unquote_plus. + static std::string quotePlus(const std::string& text); + static std::string unquotePlus(const std::string& text); + // os.path helpers over plain strings. + static std::string dirname(const std::string& path); + static std::string basename(const std::string& path); + static std::string pathJoin(const std::string& base, const std::string& tail); +}; + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_toml.cpp b/taicpp/src/cap/wire_toml.cpp new file mode 100644 index 0000000..191f909 --- /dev/null +++ b/taicpp/src/cap/wire_toml.cpp @@ -0,0 +1,818 @@ +#include "wire_toml.h" + +#include +#include +#include +#include +#include +#include + +#include "wire_text.h" + +namespace detz::wire { + +namespace { + +using Key = std::vector; + +enum Flag { kFrozen = 0, kExplicitNest = 1 }; + +struct KeyKeyError {}; // Python KeyError inside the nest helpers + +class Flags { + public: + void addPending(const Key& key, int flag) { pending_.insert({key, flag}); } + void finalizePending() { + for (const auto& item : pending_) set(item.first, item.second, false); + pending_.clear(); + } + void unsetAll(const Key& key) { + Node* cont = &root_; + for (size_t i = 0; i + 1 < key.size(); ++i) { + auto it = cont->nested.find(key[i]); + if (it == cont->nested.end()) return; + cont = &it->second; + } + cont->nested.erase(key.back()); + } + void set(const Key& key, int flag, bool recursive) { + Node* cont = &root_; + for (size_t i = 0; i + 1 < key.size(); ++i) cont = &cont->nested[key[i]]; + Node& stem = cont->nested[key.back()]; + (recursive ? stem.recursiveFlags : stem.flags).insert(flag); + } + bool is(const Key& key, int flag) const { + if (key.empty()) return false; + const Node* cont = &root_; + for (size_t i = 0; i + 1 < key.size(); ++i) { + auto it = cont->nested.find(key[i]); + if (it == cont->nested.end()) return false; + if (it->second.recursiveFlags.count(flag)) return true; + cont = &it->second; + } + auto it = cont->nested.find(key.back()); + if (it == cont->nested.end()) return false; + return it->second.flags.count(flag) || it->second.recursiveFlags.count(flag); + } + + private: + struct Node { + std::set flags, recursiveFlags; + std::map nested; + }; + Node root_; + std::set> pending_; +}; + +class NestedDict { + public: + NestedDict() : dict(Json::object()) {} + Json dict; + + Json* getOrCreateNest(const Key& key, bool accessLists = true) { + Json* cont = &dict; + for (const std::string& k : key) { + Json* hit = cont->find(k); + if (!hit) { + cont->set(k, Json::object()); + hit = cont->find(k); + } + cont = hit; + if (accessLists && cont->isArray()) { + if (cont->items().empty()) throw KeyKeyError(); + cont = &cont->items().back(); + } + if (!cont->isObject()) throw KeyKeyError(); + } + return cont; + } + + void appendNestToList(const Key& key) { + Key parent(key.begin(), key.end() - 1); + Json* cont = getOrCreateNest(parent); + Json* list = cont->find(key.back()); + if (list) { + if (!list->isArray()) throw KeyKeyError(); + list->push(Json::object()); + } else { + Json arr = Json::array(); + arr.push(Json::object()); + cont->set(key.back(), arr); + } + } +}; + +std::string keyRepr(const Key& key) { + std::string out = "("; + for (size_t i = 0; i < key.size(); ++i) { + if (i) out += ", "; + out += WireText::repr(key[i]); + } + if (key.size() == 1) out += ","; + return out + ")"; +} + +bool isDigit(char32_t c) { return c >= '0' && c <= '9'; } +bool isHex(char32_t c) { return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } +bool isBare(char32_t c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || isDigit(c) || c == '-' || c == '_'; +} +bool isCtrl(char32_t c) { return c < 32 || c == 127; } + +class Parser { + public: + explicit Parser(const std::string& text) : doc_(WireText::replaceAll(text, "\r\n", "\n")) { + for (char32_t cp : WireText::codepoints(doc_)) src_.push_back(cp); + } + + Json run() { + size_t pos = 0; + Key header; + while (true) { + pos = skipChars(pos, kWs); + if (pos >= src_.size()) break; + char32_t c = src_[pos]; + if (c == '\n') { + ++pos; + continue; + } + if (isBare(c) || c == '"' || c == '\'') { + pos = keyValueRule(pos, header); + pos = skipChars(pos, kWs); + } else if (c == '[') { + const bool second = pos + 1 < src_.size() && src_[pos + 1] == '['; + flags_.finalizePending(); + if (second) + pos = createListRule(pos, header); + else + pos = createDictRule(pos, header); + pos = skipChars(pos, kWs); + } else if (c != '#') { + fail("Invalid statement", pos); + } + pos = skipComment(pos); + if (pos >= src_.size()) break; + if (src_[pos] != '\n') fail("Expected newline or end of document after a statement", pos); + ++pos; + } + return data_.dict; + } + + private: + static constexpr const char* kWs = " \t"; + static constexpr const char* kWsNl = " \t\n"; + std::string doc_; + std::u32string src_; + Flags flags_; + NestedDict data_; + + [[noreturn]] void fail(const std::string& msg, size_t pos) const { + size_t line = 1, lastNl = std::u32string::npos; + for (size_t i = 0; i < pos && i < src_.size(); ++i) + if (src_[i] == '\n') { + ++line; + lastNl = i; + } + std::string where; + if (pos >= src_.size()) { + where = "end of document"; + } else { + const size_t col = line == 1 ? pos + 1 : pos - lastNl; + where = "line " + std::to_string(line) + ", column " + std::to_string(col); + } + throw TomlError(msg + " (at " + where + ")"); + } + + static bool inSet(char32_t c, const char* set) { + for (const char* p = set; *p; ++p) + if (static_cast(static_cast(*p)) == c) return true; + return false; + } + + size_t skipChars(size_t pos, const char* set) const { + while (pos < src_.size() && inSet(src_[pos], set)) ++pos; + return pos; + } + + bool startsWith(size_t pos, const char* lit) const { + for (size_t i = 0; lit[i]; ++i) + if (pos + i >= src_.size() || src_[pos + i] != static_cast(lit[i])) return false; + return true; + } + + static std::string enc(char32_t c) { return WireText::encode(c); } + std::string slice(size_t a, size_t b) const { + std::string out; + for (size_t i = a; i < b && i < src_.size(); ++i) out += enc(src_[i]); + return out; + } + + // error_on: ASCII control chars minus tab; `allowNewline` removes \n. + bool illegal(char32_t c, bool allowNewline) const { + if (!isCtrl(c)) return false; + if (c == '\t') return false; + if (allowNewline && c == '\n') return false; + return true; + } + + size_t findStr(const char* lit, size_t pos) const { + const std::string needle(lit); + for (size_t i = pos; i + needle.size() <= src_.size(); ++i) { + bool ok = true; + for (size_t k = 0; k < needle.size(); ++k) + if (src_[i + k] != static_cast(needle[k])) { + ok = false; + break; + } + if (ok) return i; + } + return std::u32string::npos; + } + + size_t skipUntil(size_t pos, const char* expect, bool allowNewline, bool errorOnEof) const { + size_t newPos = findStr(expect, pos); + if (newPos == std::u32string::npos) { + newPos = src_.size(); + if (errorOnEof) fail("Expected " + WireText::repr(expect), newPos); + } + for (size_t i = pos; i < newPos; ++i) + if (illegal(src_[i], allowNewline)) fail("Found invalid character " + WireText::repr(enc(src_[i])), i); + return newPos; + } + + size_t skipComment(size_t pos) const { + if (pos < src_.size() && src_[pos] == '#') return skipUntil(pos + 1, "\n", false, false); + return pos; + } + + size_t skipCommentsAndArrayWs(size_t pos) const { + while (true) { + const size_t before = pos; + pos = skipChars(pos, kWsNl); + pos = skipComment(pos); + if (pos == before) return pos; + } + } + + size_t createDictRule(size_t pos, Key& headerOut) { + ++pos; + pos = skipChars(pos, kWs); + Key key; + pos = parseKey(pos, key); + if (flags_.is(key, kExplicitNest) || flags_.is(key, kFrozen)) fail("Cannot declare " + keyRepr(key) + " twice", pos); + flags_.set(key, kExplicitNest, false); + try { + data_.getOrCreateNest(key); + } catch (const KeyKeyError&) { + fail("Cannot overwrite a value", pos); + } + if (!startsWith(pos, "]")) fail("Expected ']' at the end of a table declaration", pos); + headerOut = key; + return pos + 1; + } + + size_t createListRule(size_t pos, Key& headerOut) { + pos += 2; + pos = skipChars(pos, kWs); + Key key; + pos = parseKey(pos, key); + if (flags_.is(key, kFrozen)) fail("Cannot mutate immutable namespace " + keyRepr(key), pos); + flags_.unsetAll(key); + flags_.set(key, kExplicitNest, false); + try { + data_.appendNestToList(key); + } catch (const KeyKeyError&) { + fail("Cannot overwrite a value", pos); + } + if (!startsWith(pos, "]]")) fail("Expected ']]' at the end of an array declaration", pos); + headerOut = key; + return pos + 2; + } + + size_t keyValueRule(size_t pos, const Key& header) { + Key key; + Json value; + pos = parseKeyValuePair(pos, key, value); + Key keyParent(key.begin(), key.end() - 1); + const std::string keyStem = key.back(); + Key absParent = header; + absParent.insert(absParent.end(), keyParent.begin(), keyParent.end()); + for (size_t i = 1; i < key.size(); ++i) { + Key contKey = header; + contKey.insert(contKey.end(), key.begin(), key.begin() + static_cast(i)); + if (flags_.is(contKey, kExplicitNest)) fail("Cannot redefine namespace " + keyRepr(contKey), pos); + flags_.addPending(contKey, kExplicitNest); + } + if (flags_.is(absParent, kFrozen)) fail("Cannot mutate immutable namespace " + keyRepr(absParent), pos); + Json* nest = nullptr; + try { + nest = data_.getOrCreateNest(absParent); + } catch (const KeyKeyError&) { + fail("Cannot overwrite a value", pos); + } + if (nest->has(keyStem)) fail("Cannot overwrite a value", pos); + if (value.isObject() || value.isArray()) { + Key full = header; + full.insert(full.end(), key.begin(), key.end()); + flags_.set(full, kFrozen, true); + } + nest->set(keyStem, value); + return pos; + } + + size_t parseKeyValuePair(size_t pos, Key& key, Json& value) { + pos = parseKey(pos, key); + if (pos >= src_.size() || src_[pos] != '=') fail("Expected '=' after a key in a key/value pair", pos); + ++pos; + pos = skipChars(pos, kWs); + return parseValue(pos, value); + } + + size_t parseKey(size_t pos, Key& key) { + std::string part; + pos = parseKeyPart(pos, part); + key.assign(1, part); + pos = skipChars(pos, kWs); + while (true) { + if (pos >= src_.size() || src_[pos] != '.') return pos; + ++pos; + pos = skipChars(pos, kWs); + pos = parseKeyPart(pos, part); + key.push_back(part); + pos = skipChars(pos, kWs); + } + } + + size_t parseKeyPart(size_t pos, std::string& out) { + if (pos < src_.size() && isBare(src_[pos])) { + const size_t start = pos; + while (pos < src_.size() && isBare(src_[pos])) ++pos; + out = slice(start, pos); + return pos; + } + if (pos < src_.size() && src_[pos] == '\'') return parseLiteralStr(pos, out); + if (pos < src_.size() && src_[pos] == '"') return parseBasicStr(pos + 1, false, out); + fail("Invalid initial character for a key part", pos); + } + + size_t parseArray(size_t pos, Json& out) { + ++pos; + out = Json::array(); + pos = skipCommentsAndArrayWs(pos); + if (startsWith(pos, "]")) return pos + 1; + while (true) { + Json val; + pos = parseValue(pos, val); + out.push(val); + pos = skipCommentsAndArrayWs(pos); + const char32_t c = pos < src_.size() ? src_[pos] : 0; + if (c == ']' && pos < src_.size()) return pos + 1; + if (c != ',' || pos >= src_.size()) fail("Unclosed array", pos); + ++pos; + pos = skipCommentsAndArrayWs(pos); + if (startsWith(pos, "]")) return pos + 1; + } + } + + size_t parseInlineTable(size_t pos, Json& out) { + ++pos; + NestedDict nested; + Flags flags; + pos = skipChars(pos, kWs); + if (startsWith(pos, "}")) { + out = nested.dict; + return pos + 1; + } + while (true) { + Key key; + Json value; + pos = parseKeyValuePair(pos, key, value); + Key keyParent(key.begin(), key.end() - 1); + const std::string keyStem = key.back(); + if (flags.is(key, kFrozen)) fail("Cannot mutate immutable namespace " + keyRepr(key), pos); + Json* nest = nullptr; + try { + nest = nested.getOrCreateNest(keyParent, false); + } catch (const KeyKeyError&) { + fail("Cannot overwrite a value", pos); + } + if (nest->has(keyStem)) fail("Duplicate inline table key " + WireText::repr(keyStem), pos); + nest->set(keyStem, value); + pos = skipChars(pos, kWs); + const char32_t c = pos < src_.size() ? src_[pos] : 0; + if (c == '}' && pos < src_.size()) { + out = nested.dict; + return pos + 1; + } + if (c != ',' || pos >= src_.size()) fail("Unclosed inline table", pos); + if (value.isObject() || value.isArray()) flags.set(key, kFrozen, true); + ++pos; + pos = skipChars(pos, kWs); + } + } + + size_t parseHexChar(size_t pos, size_t len, std::string& out) const { + bool ok = pos + len <= src_.size(); + if (ok) + for (size_t i = 0; i < len; ++i) + if (!isHex(src_[pos + i])) ok = false; + if (!ok) fail("Invalid hex value", pos); + char32_t v = 0; + for (size_t i = 0; i < len; ++i) { + const char32_t c = src_[pos + i]; + v = v * 16 + (isDigit(c) ? c - '0' : (c | 0x20) - 'a' + 10); + } + pos += len; + if (!(v <= 55295 || (v >= 57344 && v <= 1114111))) fail("Escaped character is not a Unicode scalar value", pos); + out = enc(v); + return pos; + } + + size_t parseBasicStrEscape(size_t pos, bool multiline, std::string& out) const { + const char32_t a = pos < src_.size() ? src_[pos] : 0; + const char32_t b = pos + 1 < src_.size() ? src_[pos + 1] : 0; + const bool have2 = pos + 1 < src_.size(); + pos += 2; + if (multiline && have2 && (b == ' ' || b == '\t' || b == '\n')) { + if (b != '\n') { + pos = skipChars(pos, kWs); + if (pos >= src_.size()) { + out.clear(); + return pos; + } + if (src_[pos] != '\n') fail("Unescaped '\\' in a string", pos); + ++pos; + } + pos = skipChars(pos, kWsNl); + out.clear(); + return pos; + } + if (have2 && b == 'u') return parseHexChar(pos, 4, out); + if (have2 && b == 'U') return parseHexChar(pos, 8, out); + (void)a; + if (have2) { + switch (b) { + case 'b': out = "\b"; return pos; + case 't': out = "\t"; return pos; + case 'n': out = "\n"; return pos; + case 'f': out = "\f"; return pos; + case 'r': out = "\r"; return pos; + case '"': out = "\""; return pos; + case '\\': out = "\\"; return pos; + default: break; + } + } + fail("Unescaped '\\' in a string", pos); + } + + size_t parseLiteralStr(size_t pos, std::string& out) const { + ++pos; + const size_t start = pos; + pos = skipUntil(pos, "'", false, true); + out = slice(start, pos); + return pos + 1; + } + + size_t parseMultilineStr(size_t pos, bool literal, std::string& out) const { + pos += 3; + if (startsWith(pos, "\n")) ++pos; + std::string result; + const char* delim; + if (literal) { + delim = "'"; + const size_t end = skipUntil(pos, "'''", true, true); + result = slice(pos, end); + pos = end + 3; + } else { + delim = "\""; + pos = parseBasicStr(pos, true, result); + } + if (!startsWith(pos, delim)) { + out = result; + return pos; + } + ++pos; + if (!startsWith(pos, delim)) { + out = result + delim; + return pos; + } + ++pos; + out = result + delim + delim; + return pos; + } + + size_t parseBasicStr(size_t pos, bool multiline, std::string& out) const { + std::string result; + size_t start = pos; + while (true) { + if (pos >= src_.size()) fail("Unterminated string", pos); + const char32_t c = src_[pos]; + if (c == '"') { + if (!multiline) { + out = result + slice(start, pos); + return pos + 1; + } + if (startsWith(pos, "\"\"\"")) { + out = result + slice(start, pos); + return pos + 3; + } + ++pos; + continue; + } + if (c == '\\') { + result += slice(start, pos); + std::string parsed; + pos = parseBasicStrEscape(pos, multiline, parsed); + result += parsed; + start = pos; + continue; + } + if (illegal(c, multiline)) fail("Illegal character " + WireText::repr(enc(c)), pos); + ++pos; + } + } + + // --- numbers, dates, times ------------------------------------------- + static bool daysOk(int y, int m, int d) { + static const int dim[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + int days = dim[m - 1]; + if (m == 2 && ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0)) days = 29; + return y >= 1 && d >= 1 && d <= days; + } + + int num2(size_t pos) const { return static_cast((src_[pos] - '0') * 10 + (src_[pos + 1] - '0')); } + bool dig(size_t pos) const { return pos < src_.size() && isDigit(src_[pos]); } + + // [01][0-9]|2[0-3] : [0-5][0-9] : [0-5][0-9] (. 1-6 digits then more digits)? + bool matchTime(size_t pos, size_t& end, int& h, int& mi, int& s, std::string& micros) const { + if (!(dig(pos) && dig(pos + 1))) return false; + h = num2(pos); + if (h > 23) return false; + if (!(pos + 2 < src_.size() && src_[pos + 2] == ':')) return false; + if (!(dig(pos + 3) && dig(pos + 4))) return false; + mi = num2(pos + 3); + if (mi > 59) return false; + if (!(pos + 5 < src_.size() && src_[pos + 5] == ':')) return false; + if (!(dig(pos + 6) && dig(pos + 7))) return false; + s = num2(pos + 6); + if (s > 59) return false; + end = pos + 8; + micros.clear(); + if (end < src_.size() && src_[end] == '.' && dig(end + 1)) { + size_t p = end + 1; + size_t n = 0; + while (dig(p)) { + if (n < 6) micros += static_cast(src_[p]); + ++n; + ++p; + } + end = p; + } + return true; + } + + bool matchDateTime(size_t pos, size_t& end, std::string& text) const { + if (!(dig(pos) && dig(pos + 1) && dig(pos + 2) && dig(pos + 3))) return false; + if (!(pos + 4 < src_.size() && src_[pos + 4] == '-')) return false; + if (!(dig(pos + 5) && dig(pos + 6))) return false; + const int month = num2(pos + 5); + if (month < 1 || month > 12) return false; + if (!(pos + 7 < src_.size() && src_[pos + 7] == '-')) return false; + if (!(dig(pos + 8) && dig(pos + 9))) return false; + const int day = num2(pos + 8); + if (day < 1 || day > 31) return false; + const int year = num2(pos) * 100 + num2(pos + 2); + end = pos + 10; + char buf[64]; + std::snprintf(buf, sizeof buf, "%04d-%02d-%02d", year, month, day); + text = buf; + // optional time part + if (end < src_.size() && (src_[end] == 'T' || src_[end] == 't' || src_[end] == ' ')) { + size_t tEnd; + int h, mi, s; + std::string micros; + if (matchTime(end + 1, tEnd, h, mi, s, micros)) { + std::string tz; + size_t zEnd = tEnd; + if (tEnd < src_.size() && (src_[tEnd] == 'Z' || src_[tEnd] == 'z')) { + tz = "+00:00"; + zEnd = tEnd + 1; + } else if (tEnd < src_.size() && (src_[tEnd] == '+' || src_[tEnd] == '-') && dig(tEnd + 1) && dig(tEnd + 2) && + tEnd + 3 < src_.size() && src_[tEnd + 3] == ':' && dig(tEnd + 4) && dig(tEnd + 5)) { + const int oh = num2(tEnd + 1), om = num2(tEnd + 4); + if (oh <= 23 && om <= 59) { + std::snprintf(buf, sizeof buf, "%c%02d:%02d", static_cast(src_[tEnd]), oh, om); + tz = buf; + zEnd = tEnd + 6; + } + } + std::snprintf(buf, sizeof buf, " %02d:%02d:%02d", h, mi, s); + text += buf; + if (!micros.empty()) { + std::string m6 = micros; + while (m6.size() < 6) m6 += '0'; + if (m6 != "000000") text += "." + m6; + } + text += tz; + end = zEnd; + } + } + // Validate like datetime(): raises ValueError -> "Invalid date or datetime". + if (!daysOk(year, month, day)) throw std::invalid_argument("date"); + return true; + } + + // Returns end of the number match or npos; sets isFloat/valueText. + size_t matchNumber(size_t pos, bool& isFloat, std::string& matched) const { + isFloat = false; + // branch 1: 0x / 0b / 0o + if (pos < src_.size() && src_[pos] == '0' && pos + 1 < src_.size()) { + const char32_t k = src_[pos + 1]; + auto run = [&](auto isD) -> size_t { + size_t p = pos + 2; + if (!(p < src_.size() && isD(src_[p]))) return std::u32string::npos; + ++p; + while (true) { + if (p < src_.size() && isD(src_[p])) { + ++p; + } else if (p + 1 < src_.size() && src_[p] == '_' && isD(src_[p + 1])) { + p += 2; + } else { + break; + } + } + return p; + }; + size_t e = std::u32string::npos; + if (k == 'x') e = run(isHex); + else if (k == 'b') e = run([](char32_t c) { return c == '0' || c == '1'; }); + else if (k == 'o') e = run([](char32_t c) { return c >= '0' && c <= '7'; }); + if (e != std::u32string::npos) { + matched = slice(pos, e); + return e; + } + } + size_t p = pos; + if (p < src_.size() && (src_[p] == '+' || src_[p] == '-')) ++p; + if (p < src_.size() && src_[p] == '0') { + ++p; + } else if (p < src_.size() && src_[p] >= '1' && src_[p] <= '9') { + ++p; + while (true) { + if (dig(p)) ++p; + else if (p + 1 < src_.size() && src_[p] == '_' && dig(p + 1)) p += 2; + else break; + } + } else { + return std::u32string::npos; + } + const size_t intEnd = p; + // fractional part + if (p + 1 < src_.size() && src_[p] == '.' && dig(p + 1)) { + p += 2; + while (true) { + if (dig(p)) ++p; + else if (p + 1 < src_.size() && src_[p] == '_' && dig(p + 1)) p += 2; + else break; + } + } + // exponent part + if (p < src_.size() && (src_[p] == 'e' || src_[p] == 'E')) { + size_t q = p + 1; + if (q < src_.size() && (src_[q] == '+' || src_[q] == '-')) ++q; + if (dig(q)) { + ++q; + while (true) { + if (dig(q)) ++q; + else if (q + 1 < src_.size() && src_[q] == '_' && dig(q + 1)) q += 2; + else break; + } + p = q; + } + } + isFloat = p > intEnd; + matched = slice(pos, p); + return p; + } + + static Json intFromText(const std::string& raw, bool& ok) { + ok = true; + std::string t; + for (char c : raw) + if (c != '_') t += c; + int base = 10; + bool neg = false; + size_t i = 0; + if (t[0] == '+' || t[0] == '-') { + neg = t[0] == '-'; + i = 1; + } + if (t.size() > i + 1 && t[i] == '0' && (t[i + 1] == 'x' || t[i + 1] == 'b' || t[i + 1] == 'o')) { + base = t[i + 1] == 'x' ? 16 : t[i + 1] == 'b' ? 2 : 8; + i += 2; + } + unsigned long long v = 0; + for (; i < t.size(); ++i) { + const int d = isDigit(static_cast(t[i])) ? t[i] - '0' : (t[i] | 0x20) - 'a' + 10; + if (v > (0x7FFFFFFFFFFFFFFFULL - static_cast(d)) / static_cast(base)) { + if (base == 10) return Json::bigInt((neg ? "-" : "") + t.substr(t[0] == '+' || t[0] == '-' ? 1 : 0)); + ok = false; + return Json(); + } + v = v * static_cast(base) + static_cast(d); + } + return Json(neg ? -static_cast(v) : static_cast(v)); + } + + size_t parseValue(size_t pos, Json& out) { + const char32_t c = pos < src_.size() ? src_[pos] : 0; + const bool have = pos < src_.size(); + if (have && c == '"') { + std::string s; + if (startsWith(pos, "\"\"\"")) pos = parseMultilineStr(pos, false, s); + else pos = parseBasicStr(pos + 1, false, s); + out = Json(s); + return pos; + } + if (have && c == '\'') { + std::string s; + if (startsWith(pos, "'''")) pos = parseMultilineStr(pos, true, s); + else pos = parseLiteralStr(pos, s); + out = Json(s); + return pos; + } + if (have && c == 't' && startsWith(pos, "true")) { + out = Json(true); + return pos + 4; + } + if (have && c == 'f' && startsWith(pos, "false")) { + out = Json(false); + return pos + 5; + } + if (have && c == '[') return parseArray(pos, out); + if (have && c == '{') return parseInlineTable(pos, out); + // dates and times + { + size_t end = 0; + std::string text; + try { + if (matchDateTime(pos, end, text)) { + out = Json(text); + return end; + } + } catch (const std::invalid_argument&) { + fail("Invalid date or datetime", pos); + } + int h, mi, s; + std::string micros; + if (matchTime(pos, end, h, mi, s, micros)) { + char buf[64]; + std::snprintf(buf, sizeof buf, "%02d:%02d:%02d", h, mi, s); + text = buf; + if (!micros.empty()) { + while (micros.size() < 6) micros += '0'; + if (micros != "000000") text += "." + micros; + } + out = Json(text); + return end; + } + } + bool isFloat = false; + std::string matched; + const size_t numEnd = have ? matchNumber(pos, isFloat, matched) : std::u32string::npos; + if (numEnd != std::u32string::npos) { + if (isFloat) { + std::string t; + for (char ch : matched) + if (ch != '_') t += ch; + out = Json(std::strtod(t.c_str(), nullptr)); + return numEnd; + } + bool ok; + out = intFromText(matched, ok); + if (!ok) fail("Invalid value", pos); + return numEnd; + } + const std::string three = slice(pos, pos + 3); + if (three == "inf" || three == "nan") { + out = Json(three == "inf" ? INFINITY : std::nan("")); + return pos + 3; + } + const std::string four = slice(pos, pos + 4); + if (four == "-inf" || four == "+inf" || four == "-nan" || four == "+nan") { + const bool inf = four[1] == 'i'; + out = Json(inf ? (four[0] == '-' ? -INFINITY : INFINITY) : std::nan("")); + return pos + 4; + } + fail("Invalid value", pos); + } +}; + +} // namespace + +Json WireToml::loads(const std::string& text) { + Parser parser(text); + return parser.run(); +} + +} // namespace detz::wire diff --git a/taicpp/src/cap/wire_toml.h b/taicpp/src/cap/wire_toml.h new file mode 100644 index 0000000..02618ce --- /dev/null +++ b/taicpp/src/cap/wire_toml.h @@ -0,0 +1,25 @@ +#pragma once +// WireToml: tomllib.loads for CAP manifests, fleet files and MCP configs. +// A line-for-line port of CPython's tomllib parser (TOML 1.0), including its +// error messages ("... (at line L, column C)"). Differences, all on values +// no tai file uses: dates and times come back as strings (Python gives +// datetime objects) and non-decimal integers beyond 64 bits are refused. +#include +#include + +#include "wire_json.h" + +namespace detz::wire { + +// tomllib.TOMLDecodeError (a ValueError). +class TomlError : public std::runtime_error { + public: + explicit TomlError(const std::string& message) : std::runtime_error(message) {} +}; + +class WireToml { + public: + static Json loads(const std::string& text); +}; + +} // namespace detz::wire diff --git a/taicpp/src/mcp/mcp_bridge.cpp b/taicpp/src/mcp/mcp_bridge.cpp new file mode 100644 index 0000000..06edf0d --- /dev/null +++ b/taicpp/src/mcp/mcp_bridge.cpp @@ -0,0 +1,331 @@ +#include "mcp_bridge.h" + +#include +#include + +#include "mcp_error.h" +#include "mcp_http_transport.h" +#include "mcp_normalizer.h" +#include "mcp_oauth.h" +#include "mcp_stdio_transport.h" +#include "wire_hash.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::WireHash; +using detz::wire::WireOs; +using detz::wire::WireText; + +namespace { + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } + +std::string orEmpty(const Json& v) { return v.truthy() ? str(v) : std::string(); } + +bool contains(const Json& list, const std::string& value) { + if (list.isArray()) { + for (const Json& item : list.items()) + if (item.isString() && item.str() == value) return true; + return false; + } + if (list.isString()) return WireText::contains(list.str(), value); + if (list.isObject()) return list.has(value); + return false; +} + +} // namespace + +std::optional McpBridge::lookupVar(const std::string& name, const std::string& profile) { + if (WireOs::hasEnv(name) && !WireOs::getenv(name).empty()) return WireOs::getenv(name); + try { + return store().loadSecret(name, profile); + } catch (const McpStoreError&) { + return std::nullopt; + } +} + +std::pair> McpBridge::expandEntry(const Json& row, const std::string& profile) { + std::vector missing; + Json ready = row; + auto lookup = [&](const std::string& name) { return lookupVar(name, profile); }; + auto expand = [&](const std::string& text) { + auto done = McpNormalizer::mcpExpandVars(text, lookup); + missing.insert(missing.end(), done.second.begin(), done.second.end()); + return done.first; + }; + if (row.get("transport") == Json("stdio")) { + Json argv = Json::array(); + argv.push(Json(expand(str(row.get("command"))))); + for (const Json& part : row.get("args").items()) argv.push(Json(expand(str(part)))); + ready.set("argv", argv); + Json env = Json::object(); + for (const auto& kv : row.get("env").members()) env.set(kv.first, Json(expand(str(kv.second)))); + ready.set("env", env); + } else { + ready.set("url", Json(expand(str(row.get("url"))))); + Json heads = Json::object(); + for (const auto& kv : row.get("headers").members()) heads.set(kv.first, Json(expand(str(kv.second)))); + ready.set("headers", heads); + } + std::set unique(missing.begin(), missing.end()); + return {ready, std::vector(unique.begin(), unique.end())}; +} + +std::unique_ptr McpBridge::connect(const Json& row, const std::string& profile) { + auto [ready, missing] = expandEntry(row, profile); + if (!missing.empty()) + throw McpError("unresolved secrets: " + WireText::join(missing, ", ") + " (set env or store_secret)"); + std::unique_ptr transport; + if (row.get("transport") == Json("stdio")) { + std::vector argv; + for (const Json& part : ready.get("argv").items()) argv.push_back(str(part)); + std::map env; + for (const auto& kv : ready.get("env").members()) env[kv.first] = str(kv.second); + auto stdio = std::make_unique(); + stdio->spawn(argv, env, orEmpty(row.get("cwd"))); + transport = std::move(stdio); + } else { + McpHttpTransport::Headers headers; + for (const auto& kv : ready.get("headers").members()) headers.emplace_back(kv.first, str(kv.second)); + headers = McpOAuth(store()).injectHeaders(headers, str(row.get("name")), profile); + transport = std::make_unique(str(ready.get("url")), headers); + } + auto client = std::make_unique(std::move(transport)); + try { + client->initialize(); + } catch (const McpError&) { + client->close(); + throw; + } + return client; +} + +std::vector McpBridge::cachedTools(const std::string& server) { return store().mcpCachedTools(server); } + +Json McpBridge::toolSchema(const std::string& name, const std::string& description, const Json& properties, + const Json& required) { + return Json::object( + {{"type", Json("function")}, + {"function", Json::object({{"name", Json(name)}, + {"description", Json(description)}, + {"parameters", Json::object({{"type", Json("object")}, + {"properties", properties}, + {"required", required}})}})}}); +} + +bool McpBridge::toolAllowed(const Json& row, const std::string& tool) { + const Json deny = row.get("deny"); + if (deny.truthy() && contains(deny, tool)) return false; + const Json allow = row.get("allow"); + if (allow.truthy() && !contains(allow, tool)) return false; + return true; +} + +std::vector McpBridge::schemasFor(const std::string& profile) { + std::vector found; + for (const Json& row : store().mcpEnabledServers(profile)) { + std::vector tools; + for (const Json& item : cachedTools(str(row.get("name")))) + if (toolAllowed(row, str(item.get("tool")))) tools.push_back(item); + for (size_t i = 0; i < tools.size() && i < static_cast(MCP_MAX_TOOLS); ++i) { + const Json& item = tools[i]; + Json params = item.get("schema"); + if (!params.truthy()) params = Json::object({{"type", Json("object")}, {"properties", Json::object()}}); + if (!params.isObject() || params.get("type") != Json("object")) + params = Json::object({{"type", Json("object")}, {"properties", Json::object()}}); + const std::string tool = str(item.get("tool")); + const std::string desc = item.get("description").truthy() ? str(item.get("description")) : "MCP tool " + tool; + Json props = params.get("properties"); + Json required = params.get("required"); + found.push_back(toolSchema(McpNormalizer::mcpBridgeName(str(row.get("name")), tool), + "MCP " + str(row.get("name")) + "/" + tool + ": " + desc, + props.truthy() ? props : Json::object(), required.truthy() ? required : Json::array())); + } + } + return found; +} + +std::optional> McpBridge::resolve(const std::string& name) { + if (!WireText::startsWith(name, std::string(MCP_TOOL_PREFIX))) return std::nullopt; + const std::string rest = name.substr(MCP_TOOL_PREFIX.size()); + for (const Json& row : store().mcpListServers()) { + const std::string prefix = McpNormalizer::mcpSanitizeName(str(row.get("name"))) + "_"; + if (WireText::startsWith(rest, prefix)) return std::make_pair(str(row.get("name")), rest.substr(prefix.size())); + } + return std::nullopt; +} + +std::pair McpBridge::checkDrift(const std::string& server, const std::vector& fresh, + const std::string&) { + auto pins = store().mcpToolPins(server); + std::vector changed, added; + for (const Json& item : fresh) { + const std::string tool = item.get("name", Json("")).isString() ? item.get("name").str() : str(item.get("name")); + const std::string digest = McpNormalizer::mcpToolHash(tool, item.get("description", Json("")), item.get("inputSchema")); + auto it = pins.find(tool); + if (it == pins.end()) added.push_back(tool); + else if (it->second != digest) changed.push_back(tool); + } + if (added.empty() && changed.empty()) return {true, ""}; + std::string detail = "MCP server '" + server + "' changed tools"; + auto firstTen = [](std::vector names) { + std::sort(names.begin(), names.end()); + if (names.size() > 10) names.resize(10); + return WireText::join(names, ", "); + }; + if (!added.empty()) detail += "; new: " + firstTen(added); + if (!changed.empty()) detail += "; redefined: " + firstTen(changed); + if (!host_.askApproval(detail + " (approve to accept and re-pin)")) return {false, "tool list changed and was not approved"}; + store().mcpStoreTools(server, fresh); + return {true, "re-pinned " + std::to_string(fresh.size()) + " tools"}; +} + +std::string McpBridge::resultText(const Json& result) { + std::vector parts; + Json content = result.isObject() ? result.get("content", Json::array()) : Json::array(); + if (content.truthy()) { + std::vector items; + if (content.isArray()) { + items = content.items(); + } else if (content.isString()) { + for (char32_t cp : WireText::codepoints(content.str())) items.push_back(Json(WireText::encode(cp))); + } else if (content.isObject()) { + for (const auto& m : content.members()) items.push_back(Json(m.first)); + } + for (const Json& item : items) { + if (!item.isObject()) continue; + const Json kindJson = item.get("type", Json("")); + const std::string kind = str(kindJson); + if (kind == "text") { + parts.push_back(str(item.get("text", Json("")))); + } else if (kind == "image" || kind == "audio") { + parts.push_back("[" + kind + " " + std::to_string(item.get("data", Json("")).size()) + " bytes b64]"); + } else if (kind == "resource_link") { + parts.push_back("[resource " + str(item.get("uri", Json(""))) + "]"); + } else { + parts.push_back("[" + (kind.empty() ? std::string("content") : kind) + "]"); + } + } + } + const Json structured = result.isObject() ? result.get("structuredContent") : Json(); + if (!structured.isNull()) parts.push_back(WireText::head(structured.dumps(), 4000)); + return WireText::join(parts, "\n"); +} + +std::string McpBridge::call(const std::string& server, const std::string& tool, const Json& args, + const std::string& profile) { + if (!args.isObject()) return "error: MCP tool args must be an object"; + const Json row = store().mcpGetServer(server); + if (row.isNull()) return "error: unknown MCP server '" + server + "'"; + if (!store().mcpServerEnabled(server, profile)) + return "error: MCP server '" + server + "' is disabled for profile '" + profile + "'"; + if (!toolAllowed(row, tool)) return "error: MCP tool '" + tool + "' is not enabled on '" + server + "'"; + const double started = WireOs::now(); + try { + return callInner(row, server, tool, args, profile, started); + } catch (const McpHttpError& exc) { + if (exc.code != 401 || row.get("transport") != Json("http")) return std::string("error: ") + exc.what(); + McpOAuth oauth(store()); + auto tokens = oauth.loadTokens(server, profile); + if (!tokens.second || tokens.second->empty()) { + for (const auto& kv : row.get("headers").members()) + if (WireText::lower(kv.first) == "authorization") + return "error: MCP server '" + server + "' refused its configured credentials (http 401)"; + return "error: MCP server '" + server + "' needs login: run mcp_login '" + server + "' (http 401)"; + } + try { + oauth.refreshFlow(row, profile); + } catch (const McpError& rexc) { + return "error: MCP server '" + server + "' token refresh failed (" + rexc.what() + "): run mcp_login '" + server + "'"; + } + try { + return callInner(row, server, tool, args, profile, started); + } catch (const McpHttpError& exc2) { + if (exc2.code != 401) return std::string("error: ") + exc2.what(); + return "error: MCP server '" + server + "' still unauthorized after refresh: run mcp_login '" + server + "'"; + } + } +} + +std::string McpBridge::callInner(const Json& row, const std::string& server, const std::string& tool, const Json& args, + const std::string& profile, double started) { + std::unique_ptr client; + try { + client = connect(row, profile); + } catch (const McpHttpError&) { + throw; + } catch (const McpError& exc) { + return std::string("error: ") + exc.what(); + } + struct Closer { + McpClient& client; + ~Closer() { + try { + client.close(); + } catch (...) { + } + } + } closer{*client}; + std::vector fresh; + try { + fresh = client->listTools(); + } catch (const McpHttpError&) { + throw; + } catch (const McpError& exc) { + return std::string("error: tools/list failed: ") + exc.what(); + } + bool found = false; + for (const Json& item : fresh) + if (item.get("name", Json("")) == Json(tool)) found = true; + if (!found) return "error: MCP server '" + server + "' has no tool '" + tool + "'"; + auto drift = checkDrift(server, fresh, profile); + if (!drift.first) return "error: " + drift.second; + const std::string digest = WireHash::sha256Hex(args.dumps(true)).substr(0, 16); + if (!host_.askApproval("mcp call " + server + "/" + tool + " (args " + digest + ")")) return "denied by user"; + Json result; + try { + result = client->callTool(tool, args); + } catch (const McpHttpError&) { + audit(server, tool, digest, "retry-401", started, profile); + throw; + } catch (const McpError& exc) { + audit(server, tool, digest, "error", started, profile); + return std::string("error: ") + exc.what(); + } + const std::string text = resultText(result); + if (result.isObject() && result.get("isError").truthy()) { + audit(server, tool, digest, "isError", started, profile); + return "error: " + (text.empty() ? std::string("tool failed") : text); + } + if (WireText::length(text) > static_cast(MCP_RESULT_MAX)) { + const std::string memId = spill(server, tool, text, profile); + audit(server, tool, digest, "spilled", started, profile); + return "output spilled to record " + memId + " (" + std::to_string(WireText::length(text)) + " bytes); first " + + std::to_string(MCP_RESULT_MAX) + " bytes follow:\n" + WireText::head(text, MCP_RESULT_MAX); + } + audit(server, tool, digest, "ok", started, profile); + return text; +} + +std::string McpBridge::spill(const std::string& server, const std::string& tool, const std::string& text, + const std::string& profile) { + try { + return store().spillRecord("mcp " + server + "/" + tool + " output", text, profile); + } catch (const McpStoreError&) { + return "mem:?"; + } +} + +void McpBridge::audit(const std::string& server, const std::string& tool, const std::string& digest, + const std::string& outcome, double started, const std::string& profile) { + try { + store().auditEvent(host_.auditActor(), "mcp-call", server + "/" + tool, + outcome + " args=" + digest + " " + detz::wire::WireText::fmtFloat(WireOs::now() - started, 1) + "s", + profile); + } catch (const McpStoreError&) { + } +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_bridge.h b/taicpp/src/mcp/mcp_bridge.h new file mode 100644 index 0000000..53c0160 --- /dev/null +++ b/taicpp/src/mcp/mcp_bridge.h @@ -0,0 +1,54 @@ +#pragma once +// McpBridge: client-side bridge: vault servers in, lazy tool schemas and +// calls out. Owns enablement, drift pins, approval, caps, spill and audit. +#include +#include +#include +#include + +#include "mcp_client.h" +#include "mcp_host.h" + +namespace detz::mcp { + +class McpBridge { + public: + explicit McpBridge(McpHost& host) : host_(host) {} + + McpStore& store() { return host_.store(); } + // lookup_var: env first, then vault secrets; nullopt when neither has it. + std::optional lookupVar(const std::string& name, const std::string& profile); + // expand_entry(row, profile) -> (ready, missing) + std::pair> expandEntry(const Json& row, const std::string& profile); + // connect(row, profile): throws McpError / McpHttpError. The client is + // handshaken; the caller closes it. + std::unique_ptr connect(const Json& row, const std::string& profile); + std::vector cachedTools(const std::string& server); + // schemas_for(profile): lazy tool schemas for enabled servers (no spawn). + std::vector schemasFor(const std::string& profile); + static bool toolAllowed(const Json& row, const std::string& tool); + // resolve(name): split a bridged tool name back to (server, tool). + std::optional> resolve(const std::string& name); + // check_drift(server, fresh, profile) -> (ok, note) + std::pair checkDrift(const std::string& server, const std::vector& fresh, + const std::string& profile); + static std::string resultText(const Json& result); + // call(server, tool, args, profile): the full call path. Returns the text the + // model sees ("error: ..." strings included). + std::string call(const std::string& server, const std::string& tool, const Json& args, const std::string& profile); + // tool_schema(name, description, properties, required) + static Json toolSchema(const std::string& name, const std::string& description, const Json& properties, + const Json& required); + + private: + std::string callInner(const Json& row, const std::string& server, const std::string& tool, const Json& args, + const std::string& profile, double started); + std::string spill(const std::string& server, const std::string& tool, const std::string& text, + const std::string& profile); + void audit(const std::string& server, const std::string& tool, const std::string& digest, const std::string& outcome, + double started, const std::string& profile); + + McpHost& host_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_client.cpp b/taicpp/src/mcp/mcp_client.cpp new file mode 100644 index 0000000..24358ee --- /dev/null +++ b/taicpp/src/mcp/mcp_client.cpp @@ -0,0 +1,92 @@ +#include "mcp_client.h" + +#include "mcp_error.h" +#include "wire_app.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::WireApp; +using detz::wire::WireText; + +McpClient::McpClient(std::unique_ptr transport) : transport_(std::move(transport)) {} + +Json McpClient::request(const std::string& method, const Json& params, bool hasParams, double timeout) { + ++nextId_; + Json message = Json::object({{"jsonrpc", Json("2.0")}, {"id", Json(nextId_)}, {"method", Json(method)}}); + if (hasParams) message.set("params", params); + Json reply = transport_->send(message, timeout); + if (!reply.isObject()) throw McpError("reply is not an object"); + if (reply.has("error") && reply.get("error").isObject()) { + const Json err = reply.get("error"); + throw McpJsonRpcError(err.get("code", Json(-32603)), err.get("message", Json("error"))); + } + if (!reply.has("result")) throw McpError("reply has neither result nor error"); + return reply.get("result"); +} + +void McpClient::notify(const std::string& method, const Json* params) { + Json message = Json::object({{"jsonrpc", Json("2.0")}, {"method", Json(method)}}); + if (params) message.set("params", *params); + transport_->send(message); +} + +Json McpClient::initialize() { + Json result; + try { + result = request("initialize", + Json::object({{"protocolVersion", Json(std::string(MCP_PROTOCOL_VERSION))}, + {"capabilities", Json::object()}, + {"clientInfo", Json::object({{"name", Json(std::string(MCP_CLIENT_NAME))}, + {"version", Json(WireApp::version())}})}}), + true, MCP_CONNECT_TIMEOUT); + } catch (const McpHttpError&) { + throw; + } catch (const McpError& exc) { + throw McpError(std::string("initialize failed: ") + exc.what()); + } + if (!result.isObject() || !result.get("protocolVersion").isString()) throw McpError("initialize: bad handshake reply"); + if (!result.get("capabilities").isObject() || !result.get("capabilities").has("tools")) + throw McpError("initialize: server offers no tools capability"); + serverVersion_ = result.get("protocolVersion").str(); + Json info = result.get("serverInfo"); + serverInfo_ = info.truthy() ? info : Json::object(); + notify("notifications/initialized"); + return result; +} + +std::vector McpClient::listTools() { + std::vector tools; + Json cursor; + for (int page = 0; page < 20; ++page) { + Json params = Json::object(); + if (!cursor.isNull()) params.set("cursor", cursor); + Json result = request("tools/list", params, true); + if (!result.isObject() || !result.get("tools").isArray()) throw McpError("tools/list: bad reply"); + for (const Json& item : result.get("tools").items()) + if (item.isObject() && item.get("name").truthy()) tools.push_back(item); + cursor = result.get("nextCursor"); + if (!cursor.truthy()) break; + } + return tools; +} + +Json McpClient::callTool(const std::string& name, const Json& args) { + try { + return request("tools/call", + Json::object({{"name", Json(name)}, {"arguments", args.truthy() ? args : Json::object()}}), true); + } catch (const McpHttpError&) { + throw; + } catch (const McpError& exc) { + try { + Json params = Json::object({{"requestId", Json(nextId_)}, {"reason", Json("timeout")}}); + notify("notifications/cancelled", ¶ms); + } catch (const McpError&) { + } + throw McpError("call " + name + " failed: " + exc.what()); + } +} + +void McpClient::ping() { request("ping", Json::object(), true); } + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_client.h b/taicpp/src/mcp/mcp_client.h new file mode 100644 index 0000000..cb8ee39 --- /dev/null +++ b/taicpp/src/mcp/mcp_client.h @@ -0,0 +1,40 @@ +#pragma once +// McpClient: minimal MCP client over one transport: handshake, tools, call, +// ping. Roots, sampling, elicitation and progress are not implemented. +#include +#include +#include + +#include "mcp_transport.h" + +namespace detz::mcp { + +class McpClient { + public: + explicit McpClient(std::unique_ptr transport); + + // initialize(): the handshake; returns the server's result. + Json initialize(); + // tools/list followed until no cursor (at most 20 pages). + std::vector listTools(); + // tools/call + Json callTool(const std::string& name, const Json& args = Json()); + void ping(); + void close() { transport_->close(); } + + const std::string& serverVersion() const { return serverVersion_; } + const Json& serverInfo() const { return serverInfo_; } + long long nextId() const { return nextId_; } + McpTransport& transport() { return *transport_; } + + private: + Json request(const std::string& method, const Json& params, bool hasParams, double timeout = MCP_CALL_TIMEOUT); + void notify(const std::string& method, const Json* params = nullptr); + + std::unique_ptr transport_; + long long nextId_{0}; + std::string serverVersion_; + Json serverInfo_{Json::object()}; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_constants.h b/taicpp/src/mcp/mcp_constants.h new file mode 100644 index 0000000..f1f94c2 --- /dev/null +++ b/taicpp/src/mcp/mcp_constants.h @@ -0,0 +1,51 @@ +#pragma once +// MCP constants. Names and values are tai.py's, verbatim; docs/mcp.md lists +// every one (McpDocTests drift check is mirrored in tests/mcp_doc_test.cpp). +#include + +namespace detz::mcp { + +inline constexpr std::string_view MCP_PROTOCOL_VERSION = "2025-11-25"; +inline constexpr std::string_view MCP_CLIENT_NAME = "tai"; +inline constexpr std::string_view MCP_TOOL_PREFIX = "mcp_"; +inline constexpr int MCP_RESOLVER_TIMEOUT = 300; +inline constexpr int MCP_RESOLVER_STEPS = 12; +inline constexpr int MCP_OAUTH_TIMEOUT = 300; +inline constexpr int MCP_CONNECT_TIMEOUT = 20; +inline constexpr int MCP_CALL_TIMEOUT = 60; +inline constexpr int MCP_RESULT_MAX = 65536; +inline constexpr int MCP_HTTP_MAX_BODY = 10485760; +inline constexpr int MCP_MAX_SERVERS = 32; +inline constexpr int MCP_MAX_TOOLS = 100; +inline constexpr int MCP_SESSION_IDLE = 3600; +inline constexpr int MCP_CHAT_TIMEOUT = 600; +inline constexpr int MCP_DEFAULT_PORT = 8765; +// MCP_SERVER_RE: ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ +inline constexpr std::string_view MCP_SERVER_RE = "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"; +// MCP_VAR_RE: \$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\} +inline constexpr std::string_view MCP_VAR_RE = "\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\\}"; +// MCP_GITHUB_RE (IGNORECASE) +inline constexpr std::string_view MCP_GITHUB_RE = "github\\.com/([^/\\s?#]+)/([^/\\s?#]+?)(?:\\.git)?(?:[/?#]|$)"; + +// MCP_RESOLVER_BRIEF with seven %s pairs (owner, repo) for the fork subagent. +inline constexpr std::string_view MCP_RESOLVER_BRIEF = + "You must resolve the GitHub repo %s/%s into an MCP server run contract.\n" + "Use web_fetch to read, in this order, until one tells you how to run the server:\n" + "https://api.github.com/repos/%s/%s/contents/mcp.json\n" + "https://api.github.com/repos/%s/%s/contents/.mcp.json\n" + "https://api.github.com/repos/%s/%s/contents/server.json\n" + "https://api.github.com/repos/%s/%s/contents/package.json\n" + "https://api.github.com/repos/%s/%s/contents/README.md\n" + "https://raw.githubusercontent.com/%s/%s/HEAD/README.md\n" + "(github api file blobs are base64 under content, decode them yourself.)\n" + "Prefer an explicit stdio command (command + args + env) from the repo docs; npx -y for npm packages and uvx " + " for python packages are acceptable when the README shows them. If the repo is only usable as a remote http " + "endpoint, return its url instead.\n" + "Reply with EXACTLY one fenced block and nothing else outside it:\n" + "```json\n" + "{\"command\": \"npx\", \"args\": [\"-y\", \"pkg\"], \"env\": {}}\n" + "```\n" + "or {\"url\": \"https://...\"} for remote servers, or {\"error\": \"short reason\", \"evidence\": \"what you " + "checked\"} when nothing runnable exists. Never invent package names or urls you did not read."; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_error.h b/taicpp/src/mcp/mcp_error.h new file mode 100644 index 0000000..a18d1f0 --- /dev/null +++ b/taicpp/src/mcp/mcp_error.h @@ -0,0 +1,38 @@ +#pragma once +// McpError and its two subclasses, one header (they are one exception family). +#include +#include + +#include "wire_json.h" + +namespace detz::mcp { + +// A protocol, transport or configuration failure on the MCP wire. +class McpError : public std::runtime_error { + public: + explicit McpError(const std::string& message) : std::runtime_error(message) {} +}; + +// A JSON-RPC error reply: str() is "json-rpc : ". +class McpJsonRpcError : public McpError { + public: + // code and message are whatever the peer sent; str() of each is shown. + McpJsonRpcError(const detz::wire::Json& errorCode, const detz::wire::Json& text) + : McpError("json-rpc " + errorCode.pyStr() + ": " + text.pyStr()), code(errorCode), message(text) {} + McpJsonRpcError(long long errorCode, const std::string& text) + : McpJsonRpcError(detz::wire::Json(errorCode), detz::wire::Json(text)) {} + detz::wire::Json code; + detz::wire::Json message; +}; + +// An HTTP failure carrying the status and the WWW-Authenticate header, so the +// bridge and the OAuth layer can tell 401 from anything else. +class McpHttpError : public McpError { + public: + explicit McpHttpError(int statusCode, const std::string& authHeader = "") + : McpError("http " + std::to_string(statusCode)), code(statusCode), wwwAuth(authHeader) {} + int code; + std::string wwwAuth; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_host.h b/taicpp/src/mcp/mcp_host.h new file mode 100644 index 0000000..0a8a510 --- /dev/null +++ b/taicpp/src/mcp/mcp_host.h @@ -0,0 +1,33 @@ +#pragma once +// McpHost: what MCP asks of the application around it (tai.py's `app`). +// The integrator implements it once over the real Agent / Tools / Store. +#include + +#include "mcp_store.h" + +namespace detz::mcp { + +class McpHost { + public: + virtual ~McpHost() = default; + // app.store + virtual McpStore& store() = 0; + // app.ask_approval(prompt): always asks in interactive mode, auto in --auto, + // silent in --yolo. + virtual bool askApproval(const std::string& prompt) = 0; + // app.tools.audit_actor() when tools exist, else "agent". + virtual std::string auditActor() = 0; + // app.active_profile of the Tools instance running the call. + virtual std::string activeProfile() = 0; + // Tools.run_web_fetch({"url": ..., "raw": True}): the tool's text result. + virtual std::string runWebFetch(const std::string& url) = 0; + // default_runner(config, seal, profile, depth + 1, brief, timeout, max_steps): + // runs the fork subagent and returns its final text. Throws on failure. + virtual std::string runResolverAgent(const std::string& profile, const std::string& brief, int timeoutSeconds, + int maxSteps) = 0; + // getattr(app, "depth", 0) and FORK_MAX_DEPTH. + virtual int depth() = 0; + virtual int forkMaxDepth() = 0; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_http_transport.cpp b/taicpp/src/mcp/mcp_http_transport.cpp new file mode 100644 index 0000000..c8374c2 --- /dev/null +++ b/taicpp/src/mcp/mcp_http_transport.cpp @@ -0,0 +1,101 @@ +#include "mcp_http_transport.h" + +#include "mcp_error.h" +#include "wire_http.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::HttpError; +using detz::wire::UrlError; +using detz::wire::WireHttp; +using detz::wire::WireText; + +namespace { + +std::string shortError(const std::string& text) { + const std::string head = WireText::head(text, 300); + return head.empty() ? "Exception" : head; +} + +// str.capitalize() on an ASCII header name: first upper, rest lower. +std::string capitalize(const std::string& name) { + if (name.empty()) return name; + std::string out = WireText::lower(name); + out[0] = static_cast(std::toupper(static_cast(out[0]))); + return out; +} + +} // namespace + +McpHttpTransport::McpHttpTransport(std::string url, Headers headers) + : url_(std::move(url)), headers_(std::move(headers)) {} + +Json McpHttpTransport::parseSse(const std::string& text) { + Json payload; + bool found = false; + for (const std::string& frame : WireText::split(text, "\n\n")) { + std::vector data; + for (const std::string& line : WireText::split(frame, "\n")) + if (WireText::startsWith(line, "data:")) data.push_back(WireText::strip(line.substr(5))); + if (data.empty()) continue; + try { + payload = Json::parse(WireText::join(data, "\n")); + found = true; + } catch (const detz::wire::JsonError&) { + continue; + } + } + if (!found) throw McpError("SSE reply held no JSON"); + return payload; +} + +Json McpHttpTransport::post(const Json& message, double timeout) { + const std::string body = message.dumps(); + // heads = {...}; heads.update(self.headers); urllib capitalizes every name. + detz::wire::HeaderList heads; + auto put = [&](const std::string& name, const std::string& value) { + const std::string key = capitalize(name); + for (auto& entry : heads) + if (entry.first == key) { + entry.second = value; + return; + } + heads.emplace_back(key, value); + }; + put("Content-Type", "application/json"); + put("Accept", "application/json, text/event-stream"); + for (const auto& h : headers_) put(h.first, h.second); + if (!session_.empty()) put("Mcp-Session-Id", session_); + detz::wire::HttpReply reply; + try { + reply = WireHttp::urlopen("POST", url_, heads, &body, timeout, MCP_HTTP_MAX_BODY); + } catch (const HttpError& exc) { + throw McpHttpError(exc.code, exc.header("WWW-Authenticate")); + } catch (const UrlError& exc) { + throw McpError("http failed: " + shortError(exc.what())); + } + if (reply.body.size() > static_cast(MCP_HTTP_MAX_BODY)) + throw McpError("http body exceeds " + std::to_string(MCP_HTTP_MAX_BODY) + " bytes"); + const std::string sessionHeader = reply.header("Mcp-Session-Id"); + if (!sessionHeader.empty()) session_ = sessionHeader; + if (reply.body.empty()) return Json(); + const std::string contentType = reply.header("Content-Type"); + const std::string kind = WireText::strip(WireText::split(contentType, ";")[0]); + if (kind == "text/event-stream") return parseSse(WireText::decodeReplace(reply.body)); + try { + return Json::parse(WireText::decodeReplace(reply.body)); + } catch (const detz::wire::JsonError&) { + throw McpError("http reply is not JSON"); + } +} + +Json McpHttpTransport::send(const Json& message, double timeout) { + Json data = post(message, timeout); + if (!message.has("id")) return Json(); + if (data.isNull()) throw McpError("empty http reply"); + if (!data.isObject() || data.get("id") != message.get("id")) throw McpError("http reply id mismatch"); + return data; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_http_transport.h b/taicpp/src/mcp/mcp_http_transport.h new file mode 100644 index 0000000..6f119f2 --- /dev/null +++ b/taicpp/src/mcp/mcp_http_transport.h @@ -0,0 +1,34 @@ +#pragma once +// McpHttpTransport: Streamable HTTP leg: POST per request, single JSON or SSE +// reply. GET and DELETE are never sent; sessions end by dropping the id. +#include +#include +#include + +#include "mcp_transport.h" + +namespace detz::mcp { + +class McpHttpTransport : public McpTransport { + public: + using Headers = std::vector>; + + explicit McpHttpTransport(std::string url, Headers headers = {}); + + Json send(const Json& message, double timeout = MCP_CALL_TIMEOUT) override; + void close() override { session_.clear(); } + + const std::string& session() const { return session_; } + const std::string& url() const { return url_; } + // McpHttpTransport._parse_sse: the last data: payload that parses. + static Json parseSse(const std::string& text); + + private: + Json post(const Json& message, double timeout); + + std::string url_; + Headers headers_; + std::string session_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_normalizer.cpp b/taicpp/src/mcp/mcp_normalizer.cpp new file mode 100644 index 0000000..345c516 --- /dev/null +++ b/taicpp/src/mcp/mcp_normalizer.cpp @@ -0,0 +1,257 @@ +#include "mcp_normalizer.h" + +#include + +#include "mcp_constants.h" +#include "mcp_error.h" +#include "wire_hash.h" +#include "wire_regex.h" +#include "wire_text.h" +#include "wire_toml.h" + +namespace detz::mcp { + +using detz::wire::WireHash; +using detz::wire::WireRegex; +using detz::wire::WireText; + +namespace { + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } + +bool isStringList(const Json& v) { + if (!v.isArray()) return false; + for (const Json& item : v.items()) + if (!item.isString()) return false; + return true; +} + +const WireRegex& serverRe() { + static const WireRegex re{std::string(MCP_SERVER_RE)}; + return re; +} + +const WireRegex& varRe() { + static const WireRegex re{std::string(MCP_VAR_RE)}; + return re; +} + +const WireRegex& githubRe() { + static const WireRegex re{std::string(MCP_GITHUB_RE), WireRegex::kIgnoreCase}; + return re; +} + +std::string shortError(const std::string& text) { + const std::string head = WireText::head(text, 300); + return head.empty() ? "Exception" : head; +} + +std::string sortedJoin(const std::vector& keys) { + std::vector sorted(keys); + std::sort(sorted.begin(), sorted.end()); + return WireText::join(sorted, ", "); +} + +} // namespace + +bool McpNormalizer::serverNameOk(const std::string& name) { return static_cast(serverRe().match(name)); } + +std::string McpNormalizer::mcpSanitizeName(const std::string& text) { + // re.sub(r"[^a-z0-9_]", "_", text.lower()).strip("_") or "server" + std::string cleaned; + for (char32_t cp : WireText::codepoints(WireText::lower(text))) { + const bool ok = (cp >= 'a' && cp <= 'z') || (cp >= '0' && cp <= '9') || cp == '_'; + cleaned += ok ? std::string(1, static_cast(cp)) : std::string("_"); + } + cleaned = WireText::stripChars(cleaned, "_"); + return cleaned.empty() ? "server" : cleaned; +} + +std::optional> McpNormalizer::mcpGithubTarget(const std::string& source) { + auto match = githubRe().search(source); + if (!match) return std::nullopt; + return std::make_pair(match.group(1), match.group(2)); +} + +std::pair> McpNormalizer::mcpExpandVars(const std::string& text, + const Lookup& lookup) { + std::vector missing; + std::string out = varRe().sub(text, [&](const WireRegex::Match& m) -> std::string { + const std::string name = m.group(1); + std::optional value = lookup(name); + if (!value) { + if (m.hasGroup(2)) return m.group(2); + missing.push_back(name); + return m.group(0); + } + return *value; + }); + return {out, missing}; +} + +std::pair> McpNormalizer::mcpRewritePlaceholders(const std::string& textIn) { + std::string text = textIn; + std::vector noted; + if (WireText::contains(text, "${env:")) { + static const WireRegex env{"\\$\\{env:([A-Za-z_][A-Za-z0-9_]*)\\}"}; + text = env.sub(text, [](const WireRegex::Match& m) { return "${" + m.group(1) + "}"; }); + noted.push_back("rewrote ${env:NAME} to ${NAME}"); + } + if (WireText::contains(text, "${input:")) { + static const WireRegex input{"\\$\\{input:([^}]+)\\}"}; + text = input.sub(text, [](const WireRegex::Match& m) { return "${" + m.group(1) + "}"; }); + noted.push_back("rewrote ${input:id} to ${id} (resolves against vault secrets)"); + } + return {text, noted}; +} + +McpNormalized McpNormalizer::mcpEntryFromMapping(const std::string& name, const Json& raw) { + if (!raw.isObject()) throw McpError("server '" + name + "' is not an object"); + Json entry = Json::object({{"name", Json(name)}, + {"transport", Json()}, + {"command", Json("")}, + {"args", Json::array()}, + {"env", Json::object()}, + {"url", Json("")}, + {"headers", Json::object()}, + {"cwd", Json("")}, + {"allow", Json::array()}, + {"deny", Json::array()}, + {"enabled", Json()}, + {"source", Json("config")}}); + std::vector notes; + Json command = raw.get("command"); + if (!command.truthy()) command = Json(""); + Json url = raw.get("url"); + if (!url.truthy()) url = Json(""); + if (command.truthy() && url.truthy()) throw McpError("server '" + name + "' sets both command and url"); + if (command.truthy()) { + if (!command.isString()) throw McpError("server '" + name + "' command must be a string"); + entry.set("transport", Json("stdio")); + entry.set("command", command); + Json args = raw.has("args") ? raw.get("args") : Json::array(); + if (!isStringList(args)) throw McpError("server '" + name + "' args must be a string list"); + entry.set("args", args); + Json env = raw.get("env"); + if (!env.truthy()) env = raw.get("env_vars"); + if (!env.truthy()) env = Json::object(); + bool okEnv = env.isObject(); + if (okEnv) + for (const auto& kv : env.members()) + if (!kv.second.isString()) okEnv = false; + if (!okEnv) throw McpError("server '" + name + "' env must be a string map"); + entry.set("env", env); + Json cwd = raw.get("cwd"); + if (!cwd.truthy()) cwd = raw.get("working_directory"); + if (!cwd.truthy()) cwd = Json(""); + if (cwd.truthy() && !cwd.isString()) throw McpError("server '" + name + "' cwd must be a string"); + entry.set("cwd", cwd); + } else if (url.truthy()) { + if (!url.isString()) throw McpError("server '" + name + "' url must be a string"); + // probe = (url.replace("${", "x").split() or [""])[0] + const std::vector pieces = WireText::splitWhitespace(WireText::replaceAll(url.str(), "${", "x")); + const std::string probe = pieces.empty() ? "" : pieces[0]; + static const WireRegex httpRe{"https?://[^/\\s]+"}; + if (!httpRe.match(probe)) throw McpError("server '" + name + "' url must be http(s)"); + if (!raw.get("type").truthy()) notes.push_back("entry has url without type, treating as http"); + entry.set("transport", Json("http")); + entry.set("url", url); + Json headers = raw.get("headers"); + if (!headers.truthy()) headers = raw.get("http_headers"); + if (!headers.truthy()) headers = Json::object(); + if (!headers.isObject()) throw McpError("server '" + name + "' headers must be a string map"); + Json outHeaders = Json::object(); + for (const auto& kv : headers.members()) outHeaders.set(kv.first, Json(str(kv.second))); + entry.set("headers", outHeaders); + Json indirect = raw.get("env_http_headers"); + if (!indirect.truthy()) indirect = Json::object(); + if (!indirect.isObject()) throw McpError("server '" + name + "' env_http_headers must be a string map"); + for (const auto& kv : indirect.members()) { + if (!kv.second.isString()) throw McpError("server '" + name + "' env_http_headers values must be var names"); + outHeaders.set(kv.first, Json("${" + kv.second.str() + "}")); + } + entry.set("headers", outHeaders); + } else { + throw McpError("server '" + name + "' needs command or url"); + } + for (const char* key : {"enabled_tools", "disabled_tools"}) { + Json items = raw.get(key); + if (!items.truthy()) items = Json::array(); + if (!isStringList(items)) throw McpError("server '" + name + "' " + key + " must be a string list"); + } + Json allow = raw.get("enabled_tools"); + entry.set("allow", allow.truthy() ? allow : Json::array()); + Json deny = raw.get("disabled_tools"); + entry.set("deny", deny.truthy() ? deny : Json::array()); + if (!raw.get("enabled").isNull()) entry.set("enabled", Json(raw.get("enabled").truthy())); + if (!raw.get("timeout").isNull() || !raw.get("startup_timeout").isNull()) + notes.push_back("timeout fields ignored, tai caps apply (connect " + std::to_string(MCP_CONNECT_TIMEOUT) + "s, call " + + std::to_string(MCP_CALL_TIMEOUT) + "s)"); + return {entry, notes}; +} + +McpNormalized McpNormalizer::mcpNormalize(const std::string& source, const std::string* name) { + const std::string text = WireText::strip(source); + if (text.empty()) throw McpError("nothing to normalize"); + if (mcpGithubTarget(text) && text.find('\n') == std::string::npos && WireText::length(text) < 200 && + text.find('{') == std::string::npos && text.find('[') == std::string::npos) + throw McpError("github URL needs the resolver, not the normalizer"); + Json entries; + const bool isJson = WireText::startsWith(text, "{"); + if (isJson) { + Json data; + try { + data = Json::parse(text); + } catch (const detz::wire::JsonError& exc) { + throw McpError("invalid JSON: " + shortError(exc.what())); + } + if (!data.isObject()) throw McpError("config JSON must be an object"); + Json wrapped = data.has("mcpServers") ? data.get("mcpServers") : data.get("servers"); + if (wrapped.isObject()) { + entries = wrapped; + } else if (data.has("command") || data.has("url")) { + entries = Json::object({{name ? *name : std::string("server"), data}}); + if (name && name->empty()) entries = Json::object({{"server", data}}); + } else { + throw McpError("JSON needs mcpServers/servers or a bare command/url entry"); + } + } else { + Json data; + try { + data = detz::wire::WireToml::loads(text); + } catch (const std::runtime_error& exc) { + throw McpError("config is neither JSON nor TOML: " + shortError(exc.what())); + } + Json wrapped = data.has("mcp_servers") ? data.get("mcp_servers") : data.get("servers"); + if (wrapped.isObject()) entries = wrapped; + else throw McpError("TOML needs [mcp_servers.NAME] tables"); + } + if (name) { + if (!entries.has(*name)) + throw McpError("no server '" + *name + "' in config (" + + (entries.members().empty() ? std::string("empty") : sortedJoin(entries.keys())) + ")"); + entries = Json::object({{*name, entries.get(*name)}}); + } + if (entries.members().size() != 1) + throw McpError("config names " + std::to_string(entries.members().size()) + " servers (" + sortedJoin(entries.keys()) + + "), pass name to pick one"); + const std::string single = entries.members()[0].first; + const Json raw = entries.members()[0].second; + if (!serverNameOk(single)) + throw McpError("server name '" + single + "' must match [A-Za-z0-9][A-Za-z0-9_.-]{0,63}"); + McpNormalized out = mcpEntryFromMapping(single, raw); + out.entry.set("source", Json(isJson ? "config-json" : "config-toml")); + return out; +} + +std::string McpNormalizer::mcpToolHash(const std::string& name, const Json& description, const Json& schema) { + const std::string desc = description.truthy() ? str(description) : ""; + const std::string blob = name + "\n" + desc + "\n" + (schema.truthy() ? schema : Json::object()).dumps(true); + return WireHash::sha256Hex(blob).substr(0, 16); +} + +std::string McpNormalizer::mcpBridgeName(const std::string& server, const std::string& tool) { + return std::string(MCP_TOOL_PREFIX) + mcpSanitizeName(server) + "_" + mcpSanitizeName(tool); +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_normalizer.h b/taicpp/src/mcp/mcp_normalizer.h new file mode 100644 index 0000000..2b324b2 --- /dev/null +++ b/taicpp/src/mcp/mcp_normalizer.h @@ -0,0 +1,46 @@ +#pragma once +// McpNormalizer: pasted-config intake. mcp_sanitize_name, mcp_github_target, +// mcp_expand_vars, mcp_rewrite_placeholders, mcp_entry_from_mapping, +// mcp_normalize, mcp_tool_hash and mcp_bridge_name of tai.py. +#include +#include +#include +#include +#include + +#include "wire_json.h" + +namespace detz::mcp { + +using detz::wire::Json; + +struct McpNormalized { + Json entry; // the stored-entry dict + std::vector notes; +}; + +class McpNormalizer { + public: + using Lookup = std::function(const std::string&)>; + + // mcp_sanitize_name(text) + static std::string mcpSanitizeName(const std::string& text); + // mcp_github_target(source) -> (owner, repo) + static std::optional> mcpGithubTarget(const std::string& source); + // mcp_expand_vars(text, lookup) -> (expanded, missing) + static std::pair> mcpExpandVars(const std::string& text, const Lookup& lookup); + // mcp_rewrite_placeholders(text) -> (text, noted) + static std::pair> mcpRewritePlaceholders(const std::string& text); + // mcp_entry_from_mapping(name, raw): throws McpError + static McpNormalized mcpEntryFromMapping(const std::string& name, const Json& raw); + // mcp_normalize(source, name=None): throws McpError + static McpNormalized mcpNormalize(const std::string& source, const std::string* name = nullptr); + // mcp_tool_hash(name, description, schema) + static std::string mcpToolHash(const std::string& name, const Json& description, const Json& schema); + // mcp_bridge_name(server, tool) + static std::string mcpBridgeName(const std::string& server, const std::string& tool); + // MCP_SERVER_RE.match(name) + static bool serverNameOk(const std::string& name); +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_oauth.cpp b/taicpp/src/mcp/mcp_oauth.cpp new file mode 100644 index 0000000..c941ba7 --- /dev/null +++ b/taicpp/src/mcp/mcp_oauth.cpp @@ -0,0 +1,427 @@ +#include "mcp_oauth.h" + +#include + +#include +#include + +#include "mcp_client.h" +#include "mcp_error.h" +#include "mcp_http_transport.h" +#include "mcp_normalizer.h" +#include "wire_hash.h" +#include "wire_os.h" +#include "wire_proc.h" +#include "wire_regex.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::HttpError; +using detz::wire::UrlError; +using detz::wire::WireHash; +using detz::wire::WireHttp; +using detz::wire::WireOs; +using detz::wire::WireRegex; +using detz::wire::WireText; + +namespace { + +std::string shortError(const std::string& text) { + const std::string head = WireText::head(text, 300); + return head.empty() ? "Exception" : head; +} + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } + +std::function& consoleSlot() { + static std::function console = [](const std::string& text) { + size_t off = 0; + while (off < text.size()) { + ssize_t n = ::write(1, text.data() + off, text.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + return; + } + off += static_cast(n); + } + }; + return console; +} + +void print(const std::string& line) { consoleSlot()(line + "\n"); } + +// `for item in value` over a decoded JSON value, as tai.py iterates it. +std::vector iterate(const Json& value) { + std::vector out; + if (value.isArray()) return value.items(); + if (value.isString()) + for (char32_t cp : WireText::codepoints(value.str())) out.push_back(Json(WireText::encode(cp))); + else if (value.isObject()) + for (const auto& m : value.members()) out.push_back(Json(m.first)); + return out; +} + +bool defaultOpen(const std::string& url) { + if (!WireOs::hasEnv("DISPLAY") && !WireOs::hasEnv("WAYLAND_DISPLAY")) return false; + if (WireOs::which("xdg-open").empty()) return false; + try { + detz::wire::WireProc::Options options; + options.argv = {"xdg-open", url}; + options.in = options.out = options.err = detz::wire::WireProc::Stream::Devnull; + auto proc = detz::wire::WireProc::spawn(options); + const bool running = !proc->poll(); + proc.release(); // left to run; the browser outlives the login + return running; + } catch (const detz::wire::OsError&) { + return false; + } +} + +} // namespace + +void McpOAuth::setConsole(std::function console) { consoleSlot() = std::move(console); } + +std::string McpOAuth::secretName(const std::string& server, const std::string& kind) { + const std::string core = WireText::length(server) <= 40 + ? server + : WireText::head(server, 31) + "-" + WireHash::sha1Hex(server).substr(0, 8); + return "mcp-oauth-" + core + "-" + kind; +} + +std::pair, std::optional> McpOAuth::loadTokens(const std::string& server, + const std::string& profile) { + auto access = store_.loadSecret(secretName(server, "access"), profile); + auto refresh = store_.loadSecret(secretName(server, "refresh"), profile); + return {access, refresh}; +} + +void McpOAuth::saveTokens(const std::string& server, const std::string& access, const std::string& refresh, + const std::string& clientId, const std::string& profile) { + Json meta = Json::object({{"server", Json(server)}, {"kind", Json("mcp-oauth")}, {"client_id", Json(clientId)}}); + store_.saveSecret(secretName(server, "access"), access, meta, profile); + if (!refresh.empty()) store_.saveSecret(secretName(server, "refresh"), refresh, meta, profile); + else store_.deleteSecret(secretName(server, "refresh"), profile); +} + +void McpOAuth::dropTokens(const std::string& server, const std::string& profile) { + for (const char* kind : {"access", "refresh"}) store_.deleteSecret(secretName(server, kind), profile); +} + +std::string McpOAuth::clientId(const std::string& server, const std::string& profile) { + Json meta = store_.secretMeta(secretName(server, "access"), profile); + Json id = meta.get("client_id", Json("")); + return id.truthy() ? str(id) : ""; +} + +std::string McpOAuth::expandUrl(const Json& row, const std::string& profile) { + auto lookup = [&](const std::string& name) -> std::optional { + if (WireOs::hasEnv(name) && !WireOs::getenv(name).empty()) return WireOs::getenv(name); + return store_.loadSecret(name, profile); + }; + auto expanded = McpNormalizer::mcpExpandVars(str(row.get("url")), lookup); + if (!expanded.second.empty()) + throw McpError("unresolved secrets: " + WireText::join(expanded.second, ", ") + " (set env or store_secret)"); + return expanded.first; +} + +std::vector> McpOAuth::injectHeaders( + const std::vector>& headers, const std::string& server, + const std::string& profile) { + for (const auto& h : headers) + if (WireText::lower(h.first) == "authorization") return headers; + auto tokens = loadTokens(server, profile); + if (!tokens.first || tokens.first->empty()) return headers; + auto heads = headers; + bool replaced = false; + for (auto& h : heads) + if (h.first == "Authorization") { + h.second = "Bearer " + *tokens.first; + replaced = true; + } + if (!replaced) heads.emplace_back("Authorization", "Bearer " + *tokens.first); + return heads; +} + +Json McpOAuth::jsonGet(const std::string& url) { + detz::wire::HttpReply reply; + try { + reply = WireHttp::urlopen("GET", url, {{"Accept", "application/json"}}, nullptr, MCP_CONNECT_TIMEOUT, MCP_HTTP_MAX_BODY); + } catch (const HttpError& exc) { + throw McpError("metadata fetch failed: http " + std::to_string(exc.code)); + } catch (const UrlError& exc) { + throw McpError("metadata fetch failed: " + shortError(exc.what())); + } + Json data; + try { + data = Json::parse(WireText::decodeReplace(reply.body)); + } catch (const detz::wire::JsonError&) { + throw McpError("metadata reply is not JSON"); + } + if (!data.isObject()) throw McpError("metadata reply is not an object"); + return data; +} + +Json McpOAuth::formPost(const std::string& url, const std::vector>& fields) { + std::string body; + for (size_t i = 0; i < fields.size(); ++i) { + if (i) body += "&"; + body += WireText::quotePlus(fields[i].first) + "=" + WireText::quotePlus(fields[i].second); + } + detz::wire::HttpReply reply; + try { + reply = WireHttp::urlopen("POST", url, {{"Content-Type", "application/x-www-form-urlencoded"}, {"Accept", "application/json"}}, + &body, MCP_CONNECT_TIMEOUT, MCP_HTTP_MAX_BODY); + } catch (const HttpError& exc) { + throw McpError("token endpoint refused: http " + std::to_string(exc.code)); + } catch (const UrlError& exc) { + throw McpError("token endpoint failed: " + shortError(exc.what())); + } + Json data; + try { + data = Json::parse(WireText::decodeReplace(reply.body)); + } catch (const detz::wire::JsonError&) { + throw McpError("token reply is not JSON"); + } + if (!data.isObject()) throw McpError("token reply is not an object"); + if (data.get("error").truthy()) + throw McpError("token endpoint said " + str(data.get("error")) + ": " + str(data.get("error_description", Json("")))); + return data; +} + +std::string McpOAuth::resourceUrl(const std::string& wwwAuth) { + static const WireRegex re{"resource_metadata=\"([^\"]+)\""}; + auto match = re.search(wwwAuth); + return match ? match.group(1) : ""; +} + +std::string McpOAuth::wellknown(const std::string& issuer, const std::string& name) { + const WireHttp::Url parts = WireHttp::split(issuer); + std::string head = parts.scheme.empty() ? "" : parts.scheme + ":"; + if (!parts.netloc.empty() || parts.scheme == "http" || parts.scheme == "https") head += "//" + parts.netloc; + return head + "/.well-known/" + name + parts.path; +} + +McpOAuth::Metadata McpOAuth::discover(const std::string& url, const std::string& wwwAuth) { + const WireHttp::Url origin = WireHttp::split(url); + if ((origin.scheme != "http" && origin.scheme != "https") || origin.netloc.empty()) throw McpError("not an http(s) url"); + const std::string resource = resourceUrl(wwwAuth); + std::vector issuers; + std::string scope; + if (!resource.empty()) { + Json doc; + try { + doc = jsonGet(resource); + } catch (const McpError& exc) { + throw McpError(std::string("protected-resource metadata failed: ") + exc.what()); + } + for (const Json& item : iterate(doc.get("authorization_servers", Json::array()))) + if (item.isString()) issuers.push_back(item.str()); + std::vector scopes; + for (const Json& item : iterate(doc.get("scopes_supported", Json::array()))) + if (item.isString()) scopes.push_back(item.str()); + scope = WireText::join(scopes, " "); + } + if (issuers.empty()) issuers.push_back(origin.scheme + "://" + origin.netloc); + std::vector errors; + for (const std::string& issuer : issuers) { + Json doc; + try { + doc = jsonGet(wellknown(issuer, "oauth-authorization-server")); + } catch (const McpError& exc) { + errors.push_back(issuer + ": " + exc.what()); + continue; + } + if (!doc.get("authorization_endpoint").truthy() || !doc.get("token_endpoint").truthy()) { + errors.push_back(issuer + ": metadata lacks endpoints"); + continue; + } + if (!doc.get("registration_endpoint").truthy()) { + errors.push_back(issuer + ": no dynamic registration endpoint"); + continue; + } + Metadata meta; + meta.authorizationEndpoint = str(doc.get("authorization_endpoint")); + meta.tokenEndpoint = str(doc.get("token_endpoint")); + meta.registrationEndpoint = str(doc.get("registration_endpoint")); + meta.scope = scope; + return meta; + } + throw McpError("no usable authorization server (" + WireText::join(errors, "; ") + ")"); +} + +std::string McpOAuth::registerClient(const std::string& regEndpoint, const std::string& redirectUri) { + const std::string body = + Json::object({{"client_name", Json("tai-mcp")}, + {"redirect_uris", Json::array({Json(redirectUri)})}, + {"grant_types", Json::array({Json("authorization_code"), Json("refresh_token")})}, + {"response_types", Json::array({Json("code")})}, + {"token_endpoint_auth_method", Json("none")}}) + .dumps(); + detz::wire::HttpReply reply; + try { + reply = WireHttp::urlopen("POST", regEndpoint, {{"Content-Type", "application/json"}, {"Accept", "application/json"}}, + &body, MCP_CONNECT_TIMEOUT, MCP_HTTP_MAX_BODY); + } catch (const HttpError& exc) { + throw McpError("client registration refused: http " + std::to_string(exc.code)); + } catch (const UrlError& exc) { + throw McpError("client registration failed: " + shortError(exc.what())); + } + Json data; + try { + data = Json::parse(WireText::decodeReplace(reply.body)); + } catch (const detz::wire::JsonError&) { + throw McpError("registration reply is not JSON"); + } + if (!data.isObject() || !data.get("client_id").truthy()) throw McpError("registration reply has no client_id"); + return str(data.get("client_id")); +} + +std::pair McpOAuth::pkce() { + const std::string verifier = WireText::head(WireHash::tokenUrlsafe(64), 128); + std::string challenge = WireHash::urlsafeB64encode(WireHash::sha256Raw(verifier)); + while (!challenge.empty() && challenge.back() == '=') challenge.pop_back(); + return {verifier, challenge}; +} + +std::string McpOAuth::authorizeUrl(const Metadata& meta, const std::string& clientId, const std::string& redirectUri, + const std::string& scope, const std::string& state, const std::string& challenge, + const std::string& resource) { + std::vector> fields = { + {"response_type", "code"}, {"client_id", clientId}, {"redirect_uri", redirectUri}, + {"state", state}, {"code_challenge", challenge}, {"code_challenge_method", "S256"}, + {"resource", resource}}; + if (!scope.empty()) fields.emplace_back("scope", scope); + std::string query; + for (size_t i = 0; i < fields.size(); ++i) { + if (i) query += "&"; + query += WireText::quotePlus(fields[i].first) + "=" + WireText::quotePlus(fields[i].second); + } + return meta.authorizationEndpoint + (meta.authorizationEndpoint.find('?') != std::string::npos ? "&" : "?") + query; +} + +McpOAuth::Listener McpOAuth::startListener() { + Listener listener; + listener.capture = std::make_shared(); + listener.server = std::make_unique("127.0.0.1", 0, McpOAuthCallback::serverVersion()); + listener.server->setMethods({"GET"}); + std::shared_ptr capture = listener.capture; + listener.server->setHandler([capture](const detz::wire::WireHttpServer::Request& request, + detz::wire::WireHttpServer::Response& response) { + McpOAuthCallback::doGet(request, response, capture->result); + capture->received = true; + return true; + }); + listener.redirectUri = "http://127.0.0.1:" + std::to_string(listener.server->port()) + "/callback"; + return listener; +} + +std::string McpOAuth::waitCallback(Listener& listener, const std::string& state, double timeout) { + const double deadline = WireOs::now() + timeout; + std::string code; + bool done = false; + std::string failure; + while (WireOs::now() < deadline) { + listener.server->handleRequest(1.0); + if (listener.capture->received) { + const McpOAuthResult& result = listener.capture->result; + if (!result.error.empty()) failure = "authorization failed: " + result.error; + else if (result.state != state) failure = "authorization state mismatch"; + else if (result.code.empty()) failure = "authorization returned no code"; + else code = result.code; + done = true; + break; + } + } + listener.server->close(); + if (!failure.empty()) throw McpError(failure); + if (done) return code; + throw McpError("timed out after " + std::to_string(static_cast(timeout)) + + "s waiting for the browser (rerun mcp_login to retry)"); +} + +std::pair McpOAuth::exchange(const std::string& tokenEndpoint, const std::string& code, + const std::string& redirectUri, const std::string& clientId, + const std::string& verifier, const std::string& resource) { + Json data = formPost(tokenEndpoint, {{"grant_type", "authorization_code"}, + {"code", code}, + {"redirect_uri", redirectUri}, + {"client_id", clientId}, + {"code_verifier", verifier}, + {"resource", resource}}); + if (!data.get("access_token").truthy()) throw McpError("token reply has no access_token"); + Json refresh = data.get("refresh_token", Json("")); + return {str(data.get("access_token")), refresh.truthy() ? str(refresh) : ""}; +} + +std::pair McpOAuth::refreshFlow(const Json& row, const std::string& profile) { + const std::string name = str(row.get("name")); + auto tokens = loadTokens(name, profile); + if (!tokens.second || tokens.second->empty()) throw McpError("no refresh token stored"); + const std::string client = clientId(name, profile); + if (client.empty()) throw McpError("no client_id stored, rerun mcp_login"); + const std::string url = expandUrl(row, profile); + Metadata meta = discover(url); + Json data = formPost(meta.tokenEndpoint, + {{"grant_type", "refresh_token"}, {"refresh_token", *tokens.second}, {"client_id", client}, {"resource", url}}); + if (!data.get("access_token").truthy()) throw McpError("refresh reply has no access_token"); + Json next = data.get("refresh_token", Json("")); + saveTokens(name, str(data.get("access_token")), next.truthy() ? str(next) : *tokens.second, client, profile); + return {str(data.get("access_token")), "refreshed"}; +} + +std::string McpOAuth::login(const Json& row, const std::string& profile, const Opener& opener) { + if (row.get("transport") != Json("http")) throw McpError("local stdio servers need no login"); + const std::string name = str(row.get("name")); + const std::string url = expandUrl(row, profile); + std::string wwwAuth; + { + McpClient probe(std::make_unique(url)); + struct Closer { + McpClient& client; + ~Closer() { + try { + client.close(); + } catch (...) { + } + } + } closer{probe}; + bool anonymous = false; + try { + probe.initialize(); + anonymous = true; + } catch (const McpHttpError& exc) { + if (exc.code != 401) throw McpError("server unreachable: http " + std::to_string(exc.code)); + wwwAuth = exc.wwwAuth; + } + if (anonymous) throw McpError("server needs no login (anonymous handshake worked)"); + } + Metadata meta = discover(url, wwwAuth); + Listener listener = startListener(); + std::string client; + try { + client = registerClient(meta.registrationEndpoint, listener.redirectUri); + } catch (const McpError&) { + listener.server->close(); + throw; + } + auto [verifier, challenge] = pkce(); + const std::string state = WireHash::tokenUrlsafe(16); + const std::string authUrl = authorizeUrl(meta, client, listener.redirectUri, meta.scope, state, challenge, url); + print("mcp login for '" + name + "': approve in your browser"); + print(authUrl); + bool opened = false; + try { + opened = (opener ? opener : Opener(defaultOpen))(authUrl); + } catch (...) { + opened = false; + } + if (!opened) print("(no browser opened, visit the URL above yourself)"); + const std::string code = waitCallback(listener, state); + auto tokens = exchange(meta.tokenEndpoint, code, listener.redirectUri, client, verifier, url); + saveTokens(name, tokens.first, tokens.second, client, profile); + const std::string note = "logged in to '" + name + "' (tokens sealed in vault"; + return !tokens.second.empty() ? note + ")" : note + ", no refresh token issued, rerun login when it expires)"; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_oauth.h b/taicpp/src/mcp/mcp_oauth.h new file mode 100644 index 0000000..d791924 --- /dev/null +++ b/taicpp/src/mcp/mcp_oauth.h @@ -0,0 +1,82 @@ +#pragma once +// McpOAuth: OAuth 2.1 + MCP authorization: discovery, dynamic client +// registration, PKCE loopback login, refresh. Tokens live in vault secrets as +// mcp-oauth--access / -refresh. +#include +#include +#include +#include +#include + +#include + +#include "mcp_constants.h" +#include "mcp_oauth_callback.h" +#include "mcp_store.h" +#include "wire_http.h" + +namespace detz::mcp { + +class McpOAuth { + public: + using Opener = std::function; + struct Metadata { + std::string authorizationEndpoint, tokenEndpoint, registrationEndpoint, scope; + }; + struct Capture { + McpOAuthResult result; + bool received{false}; + }; + struct Listener { + std::unique_ptr server; + std::string redirectUri; + std::shared_ptr capture; + }; + + explicit McpOAuth(McpStore& store) : store_(store) {} + + static std::string secretName(const std::string& server, const std::string& kind); + // (access, refresh): nullopt for a missing secret. + std::pair, std::optional> loadTokens(const std::string& server, + const std::string& profile); + void saveTokens(const std::string& server, const std::string& access, const std::string& refresh, + const std::string& clientId, const std::string& profile); + void dropTokens(const std::string& server, const std::string& profile); + std::string clientId(const std::string& server, const std::string& profile); + // Expands ${VAR} in row["url"]; throws McpError for unresolved names. + std::string expandUrl(const Json& row, const std::string& profile); + // Adds the Bearer header unless one is configured. Returns a copy. + std::vector> injectHeaders( + const std::vector>& headers, const std::string& server, + const std::string& profile); + + static Json jsonGet(const std::string& url); + static Json formPost(const std::string& url, const std::vector>& fields); + static std::string resourceUrl(const std::string& wwwAuth); + static std::string wellknown(const std::string& issuer, const std::string& name); + Metadata discover(const std::string& url, const std::string& wwwAuth = ""); + static std::string registerClient(const std::string& regEndpoint, const std::string& redirectUri); + // pkce() -> (verifier, challenge) + static std::pair pkce(); + static std::string authorizeUrl(const Metadata& meta, const std::string& clientId, const std::string& redirectUri, + const std::string& scope, const std::string& state, const std::string& challenge, + const std::string& resource); + static Listener startListener(); + // Serves the loopback redirect until the code arrives or time runs out. + static std::string waitCallback(Listener& listener, const std::string& state, double timeout = MCP_OAUTH_TIMEOUT); + std::pair exchange(const std::string& tokenEndpoint, const std::string& code, + const std::string& redirectUri, const std::string& clientId, + const std::string& verifier, const std::string& resource); + // Refreshes with the stored refresh token. Returns (access, "refreshed"). + std::pair refreshFlow(const Json& row, const std::string& profile); + // Full interactive login; returns the note. + std::string login(const Json& row, const std::string& profile, const Opener& opener = nullptr); + + // print() target for the login screen (default: stdout). + static void setConsole(std::function console); + + private: + McpStore& store_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_oauth_callback.cpp b/taicpp/src/mcp/mcp_oauth_callback.cpp new file mode 100644 index 0000000..2ba4517 --- /dev/null +++ b/taicpp/src/mcp/mcp_oauth_callback.cpp @@ -0,0 +1,41 @@ +#include "mcp_oauth_callback.h" + +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::WireText; + +namespace { + +// urllib.parse.parse_qs(query).get(key, [""])[0] +std::string firstValue(const std::string& query, const std::string& key) { + for (const std::string& pair : WireText::split(query, "&")) { + if (pair.empty()) continue; + const size_t eq = pair.find('='); + if (eq == std::string::npos) continue; // parse_qs drops blank values + const std::string name = WireText::unquotePlus(pair.substr(0, eq)); + const std::string value = WireText::unquotePlus(pair.substr(eq + 1)); + if (name == key && !value.empty()) return value; + } + return ""; +} + +} // namespace + +void McpOAuthCallback::doGet(const detz::wire::WireHttpServer::Request& request, + detz::wire::WireHttpServer::Response& response, McpOAuthResult& result) { + const size_t q = request.path.find('?'); + std::string query = q == std::string::npos ? "" : request.path.substr(q + 1); + const size_t hash = query.find('#'); + if (hash != std::string::npos) query.resize(hash); + result.code = firstValue(query, "code"); + result.state = firstValue(query, "state"); + result.error = firstValue(query, "error"); + const std::string body = "

Login complete, you can close this window.

"; + response.status = 200; + response.headers = {{"Content-Type", "text/html"}, {"Content-Length", std::to_string(body.size())}}; + response.body = body; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_oauth_callback.h b/taicpp/src/mcp/mcp_oauth_callback.h new file mode 100644 index 0000000..692994b --- /dev/null +++ b/taicpp/src/mcp/mcp_oauth_callback.h @@ -0,0 +1,26 @@ +#pragma once +// McpOAuthCallback: loopback landing page for the OAuth authorization +// redirect. Any GET records code/state/error from the query and answers 200. +#include +#include + +#include "wire_http.h" + +namespace detz::mcp { + +struct McpOAuthResult { + std::string code; + std::string state; + std::string error; +}; + +class McpOAuthCallback { + public: + // do_GET: fills `result` (the first value of each query key, or "") and the + // response (200, text/html, Content-Length). + static void doGet(const detz::wire::WireHttpServer::Request& request, detz::wire::WireHttpServer::Response& response, + McpOAuthResult& result); + static const char* serverVersion() { return "tai-mcp-oauth"; } +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_backend.h b/taicpp/src/mcp/mcp_serve_backend.h new file mode 100644 index 0000000..19c69ad --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_backend.h @@ -0,0 +1,56 @@ +#pragma once +// McpServeBackend: what `--serve-mcp` needs from the agent core. Each MCP +// session gets its own McpServeAgent (a yolo+auto Agent with quiet output in +// tai.py); the backend also owns the stateless completions path. +#include +#include +#include +#include + +#include "wire_json.h" + +namespace detz::mcp { + +using detz::wire::Json; + +// tai.py's Denied: a tool call the user (or policy) refused. +class McpToolDenied : public std::runtime_error { + public: + explicit McpToolDenied(const std::string& text = "denied by user") : std::runtime_error(text) {} +}; + +// tai.py's BackendError: the model backend failed. +class McpBackendError : public std::runtime_error { + public: + explicit McpBackendError(const std::string& text) : std::runtime_error(text) {} +}; + +// One served Agent (Agent(config, store, yolo=True, auto=True); quiet). +class McpServeAgent { + public: + virtual ~McpServeAgent() = default; + // agent.tools.dispatch(name, args): the tool's text. Throws McpToolDenied or + // any std::exception (reported as isError). + virtual std::string dispatch(const std::string& name, const Json& args) = 0; + // agent.deadline = deadlineEpoch; agent.run_turn(message, capture=True, + // max_steps=maxSteps); agent.deadline = None afterwards. Throws McpBackendError + // or any std::exception (reported as isError). + virtual std::string runTurn(const std::string& message, int maxSteps, double deadlineEpoch) = 0; +}; + +class McpServeBackend { + public: + virtual ~McpServeBackend() = default; + // TOOL_SCHEMAS: OpenAI function-tool dicts {type, function: {name, + // description, parameters}}. + virtual std::vector toolSchemas() = 0; + // A fresh served agent. + virtual std::shared_ptr createAgent() = 0; + // MAX_STEPS (the default step budget of a chat turn). + virtual int maxSteps() = 0; + // ChatClient(config).complete(cleaned): the model's reply. A non-string reply + // is returned already json.dumps'ed. Throws McpBackendError. + virtual std::string complete(const std::vector& messages) = 0; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_context.cpp b/taicpp/src/mcp/mcp_serve_context.cpp new file mode 100644 index 0000000..258b349 --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_context.cpp @@ -0,0 +1,73 @@ +#include "mcp_serve_context.h" + +#include "mcp_constants.h" +#include "wire_hash.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::WireHash; +using detz::wire::WireOs; +using detz::wire::WireText; + +McpServeSession::McpServeSession(std::shared_ptr agentIn, std::string profileIn) + : agent(std::move(agentIn)), profile(std::move(profileIn)), created(WireOs::now()), lastSeen(created) {} + +bool McpServeContext::authed(const std::map& headers) const { + if (key_.empty()) return false; + std::string presented; + auto it = headers.find("authorization"); + if (it != headers.end()) presented = it->second; + if (WireText::startsWith(WireText::lower(presented), "bearer ")) presented = presented.substr(7); + // hmac.compare_digest raises TypeError for non-ASCII text: the request is + // dropped without a reply (the exception escapes the handler). + return WireHash::compareDigest(WireText::strip(presented), key_); +} + +void McpServeContext::prune() { + const double now = WireOs::now(); + for (auto it = sessions_.begin(); it != sessions_.end();) { + if (now - it->second.lastSeen > MCP_SESSION_IDLE) it = sessions_.erase(it); + else ++it; + } +} + +McpServeSession* McpServeContext::session(const std::string& sid) { + auto it = sessions_.find(sid); + return it == sessions_.end() ? nullptr : &it->second; +} + +const McpServeSession* McpServeContext::session(const std::string& sid) const { + auto it = sessions_.find(sid); + return it == sessions_.end() ? nullptr : &it->second; +} + +void McpServeContext::addSession(const std::string& sid, McpServeSession session) { + sessions_.erase(sid); + sessions_.emplace(sid, std::move(session)); +} + +void McpServeContext::dropSession(const std::string& sid) { sessions_.erase(sid); } + +void McpServeContext::touch(const std::string& sid, double lastSeen) { + if (McpServeSession* s = session(sid)) s->lastSeen = lastSeen; +} + +std::shared_ptr McpServeContext::agentFor(const std::string& sid, const std::string& profile) { + McpServeSession* found = session(sid); + if (!found || !found->agent) { + std::shared_ptr agent = backend_.createAgent(); + if (!found) { + addSession(sid, McpServeSession(agent, profile.empty() ? "default" : profile)); + found = session(sid); + } else { + found->agent = agent; + found->profile = !profile.empty() ? profile : (!found->profile.empty() ? found->profile : "default"); + } + } + found->lastSeen = WireOs::now(); + return found->agent; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_context.h b/taicpp/src/mcp/mcp_serve_context.h new file mode 100644 index 0000000..fd79d2b --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_context.h @@ -0,0 +1,48 @@ +#pragma once +// McpServeSession and McpServeContext: the in-memory sessions of the served +// MCP endpoint, the Bearer check and the idle pruning. +#include +#include +#include +#include + +#include "mcp_serve_backend.h" + +namespace detz::mcp { + +class McpServeSession { + public: + McpServeSession(std::shared_ptr agentIn, std::string profileIn); + std::shared_ptr agent; + std::string profile; + double created; + double lastSeen; +}; + +class McpServeContext { + public: + McpServeContext(McpServeBackend& backend, std::string key) : backend_(backend), key_(std::move(key)) {} + + McpServeBackend& backend() { return backend_; } + // authed(headers): the Bearer KEY check (header names lower-case). + bool authed(const std::map& headers) const; + // prune(): drops sessions idle for more than MCP_SESSION_IDLE seconds. + void prune(); + // agent_for(sid, profile): the session's agent, created on first use. + std::shared_ptr agentFor(const std::string& sid, const std::string& profile); + + McpServeSession* session(const std::string& sid); + const McpServeSession* session(const std::string& sid) const; + void addSession(const std::string& sid, McpServeSession session); + void dropSession(const std::string& sid); + size_t sessionCount() const { return sessions_.size(); } + // Test hook: backdates a session's last_seen. + void touch(const std::string& sid, double lastSeen); + + private: + McpServeBackend& backend_; + std::string key_; + std::map sessions_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_handler.cpp b/taicpp/src/mcp/mcp_serve_handler.cpp new file mode 100644 index 0000000..48e45ef --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_handler.cpp @@ -0,0 +1,291 @@ +#include "mcp_serve_handler.h" + +#include "cap_py.h" +#include "mcp_constants.h" +#include "wire_app.h" +#include "wire_hash.h" +#include "wire_log.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::WireApp; +using detz::wire::WireHash; +using detz::wire::WireLog; +using detz::wire::WireOs; +using detz::wire::WireText; +using Request = detz::wire::WireHttpServer::Request; +using Response = detz::wire::WireHttpServer::Response; + +namespace { + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } + +std::string shortError(const std::string& text) { + const std::string head = WireText::head(text, 300); + return head.empty() ? "Exception" : head; +} + +std::string pathOnly(const std::string& path) { return WireText::split(path, "?", 1)[0]; } + +Json errorReply(const Json& id, long long code, const std::string& message) { + return Json::object({{"jsonrpc", Json("2.0")}, + {"id", id}, + {"error", Json::object({{"code", Json(code)}, {"message", Json(message)}})}}); +} + +} // namespace + +McpServeHandler::McpServeHandler(McpServeContext& ctx) : ctx_(ctx), tools_(serveTools(ctx.backend().toolSchemas())) {} + +std::vector McpServeHandler::serveTools(const std::vector& nativeSchemas) { + std::vector tools; + for (const Json& schema : nativeSchemas) { + const Json function = schema.get("function"); + tools.push_back(Json::object({{"name", function.get("name")}, + {"description", function.get("description")}, + {"inputSchema", function.get("parameters")}})); + } + tools.push_back(Json::object( + {{"name", Json("chat")}, + {"description", Json("Full tai agent turn with tools, on the session agent. History is kept per MCP session.")}, + {"inputSchema", + Json::object({{"type", Json("object")}, + {"properties", Json::object({{"message", Json::object({{"type", Json("string")}})}, + {"profile", Json::object({{"type", Json("string")}})}, + {"max_steps", Json::object({{"type", Json("integer")}})}})}, + {"required", Json::array({Json("message")})}})}})); + tools.push_back(Json::object( + {{"name", Json("completions")}, + {"description", Json("One stateless model completion with the current model, no tools.")}, + {"inputSchema", + Json::object({{"type", Json("object")}, + {"properties", + Json::object({{"messages", Json::object({{"type", Json("array")}, + {"items", Json::object({{"type", Json("object")}})}})}})}, + {"required", Json::array({Json("messages")})}})}})); + return tools; +} + +void McpServeHandler::empty(Response& response, int status) { + response.status = status; + response.headers = {{"Content-Length", "0"}}; + response.body.clear(); +} + +void McpServeHandler::sendJson(Response& response, const Json& payload, const std::string& session) { + const std::string raw = payload.dumps(); + response.status = 200; + response.headers = {{"Content-Type", "application/json"}, {"Content-Length", std::to_string(raw.size())}}; + if (!session.empty()) response.headers.emplace_back("Mcp-Session-Id", session); + response.body = raw; +} + +bool McpServeHandler::handle(const Request& request, Response& response) { + if (request.method == "GET") { + empty(response, 405); + return true; + } + if (request.method == "DELETE") { + if (pathOnly(request.path) != "/mcp" || !ctx_.authed(request.headers)) { + empty(response, !ctx_.authed(request.headers) ? 401 : 404); + return true; + } + ctx_.dropSession(request.header("Mcp-Session-Id")); + empty(response, 200); + return true; + } + // POST + ctx_.prune(); + if (pathOnly(request.path) != "/mcp") { + empty(response, 404); + return true; + } + if (!ctx_.authed(request.headers)) { + WireLog::event("WARNING", "serve", "denied", "rejected unauthenticated request", "", + Json::object({{"path", Json(pathOnly(request.path))}})); + empty(response, 401); + return true; + } + long long length = 0; + { + // int(self.headers.get("Content-Length") or 0), ValueError -> 0 + long long parsed = 0; + const std::string raw = request.header("Content-Length"); + if (!raw.empty() && detz::cap::CapPy::toInt(Json(raw), parsed) && parsed > 0) length = parsed; + } + if (length > MCP_HTTP_MAX_BODY) { + empty(response, 413); + return true; + } + const std::string body = length ? request.body : std::string(); + Json message; + try { + std::string text, error; + if (!WireText::decodeStrict(body, text, error)) throw detz::wire::JsonError(error); + message = Json::parse(text.empty() ? "{}" : text); + } catch (const detz::wire::JsonError&) { + empty(response, 400); + return true; + } + if (!message.isObject() || message.get("jsonrpc") != Json("2.0")) { + empty(response, 400); + return true; + } + const std::string sid = request.header("Mcp-Session-Id"); + const Json methodJson = message.get("method", Json("")); + const std::string method = str(methodJson); + if (method != "initialize" && !ctx_.session(sid)) { + sendJson(response, errorReply(message.get("id"), -32603, "unknown session, initialize first"), ""); + return true; + } + auto answer = dispatchMessage(message, sid); + if (answer.first.isNull()) { + response.status = 202; + response.headers = {{"Content-Length", "0"}}; + response.body.clear(); + } else { + sendJson(response, answer.first, answer.second); + } + return true; +} + +std::pair McpServeHandler::dispatchMessage(const Json& message, const std::string& sid) { + const double started = WireOs::now(); + auto result = dispatchInner(message, sid); + const std::string method = str(message.get("method", Json(""))); + std::string tool; + if (method == "tools/call") { + Json params = message.get("params"); + Json name = params.isObject() ? params.get("name") : Json(); + tool = name.truthy() ? str(name) : ""; + } + std::string outcome = "ok"; + if (!result.first.isNull()) { + if (result.first.get("error").isObject()) outcome = "error"; + else if (result.first.get("result").isObject() && result.first.get("result").get("isError").truthy()) outcome = "error"; + } + std::string profile; + if (const McpServeSession* row = ctx_.session(!result.second.empty() ? result.second : sid)) profile = row->profile; + const long long ms = static_cast((WireOs::now() - started) * 1000); + WireLog::event("INFO", "serve", "request", "served " + (method.empty() ? std::string("?") : method) + " in " + + std::to_string(ms) + "ms (" + outcome + ")", + profile, + Json::object({{"method", Json(method)}, + {"tool", Json(tool)}, + {"session", Json(!result.second.empty() ? result.second : sid)}, + {"exec_ms", Json(ms)}, + {"outcome", Json(outcome)}})); + return result; +} + +Json McpServeHandler::textResult(const Json& msgId, const std::string& text, bool isError) { + Json result = Json::object({{"content", Json::array({Json::object({{"type", Json("text")}, {"text", Json(text)}})})}}); + if (isError) result.set("isError", Json(true)); + return Json::object({{"jsonrpc", Json("2.0")}, {"id", msgId}, {"result", result}}); +} + +std::pair McpServeHandler::dispatchInner(const Json& message, const std::string& sid) { + const std::string method = str(message.get("method", Json(""))); + const Json msgId = message.get("id"); + Json params = message.get("params"); + if (!params.truthy()) params = Json::object(); + if (!params.isObject()) throw std::runtime_error("AttributeError: params.get"); + + auto fail = [&](long long code, const std::string& text) -> std::pair { + if (msgId.isNull()) return {Json(), ""}; + return {errorReply(msgId, code, text), sid}; + }; + auto ok = [&](const Json& result) -> std::pair { + if (msgId.isNull()) return {Json(), ""}; + return {Json::object({{"jsonrpc", Json("2.0")}, {"id", msgId}, {"result", result}}), sid}; + }; + + if (method == "initialize") { + const std::string fresh = WireHash::uuid4Hex(); + ctx_.addSession(fresh, McpServeSession(nullptr, "")); + Json answer = Json::object( + {{"jsonrpc", Json("2.0")}, + {"id", msgId}, + {"result", Json::object({{"protocolVersion", Json(std::string(MCP_PROTOCOL_VERSION))}, + {"capabilities", Json::object({{"tools", Json::object()}})}, + {"serverInfo", Json::object({{"name", Json(std::string(MCP_CLIENT_NAME))}, + {"version", Json(WireApp::version())}})}})}}); + return {answer, fresh}; + } + if (method == "notifications/initialized") return {Json(), ""}; + if (method == "ping") return ok(Json::object()); + if (method == "tools/list") { + Json list = Json::array(); + for (const Json& tool : tools_) list.push(tool); + return ok(Json::object({{"tools", list}})); + } + if (method != "tools/call") return fail(-32601, "unknown method " + method); + const std::string name = str(params.get("name", Json(""))); + Json args = params.get("arguments"); + if (!args.truthy()) args = Json::object(); + if (!args.isObject()) return fail(-32602, "arguments must be an object"); + if (name == "chat") return runChat(msgId, sid, args); + if (name == "completions") return runCompletions(msgId, sid, args); + std::shared_ptr agent = ctx_.agentFor(sid, ""); + std::string text; + try { + text = agent->dispatch(name, args); + } catch (const McpToolDenied& exc) { + return ok(Json::object({{"content", Json::array({Json::object({{"type", Json("text")}, + {"text", Json(std::string("denied: ") + exc.what())}})})}, + {"isError", Json(true)}})); + } catch (const std::exception& exc) { + return ok(Json::object({{"content", Json::array({Json::object({{"type", Json("text")}, + {"text", Json("error: " + shortError(exc.what()))}})})}, + {"isError", Json(true)}})); + } + const bool failed = WireText::startsWith(text, "error:") || WireText::startsWith(text, "denied"); + Json result = Json::object({{"content", Json::array({Json::object({{"type", Json("text")}, {"text", Json(text)}})})}}); + if (failed) result.set("isError", Json(true)); + return ok(result); +} + +std::pair McpServeHandler::runChat(const Json& msgId, const std::string& sid, const Json& args) { + const Json message = args.get("message", Json("")); + if (!message.isString() || WireText::strip(message.str()).empty()) + return {errorReply(msgId, -32602, "chat needs a message string"), sid}; + long long steps = 0; + { + Json raw = args.get("max_steps", Json(0)); + long long v = 0; + if (raw.truthy() && detz::cap::CapPy::toInt(raw, v)) steps = v; + } + Json profile = args.get("profile"); + std::shared_ptr agent = ctx_.agentFor(sid, profile.truthy() ? str(profile) : ""); + const double deadline = WireOs::now() + MCP_CHAT_TIMEOUT; + try { + const int maxSteps = steps ? static_cast(steps) : ctx_.backend().maxSteps(); + const std::string text = agent->runTurn(message.str(), maxSteps, deadline); + return {textResult(msgId, text, false), sid}; + } catch (const std::exception& exc) { + return {textResult(msgId, "error: " + shortError(exc.what()), true), sid}; + } +} + +std::pair McpServeHandler::runCompletions(const Json& msgId, const std::string& sid, const Json& args) { + const Json messages = args.get("messages"); + if (!messages.isArray() || messages.items().empty()) + return {errorReply(msgId, -32602, "completions needs a messages array"), sid}; + std::vector cleaned; + for (const Json& item : messages.items()) { + const Json role = item.isObject() ? item.get("role") : Json(); + const bool roleOk = role == Json("system") || role == Json("user") || role == Json("assistant"); + if (!item.isObject() || !roleOk) return {errorReply(msgId, -32602, "bad message entry (role/content required)"), sid}; + if (!item.get("content").isString()) return {errorReply(msgId, -32602, "bad message entry (role/content required)"), sid}; + cleaned.push_back(Json::object({{"role", role}, {"content", item.get("content")}})); + } + try { + return {textResult(msgId, ctx_.backend().complete(cleaned), false), sid}; + } catch (const McpBackendError& exc) { + return {textResult(msgId, "error: " + shortError(exc.what()), true), sid}; + } +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_handler.h b/taicpp/src/mcp/mcp_serve_handler.h new file mode 100644 index 0000000..3d15b44 --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_handler.h @@ -0,0 +1,41 @@ +#pragma once +// McpServeHandler: the request handler of the served MCP endpoint +// (mcp_serve_handler in tai.py): POST /mcp, GET /mcp (405), DELETE /mcp. +// Always a single JSON reply, never SSE. +#include +#include +#include + +#include "mcp_serve_context.h" +#include "wire_http.h" + +namespace detz::mcp { + +class McpServeHandler { + public: + explicit McpServeHandler(McpServeContext& ctx); + + // The tools served: native schemas plus chat and completions (mcp_serve_tools). + const std::vector& tools() const { return tools_; } + static std::vector serveTools(const std::vector& nativeSchemas); + + // Handles one parsed request. Returns false to drop the connection without + // answering (an exception escaping a Python handler). + bool handle(const detz::wire::WireHttpServer::Request& request, detz::wire::WireHttpServer::Response& response); + + // dispatch_message(message, sid) -> (answer or Null, session id) + std::pair dispatchMessage(const Json& message, const std::string& sid); + + private: + static void empty(detz::wire::WireHttpServer::Response& response, int status); + static void sendJson(detz::wire::WireHttpServer::Response& response, const Json& payload, const std::string& session); + std::pair dispatchInner(const Json& message, const std::string& sid); + std::pair runChat(const Json& msgId, const std::string& sid, const Json& args); + std::pair runCompletions(const Json& msgId, const std::string& sid, const Json& args); + static Json textResult(const Json& msgId, const std::string& text, bool isError); + + McpServeContext& ctx_; + std::vector tools_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_main.cpp b/taicpp/src/mcp/mcp_serve_main.cpp new file mode 100644 index 0000000..5a230f8 --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_main.cpp @@ -0,0 +1,104 @@ +#include "mcp_serve_main.h" + +#include +#include + +#include + +#include "cap_interrupt.h" +#include "cap_py.h" +#include "mcp_constants.h" +#include "mcp_serve_context.h" +#include "mcp_serve_handler.h" +#include "wire_app.h" +#include "wire_http.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::cap::CapInterrupt; +using detz::cap::CapPy; +using detz::wire::WireApp; +using detz::wire::WireHttpServer; +using detz::wire::WireOs; +using detz::wire::WireText; + +namespace { + +void writeAll(int fd, const std::string& text) { + size_t off = 0; + while (off < text.size()) { + ssize_t n = ::write(fd, text.data() + off, text.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + return; + } + off += static_cast(n); + } +} + +// paint(text, code): colors only on a tty without NO_COLOR. +std::string paint(const std::string& text, const char* code) { + if (WireOs::hasEnv("NO_COLOR") && !WireOs::getenv("NO_COLOR").empty()) return text; + if (!::isatty(1)) return text; + return std::string(code) + text + "\033[0m"; +} + +} // namespace + +int McpServeMain::mcpServeMain(McpServeHost& host, const McpServeArgs& args, const std::string& key) { + auto badPort = [] { + writeAll(2, "error: bad port, use 1-65535\n"); + return 2; + }; + long long port = 0; + { + // int(args.mcp_port or os.environ.get("TAI_MCP_PORT") or MCP_DEFAULT_PORT) + if (args.mcpPort && *args.mcpPort != 0) { + port = *args.mcpPort; + } else if (!WireOs::getenv("TAI_MCP_PORT").empty()) { + if (!CapPy::toInt(detz::wire::Json(WireOs::getenv("TAI_MCP_PORT")), port)) return badPort(); + } else { + port = MCP_DEFAULT_PORT; + } + } + if (port < 1 || port > 65535) return badPort(); + std::string bind = args.mcpBind ? *args.mcpBind : ""; + if (bind.empty()) bind = WireOs::getenv("TAI_MCP_BIND"); + if (bind.empty()) bind = "127.0.0.1"; + std::unique_ptr backend = host.boot(); + McpServeContext ctx(*backend, key); + McpServeHandler handler(ctx); + std::unique_ptr server; + try { + server = std::make_unique(bind, static_cast(port), "tai-mcp/" + WireApp::version()); + } catch (const std::exception& exc) { + writeAll(2, std::string("OSError: ") + exc.what() + "\n"); + return 1; + } + server->setMaxBody(MCP_HTTP_MAX_BODY); + server->setHandler([&handler](const WireHttpServer::Request& request, WireHttpServer::Response& response) { + return handler.handle(request, response); + }); + server->setLogger([](const std::string& method, const std::string& path) { + writeAll(2, "mcp " + method + " " + WireText::split(path, "?", 1)[0] + "\n"); + }); + writeAll(1, paint("serving MCP on http://" + bind + ":" + std::to_string(port) + "/mcp (" + + std::to_string(backend->toolSchemas().size()) + " tools + chat + completions)", + "\033[32m") + + "\n"); + writeAll(1, paint("key is the authorization: served tools run yolo, no approvals asked", "\033[33m") + "\n"); + struct sigaction act {}, old {}; + act.sa_handler = &CapInterrupt::capInterrupt; + sigemptyset(&act.sa_mask); + act.sa_flags = 0; + ::sigaction(SIGINT, &act, &old); + CapInterrupt::clear(); + while (!CapInterrupt::requested()) server->handleRequest(0.5); + server->close(); + ::sigaction(SIGINT, &old, nullptr); + return 0; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_serve_main.h b/taicpp/src/mcp/mcp_serve_main.h new file mode 100644 index 0000000..4f3f846 --- /dev/null +++ b/taicpp/src/mcp/mcp_serve_main.h @@ -0,0 +1,31 @@ +#pragma once +// McpServeMain: mcp_serve_main, the `--serve-mcp KEY` entry: flags, boot, the +// single-threaded HTTP server and its startup banner. +#include +#include +#include + +#include "mcp_serve_backend.h" + +namespace detz::mcp { + +// The parsed --mcp-port / --mcp-bind flags. +struct McpServeArgs { + std::optional mcpPort; // 0 behaves like an absent flag + std::optional mcpBind; +}; + +// Boots the vault and the agent core (tai.py boot(args)). +class McpServeHost { + public: + virtual ~McpServeHost() = default; + virtual std::unique_ptr boot() = 0; +}; + +class McpServeMain { + public: + // mcp_serve_main(args, key): returns the process exit code. + static int mcpServeMain(McpServeHost& host, const McpServeArgs& args, const std::string& key); +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_sqlite_store.cpp b/taicpp/src/mcp/mcp_sqlite_store.cpp new file mode 100644 index 0000000..16be526 --- /dev/null +++ b/taicpp/src/mcp/mcp_sqlite_store.cpp @@ -0,0 +1,252 @@ +#include "mcp_sqlite_store.h" + +#include + +#include "mcp_constants.h" +#include "mcp_normalizer.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::ValueError; +using detz::wire::WireText; + +namespace { +const char* kSealPrefix = "tai1$"; + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } +} // namespace + +std::string McpSqliteStore::nowIso() { + std::time_t now = std::time(nullptr); + struct tm gm; + ::gmtime_r(&now, &gm); + char buf[40]; + std::strftime(buf, sizeof buf, "%Y-%m-%dT%H:%M:%S+00:00", &gm); + return buf; +} + +std::vector McpSqliteStore::rows(const std::string& sql, const std::vector& args, int columns) { + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) throw McpStoreError(sqlite3_errmsg(db_)); + for (size_t i = 0; i < args.size(); ++i) { + if (args[i]) + sqlite3_bind_text(stmt, static_cast(i) + 1, args[i]->c_str(), static_cast(args[i]->size()), SQLITE_TRANSIENT); + else + sqlite3_bind_null(stmt, static_cast(i) + 1); + } + std::vector out; + while (true) { + const int rc = sqlite3_step(stmt); + if (rc == SQLITE_DONE) break; + if (rc != SQLITE_ROW) { + const std::string msg = sqlite3_errmsg(db_); + sqlite3_finalize(stmt); + throw McpStoreError(msg); + } + Row row; + for (int c = 0; c < columns; ++c) { + if (sqlite3_column_type(stmt, c) == SQLITE_NULL) { + row.emplace_back(std::nullopt); + } else { + const unsigned char* text = sqlite3_column_text(stmt, c); + row.emplace_back(std::string(reinterpret_cast(text), static_cast(sqlite3_column_bytes(stmt, c)))); + } + } + out.push_back(std::move(row)); + } + sqlite3_finalize(stmt); + return out; +} + +void McpSqliteStore::exec(const std::string& sql, const std::vector& args) { rows(sql, args, 0); } + +void McpSqliteStore::commit() { + // Python's sqlite3 module commits the implicit transaction; with autocommit + // statements here there is nothing pending unless a transaction is open. + if (!sqlite3_get_autocommit(db_)) exec("COMMIT", {}); +} + +void McpSqliteStore::ensureSchema() { + struct Table { + const char* name; + const char* columns; + const char* pk; + }; + static const Table tables[] = { + {"mcp_servers", + "name TEXT PRIMARY KEY, transport TEXT, command TEXT, args_json TEXT, env_json TEXT, url TEXT, headers_json TEXT, " + "cwd TEXT, allow_json TEXT, deny_json TEXT, source TEXT, created TEXT, updated TEXT", + ""}, + {"mcp_server_profiles", "server TEXT, profile TEXT, enabled INTEGER", "server, profile"}, + {"mcp_tool_pins", "server TEXT, tool TEXT, hash TEXT, updated TEXT", "server, tool"}, + {"mcp_server_tools", "server TEXT, tool TEXT, description TEXT, schema_json TEXT, updated TEXT", "server, tool"}, + }; + for (const Table& t : tables) { + if (!rows("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", {std::string(t.name)}, 1).empty()) continue; + std::string pk = *t.pk ? std::string(", PRIMARY KEY (") + t.pk + ")" : std::string(); + exec(std::string("CREATE TABLE ") + t.name + " (" + t.columns + pk + ")", {}); + } +} + +Json McpSqliteStore::parseJson(const Cell& raw, const Json& fallback) { + Json data; + try { + data = Json::parse(raw ? *raw : ""); + } catch (const detz::wire::JsonError&) { + return fallback; + } + // isinstance(data, type(fallback)) + const bool same = (fallback.isArray() && data.isArray()) || (fallback.isObject() && data.isObject()); + return same ? data : fallback; +} + +Json McpSqliteStore::row(const Row& found) { + auto cell = [&](size_t i) -> Cell { return found[i]; }; + auto text = [&](size_t i) { return found[i] ? *found[i] : std::string(); }; + Cell headers = cell(6); + if (headers && !headers->empty() && WireText::startsWith(*headers, kSealPrefix)) headers = seal_->safeUnlock(*headers); + return Json::object({{"name", Json(text(0))}, + {"transport", found[1] ? Json(*found[1]) : Json()}, + {"command", Json(text(2))}, + {"args", parseJson(cell(3), Json::array())}, + {"env", parseJson(cell(4), Json::object())}, + {"url", Json(text(5))}, + {"headers", parseJson(headers, Json::object())}, + {"cwd", Json(text(7))}, + {"allow", parseJson(cell(8), Json::array())}, + {"deny", parseJson(cell(9), Json::array())}, + {"source", Json(text(10))}, + {"created", found[11] ? Json(*found[11]) : Json()}, + {"updated", found[12] ? Json(*found[12]) : Json()}}); +} + +static const char* kServerColumns = + "name, transport, command, args_json, env_json, url, headers_json, cwd, allow_json, deny_json, source, created, updated"; + +void McpSqliteStore::mcpSaveServer(const Json& entry) { + const Json nameJson = entry.get("name", Json("")); + const std::string name = str(nameJson); + if (!McpNormalizer::serverNameOk(name)) throw ValueError("bad MCP server name '" + name + "'"); + auto known = rows("SELECT created FROM mcp_servers WHERE name = ?", {name}, 1); + if (known.empty()) { + auto count = rows("SELECT COUNT(*) FROM mcp_servers", {}, 1); + if (std::stoll(*count[0][0]) >= MCP_MAX_SERVERS) + throw ValueError("too many MCP servers (cap " + std::to_string(MCP_MAX_SERVERS) + ")"); + } + Json headers = entry.get("headers"); + const std::string sealed = headers.truthy() ? seal_->lock(headers.dumps(true)) : std::string(); + const std::string now = nowIso(); + const std::string created = !known.empty() && known[0][0] ? *known[0][0] : now; + auto orEmpty = [&](const char* key) { Json v = entry.get(key); return v.truthy() ? str(v) : std::string(); }; + auto orList = [&](const char* key, const Json& fallback) { Json v = entry.get(key); return (v.truthy() ? v : fallback).dumps(); }; + Json transport = entry.get("transport"); + exec(std::string("INSERT OR REPLACE INTO mcp_servers (") + kServerColumns + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + {name, transport.isNull() ? Cell() : Cell(str(transport)), orEmpty("command"), orList("args", Json::array()), + orList("env", Json::object()), orEmpty("url"), sealed, orEmpty("cwd"), orList("allow", Json::array()), + orList("deny", Json::array()), orEmpty("source"), created, now}); + commit(); +} + +Json McpSqliteStore::mcpGetServer(const std::string& name) { + auto found = rows(std::string("SELECT ") + kServerColumns + " FROM mcp_servers WHERE name = ?", {name}, 13); + return found.empty() ? Json() : row(found[0]); +} + +std::vector McpSqliteStore::mcpListServers() { + std::vector out; + for (const Row& r : rows(std::string("SELECT ") + kServerColumns + " FROM mcp_servers ORDER BY name", {}, 13)) + out.push_back(row(r)); + return out; +} + +void McpSqliteStore::mcpRemoveServer(const std::string& name) { + exec("DELETE FROM mcp_server_tools WHERE server = ?", {name}); + exec("DELETE FROM mcp_tool_pins WHERE server = ?", {name}); + exec("DELETE FROM mcp_server_profiles WHERE server = ?", {name}); + exec("DELETE FROM mcp_servers WHERE name = ?", {name}); + commit(); +} + +void McpSqliteStore::mcpSetEnabled(const std::string& server, const std::string& profile, bool enabled) { + exec("INSERT OR REPLACE INTO mcp_server_profiles (server, profile, enabled) VALUES (?, ?, ?)", + {server, profile, std::string(enabled ? "1" : "0")}); + commit(); +} + +bool McpSqliteStore::mcpServerEnabled(const std::string& server, const std::string& profile) { + auto found = rows("SELECT enabled FROM mcp_server_profiles WHERE server = ? AND profile = ?", {server, profile}, 1); + return !found.empty() && found[0][0] && *found[0][0] != "0"; +} + +std::vector McpSqliteStore::mcpEnabledServers(const std::string& profile) { + std::vector out; + for (const Json& row : mcpListServers()) + if (mcpServerEnabled(row.get("name").str(), profile)) out.push_back(row); + return out; +} + +void McpSqliteStore::mcpStoreTools(const std::string& server, const std::vector& tools) { + const std::string now = nowIso(); + // One transaction: a duplicate tool name must not leave the cache half + // rewritten (tai.py leaves the half-written state pending; this rolls back). + const bool ownTransaction = sqlite3_get_autocommit(db_) != 0; + if (ownTransaction) exec("BEGIN", {}); + try { + storeToolsInner(server, tools, now); + } catch (...) { + if (ownTransaction) exec("ROLLBACK", {}); + throw; + } + commit(); +} + +void McpSqliteStore::storeToolsInner(const std::string& server, const std::vector& tools, const std::string& now) { + exec("DELETE FROM mcp_server_tools WHERE server = ?", {server}); + exec("DELETE FROM mcp_tool_pins WHERE server = ?", {server}); + for (const Json& item : tools) { + if (!item.isObject() || !item.get("name").truthy()) continue; + const std::string tool = str(item.get("name")); + Json descJson = item.get("description", Json("")); + const std::string desc = descJson.truthy() ? str(descJson) : ""; + Json schema = item.get("inputSchema"); + if (!schema.truthy()) schema = Json::object(); + exec("INSERT INTO mcp_server_tools (server, tool, description, schema_json, updated) VALUES (?, ?, ?, ?, ?)", + {server, tool, desc, schema.dumps(), now}); + exec("INSERT INTO mcp_tool_pins (server, tool, hash, updated) VALUES (?, ?, ?, ?)", + {server, tool, McpNormalizer::mcpToolHash(tool, Json(desc), schema), now}); + } +} + +std::vector McpSqliteStore::mcpCachedTools(const std::string& server) { + std::vector out; + for (const Row& r : rows("SELECT tool, description, schema_json FROM mcp_server_tools WHERE server = ? ORDER BY tool", {server}, 3)) + out.push_back(Json::object({{"tool", r[0] ? Json(*r[0]) : Json()}, + {"description", Json(r[1] ? *r[1] : std::string())}, + {"schema", parseJson(r[2], Json::object())}})); + return out; +} + +std::map McpSqliteStore::mcpToolPins(const std::string& server) { + std::map out; + for (const Row& r : rows("SELECT tool, hash FROM mcp_tool_pins WHERE server = ?", {server}, 2)) + if (r[0]) out[*r[0]] = r[1] ? *r[1] : std::string(); + return out; +} + +std::vector McpSqliteStore::mcpEnabledProfiles(const std::string& server) { + std::vector out; + for (const Row& r : rows("SELECT profile FROM mcp_server_profiles WHERE server = ? AND enabled = 1", {server}, 1)) + if (r[0]) out.push_back(*r[0]); + return out; +} + +std::vector McpSqliteStore::mcpKnownProfiles(const std::string& server) { + std::vector out; + for (const Row& r : rows("SELECT DISTINCT profile FROM mcp_server_profiles WHERE server = ?", {server}, 1)) + if (r[0]) out.push_back(*r[0]); + return out; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_sqlite_store.h b/taicpp/src/mcp/mcp_sqlite_store.h new file mode 100644 index 0000000..250d1c1 --- /dev/null +++ b/taicpp/src/mcp/mcp_sqlite_store.h @@ -0,0 +1,53 @@ +#pragma once +// McpSqliteStore: the four MCP vault tables over an open sqlite3 handle, +// method for method what tai.py's Store does for mcp_servers, +// mcp_server_profiles, mcp_tool_pins and mcp_server_tools. Secrets, records +// and audit stay pure virtual: they belong to the vault module. +#include + +#include +#include +#include + +#include "mcp_store.h" + +namespace detz::mcp { + +class McpSqliteStore : public McpStore { + public: + McpSqliteStore(sqlite3* db, McpSealPort* seal) : db_(db), seal_(seal) {} + + // The SCHEMA entries of the four tables ("CREATE TABLE ..." when missing). + void ensureSchema(); + + Json mcpGetServer(const std::string& name) override; + std::vector mcpListServers() override; + void mcpSaveServer(const Json& entry) override; + void mcpRemoveServer(const std::string& name) override; + void mcpSetEnabled(const std::string& server, const std::string& profile, bool enabled) override; + bool mcpServerEnabled(const std::string& server, const std::string& profile) override; + std::vector mcpEnabledServers(const std::string& profile) override; + void mcpStoreTools(const std::string& server, const std::vector& tools) override; + std::vector mcpCachedTools(const std::string& server) override; + std::map mcpToolPins(const std::string& server) override; + std::vector mcpEnabledProfiles(const std::string& server) override; + std::vector mcpKnownProfiles(const std::string& server) override; + + // now_iso(): UTC, seconds, "+00:00" suffix. + static std::string nowIso(); + + private: + using Cell = std::optional; // nullopt is SQL NULL + using Row = std::vector; + std::vector rows(const std::string& sql, const std::vector& args, int columns); + void exec(const std::string& sql, const std::vector& args); + void commit(); + void storeToolsInner(const std::string& server, const std::vector& tools, const std::string& now); + Json row(const Row& found); + static Json parseJson(const Cell& raw, const Json& fallback); + + sqlite3* db_; + McpSealPort* seal_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_stdio_transport.cpp b/taicpp/src/mcp/mcp_stdio_transport.cpp new file mode 100644 index 0000000..62cd3e9 --- /dev/null +++ b/taicpp/src/mcp/mcp_stdio_transport.cpp @@ -0,0 +1,106 @@ +#include "mcp_stdio_transport.h" + +#include "cap_proc_util.h" +#include "mcp_error.h" +#include "wire_os.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::OsError; +using detz::wire::WireOs; +using detz::wire::WireProc; +using detz::wire::WireText; + +namespace { +std::string shortError(const std::string& text) { + const std::string head = WireText::head(text, 300); + return head.empty() ? "Exception" : head; +} +} // namespace + +McpStdioTransport::~McpStdioTransport() { + try { + close(); + } catch (...) { + } +} + +McpStdioTransport& McpStdioTransport::spawn(const std::vector& argv, + const std::map& env, const std::string& cwd, + double) { + WireProc::Options options; + options.argv = argv; + options.replaceEnv = true; + options.env = WireOs::environ(); + for (const auto& kv : env) options.env[kv.first] = kv.second; + options.cwd = cwd; + options.in = options.out = options.err = WireProc::Stream::Pipe; + try { + proc_ = WireProc::spawn(options); + } catch (const OsError& exc) { + throw McpError("spawn failed: " + shortError(exc.what())); + } + WireProc::setNonBlocking(proc_->stdoutFd()); + return *this; +} + +std::string McpStdioTransport::readLine(double deadline) { + while (buf_.find('\n') == std::string::npos) { + if (auto code = proc_->poll()) throw McpError("server exited during read (code " + std::to_string(*code) + ")"); + const double remaining = deadline - WireOs::now(); + if (remaining <= 0) throw McpError("read timed out"); + if (!WireProc::waitReadable(proc_->stdoutFd(), std::min(remaining, 1.0))) continue; + std::optional chunk; + try { + chunk = WireProc::readFd(proc_->stdoutFd(), 65536); + } catch (const OsError& exc) { + throw McpError("read failed: " + shortError(exc.what())); + } + if (!chunk) continue; + if (chunk->empty()) throw McpError("server closed stdout"); + buf_ += *chunk; + if (buf_.size() > static_cast(MCP_HTTP_MAX_BODY)) + throw McpError("server line exceeds " + std::to_string(MCP_HTTP_MAX_BODY) + " bytes"); + } + const size_t nl = buf_.find('\n'); + std::string line = buf_.substr(0, nl); + buf_.erase(0, nl + 1); + return WireText::decodeReplace(line); +} + +Json McpStdioTransport::send(const Json& message, double timeout) { + if (!proc_) throw McpError("write failed: server not running"); + try { + proc_->writeStdin(message.dumps() + "\n"); + } catch (const OsError& exc) { + throw McpError("write failed: " + shortError(exc.what())); + } + if (!message.has("id")) return Json(); + const double deadline = WireOs::now() + timeout; + while (true) { + Json data; + try { + data = Json::parse(readLine(deadline)); + } catch (const detz::wire::JsonError&) { + continue; + } + if (!data.isObject() || data.get("id") != message.get("id")) continue; + return data; + } +} + +void McpStdioTransport::close() { + std::unique_ptr proc = std::move(proc_); + buf_.clear(); + if (!proc) return; + try { + proc->closeStdin(); + } catch (...) { + } + proc->wait(2); + if (!proc->poll()) detz::cap::CapProcUtil::capProcKill(*proc); + detz::cap::CapProcUtil::capProcReap(*proc); +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_stdio_transport.h b/taicpp/src/mcp/mcp_stdio_transport.h new file mode 100644 index 0000000..00d4730 --- /dev/null +++ b/taicpp/src/mcp/mcp_stdio_transport.h @@ -0,0 +1,32 @@ +#pragma once +// McpStdioTransport: one MCP server child over newline-delimited JSON-RPC on +// pipes. +#include +#include +#include +#include + +#include "mcp_transport.h" +#include "wire_proc.h" + +namespace detz::mcp { + +class McpStdioTransport : public McpTransport { + public: + McpStdioTransport() = default; + ~McpStdioTransport() override; + + // spawn(argv, env, cwd, timeout=MCP_CONNECT_TIMEOUT): returns this. + McpStdioTransport& spawn(const std::vector& argv, const std::map& env, + const std::string& cwd, double timeout = MCP_CONNECT_TIMEOUT); + Json send(const Json& message, double timeout = MCP_CALL_TIMEOUT) override; + void close() override; + + private: + std::string readLine(double deadline); + + std::unique_ptr proc_; + std::string buf_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_store.h b/taicpp/src/mcp/mcp_store.h new file mode 100644 index 0000000..b9dc524 --- /dev/null +++ b/taicpp/src/mcp/mcp_store.h @@ -0,0 +1,77 @@ +#pragma once +// McpStore: the vault operations MCP needs. The mcp_* table methods are +// implemented by McpSqliteStore; secrets, records and the audit trail belong +// to the vault module, so the host (integrator) supplies them by deriving +// from McpSqliteStore, or implements the whole interface over its own Store. +#include +#include +#include +#include +#include + +#include "wire_json.h" + +namespace detz::mcp { + +using detz::wire::Json; + +// sqlite3.Error / OSError raised by the vault. +class McpStoreError : public std::runtime_error { + public: + explicit McpStoreError(const std::string& message) : std::runtime_error(message) {} +}; + +class McpStore { + public: + virtual ~McpStore() = default; + + // mcp_servers rows as dicts: name, transport, command, args, env, url, + // headers, cwd, allow, deny, source, created, updated. + virtual Json mcpGetServer(const std::string& name) = 0; // Null when absent + virtual std::vector mcpListServers() = 0; + virtual void mcpSaveServer(const Json& entry) = 0; // throws wire::ValueError + virtual void mcpRemoveServer(const std::string& name) = 0; + virtual void mcpSetEnabled(const std::string& server, const std::string& profile, bool enabled) = 0; + virtual bool mcpServerEnabled(const std::string& server, const std::string& profile) = 0; + virtual std::vector mcpEnabledServers(const std::string& profile) = 0; + virtual void mcpStoreTools(const std::string& server, const std::vector& tools) = 0; + // [{tool, description, schema}] ordered by tool. + virtual std::vector mcpCachedTools(const std::string& server) = 0; + virtual std::map mcpToolPins(const std::string& server) = 0; + // SELECT profile FROM mcp_server_profiles WHERE server = ? AND enabled = 1 + virtual std::vector mcpEnabledProfiles(const std::string& server) = 0; + // SELECT DISTINCT profile FROM mcp_server_profiles WHERE server = ? + virtual std::vector mcpKnownProfiles(const std::string& server) = 0; + + // Vault secrets (OAuth tokens and ${VAR} values). loadSecret is nullopt when + // the secret does not exist. + virtual std::optional loadSecret(const std::string& name, const std::string& profile) = 0; + virtual void saveSecret(const std::string& name, const std::string& value, const Json& meta, + const std::string& profile) = 0; + virtual void deleteSecret(const std::string& name, const std::string& profile) = 0; + virtual Json secretMeta(const std::string& name, const std::string& profile) = 0; + + // add_record("output", title, text, ["mcp"], profile) plus add_chunks(...): + // returns the record id. Throws McpStoreError. + virtual std::string spillRecord(const std::string& title, const std::string& text, const std::string& profile) = 0; + // audit_event(actor, "mcp-call", path, message, tags=["mcp"], profile=profile) + virtual void auditEvent(const std::string& actor, const std::string& action, const std::string& path, + const std::string& message, const std::string& profile) = 0; +}; + +// The seal around mcp_servers.headers_json (Seal.lock / Seal.safe_unlock). +class McpSealPort { + public: + virtual ~McpSealPort() = default; + virtual std::string lock(const std::string& text) = 0; + virtual std::string safeUnlock(const std::string& text) = 0; +}; + +// A seal that stores text as is (the vault runs unsealed). +class McpPlainSeal : public McpSealPort { + public: + std::string lock(const std::string& text) override { return text; } + std::string safeUnlock(const std::string& text) override { return text; } +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_tools.cpp b/taicpp/src/mcp/mcp_tools.cpp new file mode 100644 index 0000000..a0a7309 --- /dev/null +++ b/taicpp/src/mcp/mcp_tools.cpp @@ -0,0 +1,438 @@ +#include "mcp_tools.h" + +#include +#include + +#include "mcp_constants.h" +#include "mcp_error.h" +#include "wire_hash.h" +#include "wire_os.h" +#include "wire_regex.h" +#include "wire_text.h" + +namespace detz::mcp { + +using detz::wire::WireHash; +using detz::wire::WireRegex; +using detz::wire::WireText; + +namespace { + +std::string str(const Json& v) { return v.isString() ? v.str() : v.pyStr(); } +std::string orStr(const Json& v, const std::string& fallback) { return v.truthy() ? str(v) : fallback; } + +std::string shortError(const std::string& text) { + const std::string head = WireText::head(text, 300); + return head.empty() ? "Exception" : head; +} + +// "%s" % (a, b) * 7 expansion of MCP_RESOLVER_BRIEF. +std::string resolverBrief(const std::string& owner, const std::string& repo) { + std::string out; + const std::string brief(MCP_RESOLVER_BRIEF); + bool owners = true; + for (size_t i = 0; i < brief.size(); ++i) { + if (brief[i] == '%' && i + 1 < brief.size() && brief[i + 1] == 's') { + out += owners ? owner : repo; + owners = !owners; + ++i; + } else { + out += brief[i]; + } + } + return out; +} + +Json entryBase(const std::string& repo, const std::string& command, const std::vector& args, + const std::string& owner) { + return Json::object({{"name", Json(McpNormalizer::mcpSanitizeName(repo))}, + {"transport", Json("stdio")}, + {"command", Json(command)}, + {"args", Json::fromStrings(args)}, + {"env", Json::object()}, + {"url", Json("")}, + {"headers", Json::object()}, + {"cwd", Json("")}, + {"allow", Json::array()}, + {"deny", Json::array()}, + {"enabled", Json()}, + {"source", Json("github:" + owner + "/" + repo)}}); +} + +} // namespace + +const std::vector& McpTools::toolNames() { + static const std::vector names = {"mcp_add", "mcp_list", "mcp_enable", "mcp_disable", + "mcp_remove", "mcp_tools", "mcp_call", "mcp_login"}; + return names; +} + +bool McpTools::dispatch(const std::string& name, const Json& args, std::string& out) { + if (name == "mcp_add") out = runMcpAdd(args); + else if (name == "mcp_list") out = runMcpList(args); + else if (name == "mcp_enable") out = runMcpEnable(args); + else if (name == "mcp_disable") out = runMcpDisable(args); + else if (name == "mcp_remove") out = runMcpRemove(args); + else if (name == "mcp_tools") out = runMcpTools(args); + else if (name == "mcp_call") out = runMcpCall(args); + else if (name == "mcp_login") out = runMcpLogin(args); + else if (WireText::startsWith(name, std::string(MCP_TOOL_PREFIX))) { + McpBridge bridge(host_); + auto resolved = bridge.resolve(name); + if (!resolved) return false; + out = bridge.call(resolved->first, resolved->second, args, host_.activeProfile()); + } else { + return false; + } + return true; +} + +std::vector McpTools::mcpSchemas() { + try { + return McpBridge(host_).schemasFor(host_.activeProfile()); + } catch (const McpStoreError&) { + return {}; + } catch (const detz::wire::ValueError&) { + return {}; + } catch (const detz::wire::OsError&) { + return {}; + } +} + +McpNormalized McpTools::mcpResolveGithub(const std::string& owner, const std::string& repo) { + std::vector notes; + const std::string base = "https://api.github.com/repos/" + owner + "/" + repo + "/contents/"; + auto fetch = [&](const std::string& path) -> std::optional { + const std::string out = host_.runWebFetch(base + path); + if (WireText::startsWith(out, "error:") || WireText::length(out) > 200000) return std::nullopt; + Json data; + try { + data = Json::parse(out); + } catch (const detz::wire::JsonError&) { + return std::nullopt; + } + if (!data.isObject() || data.get("type") != Json("file") || !data.get("content").truthy()) return std::nullopt; + std::string decoded; + if (!data.get("content").isString() || !WireHash::b64decode(data.get("content").str(), decoded)) return std::nullopt; + return WireText::decodeReplace(decoded); + }; + for (const char* candidate : {"mcp.json", ".mcp.json", "server.json"}) { + auto text = fetch(candidate); + if (text && !text->empty()) { + notes.push_back(std::string("found ") + candidate + ", normalizing it"); + McpNormalized normalized = McpNormalizer::mcpNormalize(*text); + notes.insert(notes.end(), normalized.notes.begin(), normalized.notes.end()); + return {normalized.entry, notes}; + } + } + auto package = fetch("package.json"); + if (package && !package->empty()) { + Json meta; + try { + meta = Json::parse(*package); + } catch (const detz::wire::JsonError&) { + meta = Json::object(); + } + if (meta.isObject()) { + const Json name = meta.get("name", Json("")); + if (name.truthy()) { + notes.push_back("package " + str(name) + " has no run contract, trying npx"); + return {entryBase(repo, "npx", {"-y", str(name)}, owner), notes}; + } + } + } + for (const char* candidate : {"README.md", "readme.md"}) { + auto text = fetch(candidate); + if (text && !text->empty()) { + static const WireRegex run{"(npx|uvx)\\s+([^\\s\"'`]+)"}; + auto found = run.search(*text); + if (found) { + notes.push_back(std::string("run block found in ") + candidate); + const std::string command = found.group(1); + return {entryBase(repo, command, command == "npx" ? std::vector{"-y", found.group(2)} + : std::vector{found.group(2)}, + owner), + notes}; + } + } + } + throw McpError("no run contract found (checked mcp.json, server.json, package.json, README)"); +} + +McpNormalized McpTools::mcpResolveAgent(const std::string& owner, const std::string& repo, const std::string& profile) { + const int depth = host_.depth(); + if (depth >= host_.forkMaxDepth()) throw McpError("at fork depth limit, cannot spawn resolver"); + const std::string brief = resolverBrief(owner, repo); + std::string out; + try { + out = host_.runResolverAgent(profile, brief, MCP_RESOLVER_TIMEOUT, MCP_RESOLVER_STEPS); + } catch (const std::exception& exc) { + throw McpError("resolver subagent failed: " + shortError(exc.what())); + } + static const WireRegex fenced{"```json\\s*(\\{.*?\\})\\s*```", WireRegex::kDotAll}; + static const WireRegex bare{"(\\{.*\\})", WireRegex::kDotAll}; + auto match = fenced.search(out); + if (!match) match = bare.search(out); + if (!match) throw McpError("resolver returned no JSON (first 200 chars: " + detz::wire::WireText::repr(WireText::head(out, 200)) + ")"); + Json data; + try { + data = Json::parse(match.group(1)); + } catch (const detz::wire::JsonError&) { + throw McpError("resolver returned bad JSON: " + WireText::head(match.group(1), 200)); + } + if (!data.isObject()) throw McpError("resolver returned non-object JSON"); + if (data.get("error").truthy()) + throw McpError("resolver: " + str(data.get("error")) + " (checked: " + str(data.get("evidence", Json("?"))) + ")"); + const std::string sanitized = McpNormalizer::mcpSanitizeName(repo); + McpNormalized normalized = McpNormalizer::mcpNormalize(data.dumps(), &sanitized); + std::vector notes = {"resolver subagent settled it"}; + notes.insert(notes.end(), normalized.notes.begin(), normalized.notes.end()); + return {normalized.entry, notes}; +} + +std::string McpTools::runMcpAdd(const Json& args) { + const std::string source = WireText::strip(orStr(args.get("source"), "")); + const std::string profile = orStr(args.get("profile"), host_.activeProfile()); + if (source.empty()) return "error: source must be pasted JSON/TOML config or a github URL"; + std::optional> target; + if (source.find('\n') == std::string::npos && source.find('{') == std::string::npos && source.find('[') == std::string::npos) + target = McpNormalizer::mcpGithubTarget(source); + std::vector notes; + Json entry; + try { + if (target) { + try { + McpNormalized found = mcpResolveGithub(target->first, target->second); + entry = found.entry; + notes = found.notes; + } catch (const McpError& first) { + try { + McpNormalized found = mcpResolveAgent(target->first, target->second, profile); + entry = found.entry; + notes = {std::string("deterministic pass failed (") + first.what() + "), resolver subagent took over"}; + notes.insert(notes.end(), found.notes.begin(), found.notes.end()); + } catch (const McpError& second) { + throw McpError(std::string(first.what()) + "; resolver subagent also failed: " + second.what()); + } + } + entry.set("source", Json("github:" + target->first + "/" + target->second)); + } else { + auto rewritten = McpNormalizer::mcpRewritePlaceholders(source); + notes.insert(notes.end(), rewritten.second.begin(), rewritten.second.end()); + const std::string pick = orStr(args.get("name"), ""); + McpNormalized normalized = McpNormalizer::mcpNormalize(rewritten.first, pick.empty() ? nullptr : &pick); + entry = normalized.entry; + notes.insert(notes.end(), normalized.notes.begin(), normalized.notes.end()); + } + } catch (const McpError& exc) { + if (target) + return "error: could not resolve github " + target->first + "/" + target->second + ": " + exc.what() + + "\nfinish it by hand: read the repo README for the run command, then call mcp_add with explicit JSON like " + "{\"mcpServers\": {\"name\": {\"command\": \"npx\", \"args\": [...]}}}"; + return std::string("error: ") + exc.what(); + } + const std::string name = orStr(args.get("name"), orStr(entry.get("name"), "")); + if (!McpNormalizer::serverNameOk(name)) return "error: bad server name '" + name + "'"; + entry.set("name", Json(name)); + std::string prompt; + if (entry.get("transport") == Json("stdio")) { + std::vector pieces = {str(entry.get("command"))}; + for (const Json& a : entry.get("args").items()) pieces.push_back(str(a)); + const std::string shown = WireText::join(pieces, " "); + std::vector flags; + for (const char* word : {"sudo", "rm -rf", "curl", "wget", "ssh", "nc "}) + if (shown.find(word) != std::string::npos) flags.push_back(word); + prompt = "mcp add stdio '" + WireText::head(shown, 300) + "'" + + (flags.empty() ? std::string() : " FLAGS: " + WireText::join(flags, ",")) + " (runs with your privileges)"; + } else { + prompt = "mcp add http " + WireText::head(str(entry.get("url")), 300) + " (headers sealed, secrets resolve at call time)"; + } + if (!notes.empty()) prompt += " [" + WireText::head(WireText::join(notes, "; "), 300) + "]"; + if (!host_.askApproval(prompt)) return "denied by user"; + McpBridge bridge(host_); + std::unique_ptr client; + try { + client = bridge.connect(entry, profile); + } catch (const McpHttpError& exc) { + if (exc.code == 401) return mcpAdd401(entry, profile, notes); + return std::string("error: verify failed, nothing stored: ") + exc.what(); + } catch (const McpError& exc) { + return std::string("error: verify failed, nothing stored: ") + exc.what(); + } + std::vector tools; + try { + try { + tools = client->listTools(); + } catch (const McpHttpError& exc) { + client->close(); + if (exc.code == 401) return mcpAdd401(entry, profile, notes); + return std::string("error: verify failed, nothing stored: ") + exc.what(); + } catch (const McpError& exc) { + client->close(); + return std::string("error: verify failed, nothing stored: ") + exc.what(); + } + } catch (...) { + client->close(); + throw; + } + client->close(); + try { + host_.store().mcpSaveServer(entry); + } catch (const detz::wire::ValueError& exc) { + return std::string("error: ") + exc.what(); + } + const Json enabled = entry.get("enabled"); + host_.store().mcpSetEnabled(name, profile, enabled.isNull() ? true : enabled.truthy()); + host_.store().mcpStoreTools(name, tools); + std::vector lines = {"added MCP server '" + name + "' (" + str(entry.get("transport")) + ", " + + std::to_string(tools.size()) + " tools, enabled for " + profile + ")"}; + for (size_t i = 0; i < tools.size() && i < static_cast(MCP_MAX_TOOLS); ++i) + lines.push_back("- " + str(tools[i].get("name", Json(""))) + ": " + + WireText::head(orStr(tools[i].get("description", Json("")), ""), 120)); + if (tools.size() > static_cast(MCP_MAX_TOOLS)) + lines.push_back("- ...and " + std::to_string(tools.size() - MCP_MAX_TOOLS) + " more (cap " + std::to_string(MCP_MAX_TOOLS) + + " offered)"); + return WireText::join(lines, "\n"); +} + +std::string McpTools::mcpAdd401(Json entry, const std::string& profile, const std::vector& notes) { + if (entry.get("transport") != Json("http")) return "error: verify failed, nothing stored: http 401"; + McpOAuth oauth(host_.store()); + const std::string name = str(entry.get("name")); + auto tokens = oauth.loadTokens(name, profile); + if (tokens.first && !tokens.first->empty()) + return "error: verify failed: stored OAuth tokens refused, run mcp_login '" + name + "'"; + for (const auto& kv : entry.get("headers").members()) + if (WireText::lower(kv.first) == "authorization") return "error: verify failed: http 401, check the configured credentials"; + entry.set("enabled", Json(false)); + try { + host_.store().mcpSaveServer(entry); + } catch (const detz::wire::ValueError& exc) { + return std::string("error: ") + exc.what(); + } + host_.store().mcpSetEnabled(name, profile, false); + std::vector lines = {"stored MCP server '" + name + "' disabled: it answered 401, so it needs OAuth login", + "run mcp_login '" + name + "' to approve in your browser, then it verifies and enables"}; + for (const std::string& note : notes) lines.push_back("(note: " + note + ")"); + return WireText::join(lines, "\n"); +} + +std::string McpTools::runMcpList(const Json&) { + const std::vector rows = host_.store().mcpListServers(); + if (rows.empty()) return "no MCP servers registered"; + std::vector lines; + for (const Json& row : rows) { + std::string detail; + if (row.get("transport") == Json("stdio")) { + std::vector pieces = {str(row.get("command"))}; + for (const Json& a : row.get("args").items()) pieces.push_back(str(a)); + detail = WireText::head(WireText::join(pieces, " "), 120); + } else { + detail = WireText::head(str(row.get("url")), 120); + } + std::vector enabled = host_.store().mcpEnabledProfiles(str(row.get("name"))); + std::sort(enabled.begin(), enabled.end()); + const size_t cached = host_.store().mcpCachedTools(str(row.get("name"))).size(); + lines.push_back(str(row.get("name")) + " (" + str(row.get("transport")) + ") " + detail + + " | profiles: " + (enabled.empty() ? std::string("none") : WireText::join(enabled, ", ")) + + " | tools: " + std::to_string(cached) + " | from " + orStr(row.get("source"), "?")); + } + return WireText::join(lines, "\n"); +} + +std::string McpTools::runMcpEnable(const Json& args) { + const std::string server = orStr(args.get("server"), ""); + const std::string profile = orStr(args.get("profile"), host_.activeProfile()); + if (host_.store().mcpGetServer(server).isNull()) return "error: unknown MCP server '" + server + "'"; + host_.store().mcpSetEnabled(server, profile, true); + return "MCP server '" + server + "' enabled for " + profile; +} + +std::string McpTools::runMcpDisable(const Json& args) { + const std::string server = orStr(args.get("server"), ""); + const std::string profile = orStr(args.get("profile"), host_.activeProfile()); + if (host_.store().mcpGetServer(server).isNull()) return "error: unknown MCP server '" + server + "'"; + host_.store().mcpSetEnabled(server, profile, false); + return "MCP server '" + server + "' disabled for " + profile; +} + +std::string McpTools::runMcpRemove(const Json& args) { + const std::string server = orStr(args.get("server"), ""); + if (host_.store().mcpGetServer(server).isNull()) return "error: unknown MCP server '" + server + "'"; + if (!host_.askApproval("mcp remove '" + server + "' (server, profile rows, pins, cached tools)")) return "denied by user"; + std::vector profiles = host_.store().mcpKnownProfiles(server); + host_.store().mcpRemoveServer(server); + McpOAuth oauth(host_.store()); + std::set all(profiles.begin(), profiles.end()); + all.insert(host_.activeProfile()); + for (const std::string& profile : all) oauth.dropTokens(server, profile); + return "removed MCP server '" + server + "'"; +} + +std::string McpTools::runMcpTools(const Json& args) { + const std::string server = orStr(args.get("server"), ""); + std::vector rows; + if (!server.empty()) rows.push_back(host_.store().mcpGetServer(server)); + else rows = host_.store().mcpListServers(); + std::vector present; + for (const Json& row : rows) + if (!row.isNull()) present.push_back(row); + if (!server.empty() && present.empty()) return "error: unknown MCP server '" + server + "'"; + if (present.empty()) return "no MCP servers registered"; + std::vector lines; + for (const Json& row : present) { + auto pins = host_.store().mcpToolPins(str(row.get("name"))); + for (const Json& item : host_.store().mcpCachedTools(str(row.get("name")))) { + const std::string tool = str(item.get("tool")); + lines.push_back(str(row.get("name")) + "/" + tool + (pins.count(tool) ? "" : " (unpinned)") + ": " + + WireText::head(orStr(item.get("description"), ""), 150)); + } + } + const std::string joined = WireText::join(lines, "\n"); + return joined.empty() ? "no cached tools (call mcp_add to verify)" : joined; +} + +std::string McpTools::runMcpCall(const Json& args) { + const std::string server = orStr(args.get("server"), ""); + const std::string tool = orStr(args.get("tool"), ""); + Json callArgs = args.get("args"); + if (!callArgs.truthy()) callArgs = Json::object(); + if (server.empty() || tool.empty()) return "error: mcp_call needs server and tool"; + return McpBridge(host_).call(server, tool, callArgs, host_.activeProfile()); +} + +std::string McpTools::runMcpLogin(const Json& args, const McpOAuth::Opener& opener) { + const std::string server = orStr(args.get("server"), ""); + const std::string profile = orStr(args.get("profile"), host_.activeProfile()); + const Json row = host_.store().mcpGetServer(server); + if (row.isNull()) return "error: unknown MCP server '" + server + "'"; + if (row.get("transport") != Json("http")) return "error: local stdio servers need no login"; + if (!host_.askApproval("mcp login '" + server + "' (opens a browser, waits up to " + std::to_string(MCP_OAUTH_TIMEOUT) + "s)")) + return "denied by user"; + std::string note; + try { + note = McpOAuth(host_.store()).login(row, profile, opener); + } catch (const McpError& exc) { + return std::string("error: ") + exc.what(); + } + McpBridge bridge(host_); + std::unique_ptr client; + try { + client = bridge.connect(row, profile); + } catch (const McpError& exc) { + return std::string("error: login ok but verify failed: ") + exc.what(); + } + std::vector tools; + try { + tools = client->listTools(); + } catch (const McpError& exc) { + client->close(); + return std::string("error: login ok but verify failed: ") + exc.what(); + } + client->close(); + host_.store().mcpSetEnabled(server, profile, true); + host_.store().mcpStoreTools(server, tools); + return note + "\nverified " + std::to_string(tools.size()) + " tools, enabled for " + profile; +} + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_tools.h b/taicpp/src/mcp/mcp_tools.h new file mode 100644 index 0000000..7c886b9 --- /dev/null +++ b/taicpp/src/mcp/mcp_tools.h @@ -0,0 +1,49 @@ +#pragma once +// McpTools: the backends of the eight mcp_* agent tools (run_mcp_add ... +// run_mcp_login in tai.py's Tools) plus the GitHub resolver passes and the +// bridged-name dispatch. The agent core registers the handlers listed in +// docs/wiring/capmcp.md and calls them with the decoded tool arguments. +#include +#include +#include + +#include "mcp_bridge.h" +#include "mcp_host.h" +#include "mcp_normalizer.h" +#include "mcp_oauth.h" + +namespace detz::mcp { + +class McpTools { + public: + explicit McpTools(McpHost& host) : host_(host) {} + + // The eight tool names, in registration order. + static const std::vector& toolNames(); + // Dispatches by tool name ("mcp_add" ...); empty optional when not an mcp_* tool. + // Falls back to the bridge for "mcp__" names. + bool dispatch(const std::string& name, const Json& args, std::string& out); + + // mcp_schemas(): bridged tool schemas for the active profile ([] on store errors). + std::vector mcpSchemas(); + // mcp_resolve_github(owner, repo): the deterministic pass; throws McpError. + McpNormalized mcpResolveGithub(const std::string& owner, const std::string& repo); + // mcp_resolve_agent(owner, repo, profile): the fork subagent pass; throws McpError. + McpNormalized mcpResolveAgent(const std::string& owner, const std::string& repo, const std::string& profile); + + std::string runMcpAdd(const Json& args); + std::string runMcpList(const Json& args); + std::string runMcpEnable(const Json& args); + std::string runMcpDisable(const Json& args); + std::string runMcpRemove(const Json& args); + std::string runMcpTools(const Json& args); + std::string runMcpCall(const Json& args); + std::string runMcpLogin(const Json& args, const McpOAuth::Opener& opener = nullptr); + + private: + std::string mcpAdd401(Json entry, const std::string& profile, const std::vector& notes); + + McpHost& host_; +}; + +} // namespace detz::mcp diff --git a/taicpp/src/mcp/mcp_transport.h b/taicpp/src/mcp/mcp_transport.h new file mode 100644 index 0000000..174cc11 --- /dev/null +++ b/taicpp/src/mcp/mcp_transport.h @@ -0,0 +1,19 @@ +#pragma once +// McpTransport: the leg behind McpClient (stdio or Streamable HTTP). +#include "mcp_constants.h" +#include "wire_json.h" + +namespace detz::mcp { + +using detz::wire::Json; + +class McpTransport { + public: + virtual ~McpTransport() = default; + // Sends one JSON-RPC message. A message with an "id" waits for its reply and + // returns it; a notification returns Null. Throws McpError / McpHttpError. + virtual Json send(const Json& message, double timeout = MCP_CALL_TIMEOUT) = 0; + virtual void close() = 0; +}; + +} // namespace detz::mcp diff --git a/taicpp/tests/capmcp_cli.cpp b/taicpp/tests/capmcp_cli.cpp new file mode 100644 index 0000000..1377816 --- /dev/null +++ b/taicpp/tests/capmcp_cli.cpp @@ -0,0 +1,30 @@ +// capmcp_cli: a thin test driver around the CAP and MCP classes. It stands in +// for main.cpp (which the integrator owns) so the same argv can be run through +// tai.py and through the C++ classes and the outputs diffed. +// +// capmcp_cli --cap-manifest +// capmcp_cli --cap COMMAND ... +#include +#include +#include + +#include "cap_agent_manifest.h" +#include "cap_cli.h" + +int main(int argc, char** argv) { + std::vector args(argv + 1, argv + argc); + if (args.empty()) { + std::fputs("usage: capmcp_cli --cap-manifest | --cap COMMAND ...\n", stderr); + return 2; + } + if (args[0] == "--cap-manifest") { + // print(TAI_CAP_MANIFEST) + std::fputs((detz::cap::CapAgentManifest::TAI_CAP_MANIFEST() + "\n").c_str(), stdout); + return 0; + } + if (args[0] == "--cap") { + return detz::cap::CapCli::capCliMain(std::vector(args.begin() + 1, args.end())); + } + std::fputs("unknown mode\n", stderr); + return 2; +} diff --git a/taicpp/tests/capmcp_cli_diff.py b/taicpp/tests/capmcp_cli_diff.py new file mode 100644 index 0000000..01c37d7 --- /dev/null +++ b/taicpp/tests/capmcp_cli_diff.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Runs the same `--cap-manifest` / `--cap ...` argv through tai.py and through +the C++ test driver (build/capmcp/capmcp_cli) and diffs stdout, stderr and the +exit code. Every case runs in a throwaway HOME / TAI_HOME / cwd. + + python3 -I tests/capmcp_cli_diff.py CPP_DRIVER [--tai PATH_TO_TAI_PY] [--filter TEXT] [--verbose] + +Exit 0 only when all cases are byte-identical.""" +import http.server +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import textwrap +import threading + +HERE = os.path.dirname(os.path.abspath(__file__)) +TAI = os.path.join(os.path.dirname(os.path.dirname(HERE)), "tai.py") + +FAKE_STREAM_AGENT = r''' +import json, sys +print(json.dumps({"type": "system", "subtype": "init", "session_id": "fake-1"}), flush=True) +n = 0 +for line in sys.stdin: + try: + frame = json.loads(line) + except ValueError: + continue + n += 1 + text = frame.get("message") + print(json.dumps({"type": "assistant", "message": {"id": "m%d" % n, "role": "assistant", "content": [ + {"type": "text", "text": "echo %d: %s" % (n, text)}, + {"type": "tool_use", "id": "t%d" % n, "name": "Read", "input": {"path": "x"}}]}}), flush=True) + print(json.dumps({"type": "user", "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t%d" % n, "content": "ok", "is_error": False}]}}), flush=True) + print(json.dumps({"type": "result", "subtype": "success", "usage": {"input_tokens": 3, "output_tokens": 5}, "duration_ms": 7, "total_cost_usd": 0.5}), flush=True) +''' + +FAKE_ACP_AGENT = r''' +import json, sys +def send(obj): + print(json.dumps(obj), flush=True) +for line in sys.stdin: + msg = json.loads(line) + if "method" in msg and "id" in msg and msg["method"] == "initialize": + send({"jsonrpc": "2.0", "id": msg["id"], "result": {"protocolVersion": 1, "agentCapabilities": {}}}) + elif msg.get("method") == "session/new": + send({"jsonrpc": "2.0", "id": msg["id"], "result": {"sessionId": "s1"}}) + elif msg.get("method") == "session/prompt": + text = msg["params"]["prompt"][0]["text"] + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "acp says " + text}}}}) + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Read file"}}}) + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed", "content": [{"type": "text", "text": "done"}]}}}) + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "usage_update", "usage": {"inputTokens": 10, "outputTokens": 4}}}}) + send({"jsonrpc": "2.0", "id": msg["id"], "result": {"stopReason": "end_turn"}}) +''' + +FAKE_PTY_AGENT = r''' +import sys +sys.stdout.write("fake agent ready\nfake> ") +sys.stdout.flush() +for line in sys.stdin: + text = line.strip() + sys.stdout.write("you said: %s\n" % text) + sys.stdout.write("fake> ") + sys.stdout.flush() +''' + +MANIFEST_STREAM = '''[agent] +name = "fake" +binary = "fake" +profiles = ["chat"] +[startup] +command = ["python3", "-c", "pass"] +ready_when = { pattern = "ready" } +[fast_path] +stream_json = ["python3", "{agent}"] +[pty] +cols = 80 +rows = 24 +[capabilities] +streaming_output = true +''' + +MANIFEST_ACP = '''[agent] +name = "acpfake" +binary = "acpfake" +profiles = ["chat"] +[startup] +command = ["python3", "-c", "pass"] +ready_when = { pattern = "ready" } +[fast_path] +acp_stdio = true +[pty] +cols = 80 +rows = 24 +[capabilities] +streaming_output = true +''' + +MANIFEST_PTY = '''[agent] +name = "ptyfake" +binary = "ptyfake" +profiles = ["chat"] +[startup] +command = ["python3", "{agent}"] +ready_when = { pattern = "fake agent ready" } +init_timeout_seconds = 20 +[pty] +cols = 100 +rows = 30 +[parse] +idle = ["^fake> $"] +[capabilities] +streaming_output = true +''' + +MANIFEST_A2A = '''[agent] +name = "a2afake" +binary = "a2afake" +profiles = ["chat"] +[startup] +command = ["python3", "-c", "pass"] +ready_when = { pattern = "ready" } +[fast_path] +a2a_serve_at = "http://127.0.0.1:{port}" +[pty] +cols = 80 +rows = 24 +[capabilities] +streaming_output = true +''' + +FLEET_TOML = '''[fleet] +start = "cap://a" +task = "begin" +budget_usd = 100 + +[[sessions]] +name = "a" +agent = "fake" + +[[sessions]] +name = "b" +agent = "fake" + +[[routes]] +when = "cap://a.done" +route_to = "cap://b" +''' + + +class A2aHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path == "/.well-known/agent-card.json": + body = json.dumps({"name": "a2a-card", "extensions": [{"uri": "cap-protocol/v1"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) + if self.path != "/message/send": + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + data = json.loads(raw) + text = data["message"]["parts"][0]["text"] + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + frames = [ + {"task": {"id": "t1", "contextId": "c1", "status": {"state": "working"}}}, + {"message": {"messageId": "mm1", "parts": [{"kind": "text", "text": "a2a heard " + text}]}}, + {"task": {"id": "t1", "contextId": "c1", "status": {"state": "completed"}}}, + ] + for frame in frames: + self.wfile.write(("data: " + json.dumps(frame) + "\n\n").encode()) + self.wfile.flush() + + +def normalize(text, root): + text = text.replace(root, "") + text = re.sub(r"cap_[0-9a-f]{12}", "cap_", text) + return text + + +def run(argv, env, cwd, timeout=60, stdin=None): + done = subprocess.run(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, + stdin=subprocess.DEVNULL if stdin is None else None, input=stdin) + return done.returncode, done.stdout.decode("utf-8", "replace"), done.stderr.decode("utf-8", "replace") + + +def main(): + args = sys.argv[1:] + if not args: + print(__doc__) + return 2 + cpp = os.path.abspath(args[0]) + tai = TAI + if "--tai" in args: + tai = args[args.index("--tai") + 1] + only = args[args.index("--filter") + 1] if "--filter" in args else None + root = tempfile.mkdtemp(prefix="capmcp-cli-") + server = http.server.HTTPServer(("127.0.0.1", 0), A2aHandler) + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + home = os.path.join(root, "home") + agents = os.path.join(home, ".config", "cap", "agents") + os.makedirs(agents) + work = os.path.join(root, "work") + os.makedirs(work) + files = { + "fake_stream.py": FAKE_STREAM_AGENT, "fake_acp.py": FAKE_ACP_AGENT, "fake_pty.py": FAKE_PTY_AGENT, + } + for name, text in files.items(): + with open(os.path.join(root, name), "w") as handle: + handle.write(text) + manifests = { + "fake.toml": MANIFEST_STREAM.replace("{agent}", os.path.join(root, "fake_stream.py")), + "acpfake.toml": MANIFEST_ACP, + "ptyfake.toml": MANIFEST_PTY.replace("{agent}", os.path.join(root, "fake_pty.py")), + "a2afake.toml": MANIFEST_A2A.replace("{port}", str(port)), + "broken.toml": "[agent]\nname = \"broken\"\n", + "wrongname.toml": MANIFEST_STREAM.replace('name = "fake"', 'name = "other"').replace("{agent}", "x"), + "badre.toml": MANIFEST_STREAM.replace('name = "fake"', 'name = "badre"').replace("{agent}", "x") + "\n[parse]\nidle = [\"(?<=a)b\"]\n", + "notoml.toml": "this is not toml = = =\n", + } + for name, text in manifests.items(): + with open(os.path.join(agents, name), "w") as handle: + handle.write(text) + fleet = os.path.join(root, "fleet.toml") + with open(fleet, "w") as handle: + handle.write(FLEET_TOML) + badfleet = os.path.join(root, "badfleet.toml") + with open(badfleet, "w") as handle: + handle.write("[fleet]\nstart = \"cap://zzz\"\n[[sessions]]\nname = \"a\"\nagent = \"fake\"\n") + nofleet = os.path.join(root, "nofleet.toml") + with open(nofleet, "w") as handle: + handle.write("[fleet]\n") + garbage = os.path.join(root, "garbage.toml") + with open(garbage, "w") as handle: + handle.write("a = = 1") + env = dict(os.environ, HOME=home, TAI_HOME=os.path.join(root, "taihome"), COLUMNS="80", PYTHONDONTWRITEBYTECODE="1") + env.pop("TAI_PASSPHRASE", None) + cases = [ + ["--cap"], ["--cap", "help"], ["--cap", "-h"], ["--cap", "--help"], ["--cap", "bogus"], ["--cap", "resolve"], + ["--cap", "resolve", "-h"], ["--cap", "resolve", "nosuchagent"], ["--cap", "resolve", "fake"], + ["--cap", "resolve", "acpfake"], ["--cap", "resolve", "ptyfake"], ["--cap", "resolve", "a2afake"], + ["--cap", "resolve", "broken"], ["--cap", "resolve", "wrongname"], ["--cap", "resolve", "badre"], + ["--cap", "resolve", "notoml"], ["--cap", "resolve", "x", "--binary"], ["--cap", "resolve", "x", "y"], + ["--cap", "chat"], ["--cap", "chat", "-h"], ["--cap", "chat", "--driver", "fake", "--max-risk", "bad"], + ["--cap", "chat", "--driver", "nosuch"], ["--cap", "chat", "--driver", "fake", "--a", "x"], + ["--cap", "run"], ["--cap", "run", "-h"], ["--cap", "run", os.path.join(root, "missing.toml")], + ["--cap", "run", garbage], ["--cap", "run", nofleet], ["--cap", "run", badfleet], + ["--cap", "run", fleet, "--task", " "], + ["--cap", "chat", "--driver", "fake", "--task", "hello world"], + ["--cap", "chat", "--driver", "ptyfake", "--task", "hello pty"], + ["--cap", "chat", "--driver", "acpfake", "--argv", "python3 %s" % os.path.join(root, "fake_acp.py"), "--task", "hi acp"], + ["--cap", "chat", "--driver", "acpfake", "--task", "no argv"], + ["--cap", "chat", "--driver", "acpfake", "--argv", "python3 'unterminated", "--task", "x"], + ["--cap", "chat", "--driver", "a2afake", "--task", "hi a2a"], + ["--cap", "run", fleet], + ["--cap", "run", fleet, "--task", "override task"], + ] + for columns in ("40", "60", "100", "132"): + for sub in ("resolve", "chat", "run"): + cases.append(("COLUMNS=" + columns, ["--cap", sub, "-h"])) + cases.append(("COLUMNS=" + columns, ["--cap", sub, "--bogus-flag"])) + failures = 0 + total = 0 + for case in cases: + extra = {} + if isinstance(case, tuple): + key, value = case[0].split("=", 1) + extra[key] = value + case = case[1] + if only and only not in " ".join(case): + continue + total += 1 + cenv = dict(env, **extra) + want = run([sys.executable, "-I", tai] + case, cenv, work) + got = run([cpp] + case, cenv, work) + want = (want[0], normalize(want[1], root), normalize(want[2], root)) + got = (got[0], normalize(got[1], root), normalize(got[2], root)) + if "--verbose" in args: + print("CASE %s -> rc %s stdout %r stderr %r" % (" ".join(case).replace(root, ""), want[0], want[1][:200], want[2][:160])) + if want != got: + failures += 1 + print("MISMATCH %s" % " ".join(case).replace(root, "")) + for label, a, b in (("rc", want[0], got[0]), ("stdout", want[1], got[1]), ("stderr", want[2], got[2])): + if a != b: + print(" %s py : %r" % (label, a if label == "rc" else a[:600])) + print(" %s cpp: %r" % (label, b if label == "rc" else b[:600])) + # worktree dirs created by coding profiles must not leak between cases + shutil.rmtree(os.path.join(work, ".cap-worktrees"), ignore_errors=True) + want = run([sys.executable, "-I", tai, "--cap-manifest"], env, work) + got = run([cpp, "--cap-manifest"], env, work) + total += 1 + if want != got: + failures += 1 + print("MISMATCH --cap-manifest") + print("%d cases, %d mismatches" % (total, failures)) + return 1 if failures else 0 + finally: + server.shutdown() + shutil.rmtree(root, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/taicpp/tests/capmcp_diff.py b/taicpp/tests/capmcp_diff.py new file mode 100644 index 0000000..dd0a5a6 --- /dev/null +++ b/taicpp/tests/capmcp_diff.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Differential test of the C++ wire layer (JSON, TOML, str(), float repr, +regex) against CPython. Builds nothing: pass the compiled probe. + + python3 -I tests/capmcp_diff.py PROBE_BINARY [--seed N] [--fuzz N] + +Exit 0 only when every case prints the same line in both programs.""" +import json +import os +import random +import re +import subprocess +import sys +import tempfile +import tomllib + +JSON_CASES = [ + "", " ", "x", "{", "{\"a\"", "{\"a\":", "{\"a\":1", "{\"a\":1,", "{\"a\":1,}", "[1,]", "[1", "[", "[1 2]", + "{\"a\" 1}", "{a:1}", "{'a':1}", "\"abc", "\"a\\x\"", "\"a\\u12\"", "\"a\nb\"", "01", "-", "-x", "1.", "1e", + "1.5e3", "nul", "tru", "NaN", "-Infinity", "Infinity", "{\"a\":1} x", "[] []", "\"\\ud83d\\ude00\"", + "123456789012345678901234567890", " {\"a\": [1, 2.5, \"x\", null, true, false]} ", "{\"a\":1,\"a\":2}", + "\"\\u00e9\"", "[1,\n2,\n x]", "\ufeff{}", "{\"a\":1 \"b\":2}", "{,}", "[,1]", "{\"a\":}", "\"\\", "\"\\u", + "\"\\u12", "{\"a\":1,,}", "tru e", "-0", "-0.0", "1E5", "1e400", "-1e400", "\"é\"", "\"日本語\"", "[\"😀\"]", + "{\"b\":1,\"a\":{\"z\":[],\"y\":{}}}", "0.1", "1e-7", "1e22", "123456789012345680000.0", "5e-324", "1.7976931348623157e308", + "9223372036854775807", "9223372036854775808", "-9223372036854775808", "-9223372036854775809", + "\"\\/\\b\\f\\n\\r\\t\\\"\\\\\"", "\"\x7f\"", "\"\\u007f\"", "\"\\u2028\"", "{\"a\":\n\n [\n1\n,\n2] }", +] + +TOML_CASES = [ + "", "a = 1", "a = 1\nb = 2.5\nc = \"x\"\nd = true\ne = [1, 2, 3]\nf = { g = 1, h = \"i\" }", + "[t]\na = 1\n[t.u]\nb = 2", "[[s]]\nname = \"a\"\n[[s]]\nname = \"b\"", + "a = ", "a", "= 1", "a = 1 b = 2", "a = 1\na = 2", "[t]\n[t]", "a = [1, 2", "a = {x = 1", "a = \"abc", + "a = 'abc'\nb = '''multi\nline'''\nc = \"\"\"x\ny\"\"\"", "a = \"\"\"line \\\n continued\"\"\"", + "a = 0x1F\nb = 0o17\nc = 0b101\nd = 1_000\ne = +5\nf = -7", "a = 1e3\nb = 1.5e-3\nc = inf\nd = -inf\ne = nan", + "a = 01", "a = 1__0", "a = 0x", "a = .5", "a = 5.", "a = 1979-05-27", "a = 1979-05-27T07:32:00Z", + "a = 1979-05-27 07:32:00.5-08:00", "a = 07:32:00", "a = 1979-02-30", "a = 2024-02-29", + "a.b.c = 1\na.b.d = 2", "a.b = 1\n[a]\nc = 2", "[a]\nb = 1\n[a.b]", "[a.b]\nc = 1\n[a]\nd = 2", + "a = [[1], [2, 3], []]", "a = [\n 1, # one\n 2,\n]\n", "a = { b = 1, }", "a = [ ,]", + "# comment only\n", "a = 1 # trailing\n", "a = \"x\" y", "\"quoted key\" = 1\n'lit key' = 2", + "a = \"\\u00e9\\U0001F600\"", "a = \"\\x\"", "a = \"\\ud800\"", "a = \"tab\there\"", "a = \"nl\nx\"", + "[ a . b ]\nc = 1", "[a]\nb = 1\n[[a.c]]\nd = 1\n[[a.c]]\nd = 2", "x = {a.b = 1, a.c = 2}", + "x = {a = 1}\n[x.b]", "a = [1, \"two\", 3.0]", "[a]\n[a.b]\n[a]", "a = 1\n[a]", "[a]\nb = 1\n[a]\nc = 2", + "\r\na = 1\r\nb = 2\r\n", "a = 1\rb = 2", "a = \"\"\"\"x\"\"\"\"", "a = '''x'''''", "a=\"\"\"\\ \n x\"\"\"", + "a = tru", "a = True", "[]", "[a", "[[a]", "[[a]]\n[a]", "[a]\n[[a]]", "a = [\n", "key = 'unterminated", + "bad key = 1", "a = \"\x01\"", "# \x01", "a = 1_000.000_1", "a = 1e+3_0", "a = 0e0", "a = -0.0", + "a = 123456789012345678", "a = 9223372036854775807", "a = 9223372036854775808", + "[agent]\nname = \"tai\"\nprofiles = [\"coding\"]\n[probe]\ncommand = [\"tai.py\", \"--version\"]\nversion_pattern = \"tai (\\\\d+)\"", +] + +REGEX_CASES = [ + ("^tai\\[.*\\]› $", "tai[main]› "), ("^tai\\[.*\\]› $", "tai[main]›"), ("^>\\s*$", "> "), ("^❯\\s*$", "❯"), + ("(?P\\w+)\\((?P.*)\\)", "run(ls -l)"), ("(?Pok|failed)?x", "x"), + ("github\\.com/([^/\\s?#]+)/([^/\\s?#]+?)(?:\\.git)?(?:[/?#]|$)", "https://github.com/o/r.git"), + ("github\\.com/([^/\\s?#]+)/([^/\\s?#]+?)(?:\\.git)?(?:[/?#]|$)", "https://github.com/o/r/tree/x"), + ("(npx|uvx)\\s+([^\\s\"'`]+)", "run: npx -y pkg-name now"), ("@(cap://[A-Za-z0-9_.-]+)", "hi @cap://a-1 and @cap://b"), + ("\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\\}", "x ${FOO:-bar} y"), + ("\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\\}", "x ${FOO} y"), + ("resource_metadata=\"([^\"]+)\"", "Bearer resource_metadata=\"https://x/y\", realm=x"), + ("\\bno\\b", "say no way"), ("\\byes\\b", "yes."), ("seal error:", "seal error: boom"), + ("^seal error:", "x\nseal error:"), ("end$", "the end\n"), ("end$", "the end\nmore"), ("a{2}", "caab aab"), + ("a{,2}b", "aab"), ("x{", "x{"), ("}", "a}"), ("[]a]+", "]]a"), ("[^]a]+", "bc]"), ("\\d+\\.\\d+", "v 1.44.2"), + ("tai (\\d+\\.\\d+\\.\\d+)", "tai 1.44.2"), ("(a)|(b)", "b"), ("é+", "ééé"), ("\\w+", "日本語 x"), + ("(?:ab)+", "ababab"), ("a(?=b)", "ab"), ("a(?!b)", "ac"), ("\\Aab", "ab"), ("ab\\Z", "ab"), +] + +REPR_CASES = [ + "{\"a\":\"it's\",\"b\":[1,null,true,1.5,\"x\\n\"]}", "[\"a\\\"b'c\"]", "[\"\\u00e9\\u2028\\u00a0\\u0007\"]", + "{}", "[]", "[1e22, 1e-7, 0.1, 100.0, 1e16, 123456.789]", "\"plain\"", "null", "[true,false]", "{\"k\":{\"n\":[{}]}}", + "\"日本語\"", "\"\\ud83d\\ude00\"", "[NaN, Infinity, -Infinity]", "\"\\\\\"", +] + +FLOAT_CASES = ["0", "1", "0.1", "1e22", "1e-7", "100", "1e16", "1e15", "123456789.123456789", "5e-324", "1.7976931348623157e308", + "0.0001", "0.00001", "12345678901234567890", "-2.5", "3.14159", "1e300", "2.5e-5", "0.30000000000000004"] + + +def write_cases(path, cases): + with open(path, "wb") as handle: + handle.write(b"\0".join(c.encode("utf-8", "surrogatepass") for c in cases)) + + +def py_json(i, text): + try: + return "%d OK %s" % (i, json.dumps(json.loads(text))) + except ValueError as exc: + return "%d ERR %s" % (i, exc) + + +def py_toml(i, text): + try: + return "%d OK %s" % (i, json.dumps(tomllib.loads(text), default=str)) + except ValueError as exc: + return "%d ERR %s" % (i, exc) + + +def py_repr(i, text): + try: + return "%d OK %s" % (i, repr(json.loads(text))) + except ValueError as exc: + return "%d ERR %s" % (i, exc) + + +def py_float(i, text): + return "%d OK %s" % (i, repr(float(text))) + + +def py_regex(i, text): + pattern, subject = text.split("\x01", 1) + try: + found = re.search(pattern, subject) + except re.error as exc: + return "%d ERR %s" % (i, exc) + if not found: + return "%d NONE" % i + groups = [found.group(0)] + list(found.groups()) + return "%d OK %d %d %s %s" % (i, found.start(), found.end(), json.dumps(groups), json.dumps(found.groupdict())) + + +def fuzz_json(rng, count): + out = [] + atoms = ["null", "true", "false", "0", "-1", "12", "3.5", "-2.5e3", "\"s\"", "\"\\u00e9\"", "\"a b\"", "[]", "{}", "NaN", "\"\\n\""] + for _ in range(count): + depth = rng.randint(0, 3) + + def build(d): + if d == 0: + return rng.choice(atoms) + if rng.random() < 0.5: + return "[" + ", ".join(build(d - 1) for _ in range(rng.randint(0, 3))) + "]" + return "{" + ", ".join("\"k%d\": %s" % (rng.randint(0, 5), build(d - 1)) for _ in range(rng.randint(0, 3))) + "}" + + text = build(depth) + out.append(text) + if text: + cut = rng.randint(0, len(text)) + out.append(text[:cut]) + pos = rng.randint(0, len(text) - 1) if len(text) > 1 else 0 + out.append(text[:pos] + rng.choice([",", "]", "}", "\"", "\\", " ", "x", ":"]) + text[pos + 1:]) + return out + + +def run(probe, mode, cases, pyfn, label): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "cases.bin") + write_cases(path, cases) + done = subprocess.run([probe, mode, path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + got = done.stdout.decode("utf-8", "replace").splitlines() + want = [pyfn(i, c) for i, c in enumerate(cases)] + bad = 0 + for i, (g, w) in enumerate(zip(got, want)): + if g != w: + bad += 1 + if bad <= 8: + print("MISMATCH %s case %d %r\n cpp: %s\n py : %s" % (label, i, cases[i][:120], g[:300], w[:300])) + if len(got) != len(want): + bad += 1 + print("MISMATCH %s: %d lines from probe, %d cases (stderr: %s)" % (label, len(got), len(want), done.stderr.decode()[:300])) + print("%-8s %4d cases, %d mismatches" % (label, len(cases), bad)) + return bad + + +def main(): + args = sys.argv[1:] + if not args: + print(__doc__) + return 2 + probe = args[0] + seed = int(args[args.index("--seed") + 1]) if "--seed" in args else 7 + fuzz = int(args[args.index("--fuzz") + 1]) if "--fuzz" in args else 300 + rng = random.Random(seed) + bad = 0 + jcases = JSON_CASES + fuzz_json(rng, fuzz) + bad += run(probe, "json", jcases, py_json, "json") + bad += run(probe, "repr", REPR_CASES, py_repr, "repr") + bad += run(probe, "float", FLOAT_CASES, py_float, "float") + bad += run(probe, "toml", TOML_CASES, py_toml, "toml") + bad += run(probe, "regex", ["%s\x01%s" % c for c in REGEX_CASES], py_regex, "regex") + print("TOTAL mismatches: %d" % bad) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/taicpp/tests/capmcp_probe.cpp b/taicpp/tests/capmcp_probe.cpp new file mode 100644 index 0000000..0af6120 --- /dev/null +++ b/taicpp/tests/capmcp_probe.cpp @@ -0,0 +1,72 @@ +// capmcp_probe: differential probe for the wire layer. Reads NUL separated +// cases from a file and prints one result line per case; tests/capmcp_diff.py +// runs the same cases through CPython and diffs the two outputs. +// +// capmcp_probe json CASES json.loads + json.dumps +// capmcp_probe toml CASES tomllib.loads + json.dumps +// capmcp_probe repr CASES json.loads + str() of the value +// capmcp_probe regex CASES PATTERN\x01TEXT -> re.search groups +// capmcp_probe float CASES text -> repr(float(text)) +#include +#include +#include +#include +#include + +#include "wire_json.h" +#include "wire_regex.h" +#include "wire_text.h" +#include "wire_toml.h" + +using detz::wire::Json; +using detz::wire::WireText; + +static std::vector loadCases(const char* path) { + std::ifstream in(path, std::ios::binary); + std::string all((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::vector out; + size_t pos = 0; + while (pos <= all.size()) { + size_t at = all.find('\0', pos); + if (at == std::string::npos) { + if (pos < all.size()) out.push_back(all.substr(pos)); + break; + } + out.push_back(all.substr(pos, at - pos)); + pos = at + 1; + } + return out; +} + +int main(int argc, char** argv) { + if (argc < 3) return 2; + const std::string mode = argv[1]; + const auto cases = loadCases(argv[2]); + for (size_t i = 0; i < cases.size(); ++i) { + try { + if (mode == "json") { + std::printf("%zu OK %s\n", i, Json::parse(cases[i]).dumps().c_str()); + } else if (mode == "toml") { + std::printf("%zu OK %s\n", i, detz::wire::WireToml::loads(cases[i]).dumps().c_str()); + } else if (mode == "repr") { + std::printf("%zu OK %s\n", i, Json::parse(cases[i]).pyRepr().c_str()); + } else if (mode == "float") { + std::printf("%zu OK %s\n", i, WireText::reprFloat(std::strtod(cases[i].c_str(), nullptr)).c_str()); + } else if (mode == "regex") { + const size_t sep = cases[i].find('\x01'); + detz::wire::WireRegex re(cases[i].substr(0, sep)); + auto m = re.search(cases[i].substr(sep + 1)); + if (!m) { + std::printf("%zu NONE\n", i); + } else { + Json groups = Json::array(); + for (size_t g = 0; g <= m.groupCount(); ++g) groups.push(m.hasGroup(g) ? Json(m.group(g)) : Json()); + std::printf("%zu OK %zu %zu %s %s\n", i, m.start(), m.end(), groups.dumps().c_str(), m.groupdict().dumps().c_str()); + } + } + } catch (const std::exception& exc) { + std::printf("%zu ERR %s\n", i, exc.what()); + } + } + return 0; +} diff --git a/taicpp/tests/capmcp_unit.cpp b/taicpp/tests/capmcp_unit.cpp new file mode 100644 index 0000000..81f5af3 --- /dev/null +++ b/taicpp/tests/capmcp_unit.cpp @@ -0,0 +1,196 @@ +// capmcp_unit: table checks, drift checks against ../cap.md and ../mcp.md, +// the MCP vault tables, the normalizer and the served endpoint (in process). +// Run from taicpp/: build/capmcp/capmcp_unit +#include + +#include +#include +#include +#include + +#include "cap_agent_manifest.h" +#include "cap_binding.h" +#include "cap_constants.h" +#include "cap_error.h" +#include "cap_event.h" +#include "cap_manifest.h" +#include "cap_stream_json.h" +#include "mcp_constants.h" +#include "mcp_error.h" +#include "mcp_normalizer.h" +#include "mcp_serve_handler.h" +#include "mcp_sqlite_store.h" + +using namespace detz; +using detz::wire::Json; + +static int failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + ++failures; \ + } \ + } while (0) + +static std::string slurp(const char* path) { + std::ifstream in(path, std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +class FakeAgent : public mcp::McpServeAgent { + public: + std::string dispatch(const std::string& name, const Json& args) override { + if (name == "echo") return args.dumps(); + if (name == "deny") throw mcp::McpToolDenied(); + return "error: unknown tool " + name; + } + std::string runTurn(const std::string& message, int steps, double) override { + return "reply:" + message + ":" + std::to_string(steps); + } +}; + +class FakeBackend : public mcp::McpServeBackend { + public: + std::vector toolSchemas() override { + return {Json::object({{"type", Json("function")}, + {"function", Json::object({{"name", Json("echo")}, {"description", Json("d")}, + {"parameters", Json::object({{"type", Json("object")}})}})}})}; + } + std::shared_ptr createAgent() override { return std::make_shared(); } + int maxSteps() override { return 25; } + std::string complete(const std::vector&) override { return "done"; } +}; + +class TestStore : public mcp::McpSqliteStore { + public: + using McpSqliteStore::McpSqliteStore; + std::optional loadSecret(const std::string&, const std::string&) override { return std::nullopt; } + void saveSecret(const std::string&, const std::string&, const Json&, const std::string&) override {} + void deleteSecret(const std::string&, const std::string&) override {} + Json secretMeta(const std::string&, const std::string&) override { return Json::object(); } + std::string spillRecord(const std::string&, const std::string&, const std::string&) override { return "mem:1"; } + void auditEvent(const std::string&, const std::string&, const std::string&, const std::string&, + const std::string&) override {} +}; + +int main() { + // --- CAP tables + CHECK(cap::CAP_ERROR_CODES.size() == 15); + CHECK(cap::CapError("cap_budget_exceeded", "x").numeric == -32004); + CHECK(cap::CapError("cap_custom").numeric == 0); + CHECK(std::string(cap::CapError("cap_custom").what()) == "cap_custom"); + CHECK(cap::CapEvent::KINDS().size() == 19); + for (const auto& kind : cap::CapEvent::KINDS()) CHECK(cap::CapEvent::DIRECTIONS(kind) != nullptr); + Json ev = cap::CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json("m1")}, {"text", Json("hi")}, + {"channel", Json("assistant")}})); + CHECK(ev.dumps() == "{\"kind\": \"cap.text_chunk\", \"message_id\": \"m1\", \"text\": \"hi\", \"channel\": \"assistant\"}"); + bool threw = false; + try { + cap::CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json("m1")}, {"text", Json("hi")}})); + } catch (const wire::ValueError&) { + threw = true; + } + CHECK(threw); + CHECK(cap::CapBinding::select(Json::object({{"fast_path", Json::object({{"grpc", Json(true)}})}})) == "pty"); + cap::CapManifest manifest = cap::CapManifest::fromToml(cap::CapAgentManifest::TAI_CAP_MANIFEST(), "own"); + CHECK(manifest.name() == "tai"); + CHECK(manifest.binding() == "stream_json"); + CHECK(manifest.get("parse.idle").items().size() == 1); + CHECK(cap::CapStreamJson::parseUserText("{\"type\":\"user\",\"message\":\"x\"}") == "x"); + + // --- drift check against ../cap.md + const std::string capDoc = slurp("../cap.md"); + if (!capDoc.empty()) { + for (const auto& kind : cap::CapEvent::KINDS()) CHECK(capDoc.find("`" + kind + "`") != std::string::npos); + for (const auto& entry : cap::CAP_ERROR_CODES) CHECK(capDoc.find(std::string(entry.second)) != std::string::npos); + for (const auto& code : cap::CAP_ERROR_STRINGS) CHECK(capDoc.find("`" + std::string(code) + "`") != std::string::npos); + CHECK(capDoc.find("\xE2\x80\x94") == std::string::npos); + } else { + std::printf("note: ../cap.md not found, doc drift check skipped\n"); + } + const std::string mcpDoc = slurp("../mcp.md"); + if (!mcpDoc.empty()) { + for (const char* name : {"MCP_CONNECT_TIMEOUT", "MCP_CALL_TIMEOUT", "MCP_RESULT_MAX", "MCP_HTTP_MAX_BODY", + "MCP_MAX_SERVERS", "MCP_MAX_TOOLS", "MCP_SESSION_IDLE", "MCP_CHAT_TIMEOUT", + "MCP_RESOLVER_TIMEOUT", "MCP_RESOLVER_STEPS", "MCP_OAUTH_TIMEOUT"}) + CHECK(mcpDoc.find(name) != std::string::npos); + } + + // --- normalizer + CHECK(mcp::McpNormalizer::mcpSanitizeName("Foo Bar!") == "foo_bar"); + CHECK(mcp::McpNormalizer::mcpSanitizeName("!!") == "server"); + auto expanded = mcp::McpNormalizer::mcpExpandVars("a ${X} ${Y:-d} ${Z}", [](const std::string& n) -> std::optional { + if (n == "X") return std::string("1"); + return std::nullopt; + }); + CHECK(expanded.first == "a 1 d ${Z}"); + CHECK(expanded.second.size() == 1 && expanded.second[0] == "Z"); + auto norm = mcp::McpNormalizer::mcpNormalize("{\"mcpServers\": {\"s\": {\"command\": \"npx\", \"args\": [\"-y\", \"p\"]}}}"); + CHECK(norm.entry.get("transport").str() == "stdio"); + CHECK(norm.entry.get("source").str() == "config-json"); + threw = false; + try { + mcp::McpNormalizer::mcpNormalize("https://github.com/o/r"); + } catch (const mcp::McpError&) { + threw = true; + } + CHECK(threw); + CHECK(mcp::McpNormalizer::mcpBridgeName("My Srv", "do-it") == "mcp_my_srv_do_it"); + + // --- vault tables + sqlite3* db = nullptr; + sqlite3_open(":memory:", &db); + mcp::McpPlainSeal seal; + TestStore store(db, &seal); + store.ensureSchema(); + store.mcpSaveServer(Json::object({{"name", Json("srv")}, {"transport", Json("http")}, {"url", Json("https://x.test/mcp")}, + {"headers", Json::object({{"Authorization", Json("Bearer s")}})}})); + CHECK(store.mcpGetServer("srv").get("url").str() == "https://x.test/mcp"); + CHECK(!store.mcpServerEnabled("srv", "t")); + store.mcpSetEnabled("srv", "t", true); + CHECK(store.mcpEnabledServers("t").size() == 1); + store.mcpStoreTools("srv", {Json::object({{"name", Json("b")}, {"description", Json("bee")}}), + Json::object({{"name", Json("a")}})}); + CHECK(store.mcpCachedTools("srv")[0].get("tool").str() == "a"); + CHECK(store.mcpToolPins("srv").size() == 2); + threw = false; + try { + store.mcpSaveServer(Json::object({{"name", Json("bad name!")}})); + } catch (const wire::ValueError&) { + threw = true; + } + CHECK(threw); + store.mcpRemoveServer("srv"); + CHECK(store.mcpGetServer("srv").isNull()); + sqlite3_close(db); + + // --- served endpoint, in process + FakeBackend backend; + mcp::McpServeContext ctx(backend, "k"); + mcp::McpServeHandler handler(ctx); + wire::WireHttpServer::Request req; + wire::WireHttpServer::Response resp; + req.method = "POST"; + req.path = "/mcp"; + req.headers["authorization"] = "Bearer k"; + req.body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; + req.headers["content-length"] = std::to_string(req.body.size()); + CHECK(handler.handle(req, resp) && resp.status == 200); + CHECK(Json::parse(resp.body).get("result").get("protocolVersion").str() == "2025-11-25"); + std::string sid; + for (const auto& h : resp.headers) + if (h.first == "Mcp-Session-Id") sid = h.second; + CHECK(sid.size() == 32); + req.headers["mcp-session-id"] = sid; + req.body = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"deny\",\"arguments\":{}}}"; + req.headers["content-length"] = std::to_string(req.body.size()); + handler.handle(req, resp); + CHECK(Json::parse(resp.body).get("result").get("isError").truthy()); + req.headers["authorization"] = "Bearer wrong"; + handler.handle(req, resp); + CHECK(resp.status == 401); + + std::printf(failures ? "%d FAILURES\n" : "capmcp_unit: all checks passed\n", failures); + return failures ? 1 : 0; +}