diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 7748f32fce..1f95671bdd 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d44141ce2d62548faf2f081b7319fc529b5ed791891a68cd6733dcd4341508c1", + "digest": "44e81d200d46b1ffb031427d4812fbcaa388aa556d82408fa78f8e6193fdcf22", "ownerDigest": "df28e5eed6e3190741899ebf236bc5fb426d2469187e437839a349a7136c7c34", "searchAcceptance": [ { @@ -29795,8 +29795,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "titleEn": "Choose native mode or PTC TypeScript / Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -29992,8 +29992,8 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript / Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript / Python.", "valueSchema": { "type": "string", "enum": [ @@ -30129,8 +30129,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", - "选择模型使用原生模式还是 PTC TypeScript 模式", - "Choose native mode or PTC TypeScript mode for the model", + "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "Choose native mode or PTC TypeScript / Python mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/docs/interactive-capabilities/capabilities/setting.tools.execution.md b/docs/interactive-capabilities/capabilities/setting.tools.execution.md index e8c2c03973..4e9d9aff99 100644 --- a/docs/interactive-capabilities/capabilities/setting.tools.execution.md +++ b/docs/interactive-capabilities/capabilities/setting.tools.execution.md @@ -27,8 +27,8 @@ Manage agent tool permissions, presentation mode, concurrency, timeouts, deferre - Review, revoke, or clear remembered project permission grants and audit records - **智能体可直接控制 / Direct Agent control** · 按需延迟加载工具定义以减少智能体初始上下文 - Deferred tool loading on demand to reduce initial agent context -- **智能体可直接控制 / Direct Agent control** · 选择模型使用原生模式还是 PTC TypeScript 模式 - - Choose native mode or PTC TypeScript mode for the model +- **智能体可直接控制 / Direct Agent control** · 选择模型使用原生模式还是 PTC TypeScript / Python 模式 + - Choose native mode or PTC TypeScript / Python mode for the model - **智能体可直接控制 / Direct Agent control** · 设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略 - Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy - **智能体可直接控制 / Direct Agent control** · 设置或取消单次工具调用超时 @@ -69,7 +69,7 @@ Manage agent tool permissions, presentation mode, concurrency, timeouts, deferre | --- | --- | --- | --- | | 显示权限模式选择器 / Show permission-mode selector | `boolean` | 在聊天输入框下方显示或隐藏权限模式快捷选择器。 | Show or hide the permission-mode shortcut below the chat composer. | | 延迟加载工具 / Deferred tool loading | `boolean` | 按需加载大型工具定义,减少智能体初始上下文。 | Load large tool definitions on demand to reduce initial agent context. | -| 工具呈现模式 / Tool presentation mode | `native` / `ptc` | 选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。 | Choose whether the model calls native tools directly or uses PTC TypeScript. | +| 工具呈现模式 / Tool presentation mode | `native` / `ptc` | 选择模型直接调用原生工具,或通过 PTC TypeScript / Python 调用工具。 | Choose whether the model calls native tools directly or uses PTC TypeScript / Python. | | 子智能体批量执行策略 / Subagent batch policy | `safe_only` / `force_parallel` / `serial` | 选择仅安全任务并行、强制并行或串行执行。 | Choose safe-only parallelism, forced parallelism, or serial execution. | | 子智能体最大并发 / Maximum subagent concurrency | `integer` (1–32) | 限制同时运行的子智能体数量。 | Limit the number of subagents that may run at once. | | Swarm 最大并发 / Maximum swarm concurrency | `integer` (1–64) | 限制 Swarm 中同时执行的工作单元。 | Limit concurrent work units inside a swarm. | diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index dfb7380d06..fc6e4823f2 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "d44141ce2d62548faf2f081b7319fc529b5ed791891a68cd6733dcd4341508c1", + "catalogDigest": "44e81d200d46b1ffb031427d4812fbcaa388aa556d82408fa78f8e6193fdcf22", "count": 207, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index b7654c4100..4cebfa4613 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "d44141ce2d62548faf2f081b7319fc529b5ed791891a68cd6733dcd4341508c1", + "catalogDigest": "44e81d200d46b1ffb031427d4812fbcaa388aa556d82408fa78f8e6193fdcf22", "commandCount": 709, "coverage": { "commandCount": 709, diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index cf78f6cd78..452e167b09 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -3,6 +3,39 @@ For development commands, see [AGENTS.md](AGENTS.md) and the repository [contribution guide](../../../CONTRIBUTING.md). +## Programmatic tool calling (PTC) + +In **Settings → Tools → Execution & Permissions**, select PTC tool presentation +for new sessions. `RunCode` accepts an async function body and an optional +`language`: `typescript` (the backward-compatible default) or `python`. +TypeScript needs Node.js 22.6+; Python needs Python 3.8+ and does not need Node.js. +Install the selected runtime on the execution host: the SSH workspace target, +peer host, or detached-dispatch target, not just the controller computer. +An unavailable runtime returns an explicit error without local fallback or +silently translating the source into another language. + +For example, a Python `RunCode` request is: + +```json +{ + "language": "python", + "code": "import asyncio\nresults = await asyncio.gather(tools.Read({'file_path': 'a.py'}), tools.Read({'file_path': 'b.py'}))\nreturn results" +} +``` + +Use the actual argument names from the session's generated tool guide. +`tools.(arguments)` and `tools["name"](arguments)` are awaitable; arguments +are dictionaries and JSON results are Python dictionaries/lists. Load deferred +tools with `await tools.getToolSpec("name")`, inspect the returned `input_schema` +and `output_schema`, then call `await tools.callTool("name", arguments)`. +`print` output is captured in `logs`; return a JSON-serializable value. + +Both languages share tool permissions, execution deadlines, output limits and +process-tree cancellation. Pending nested calls are cancelled when the program +ends. These workers provide failure containment, **not an OS sandbox**: +Python imports and direct filesystem/network operations are not governed by +nested-tool permissions. Do not run untrusted code on this boundary. + ## Model selection and smart routing **Release availability:** smart mode is not available in this release. The GUI diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 51b2b77331..3666f1c710 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -234,6 +234,14 @@ presentation, check both the native baseline and the optional PTC owner: cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::product_runtime:: cargo test --locked -p openbitfun-core --no-default-features --features ptc-runtime --lib agentic::tools::product_runtime:: ``` + +For RunCode language validation, TypeScript/Python nested pipeline dispatch, and +PTC manifest/guide compatibility: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features ptc-runtime --lib ptc_ +``` + For config-backed product-control option defaults, typed binding, and persisted readback: ```bash diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs index c495de7997..ab6bc4eba7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs @@ -38,7 +38,7 @@ impl Tool for PtcRunCodeTool { } fn short_description(&self) -> String { - "Execute a TypeScript program with available tools".to_string() + "Execute a TypeScript or Python program with available tools".to_string() } fn input_schema(&self) -> Value { @@ -87,6 +87,17 @@ impl Tool for PtcRunCodeTool { input: &Value, _context: Option<&ToolUseContext>, ) -> ValidationResult { + if input + .get("language") + .is_some_and(|language| !matches!(language.as_str(), Some("typescript" | "python"))) + { + return ValidationResult { + result: false, + message: Some("RunCode language must be typescript or python".to_string()), + error_code: Some(400), + meta: None, + }; + } if input .get("code") .and_then(Value::as_str) @@ -112,3 +123,29 @@ impl Tool for PtcRunCodeTool { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn ptc_validates_python_and_legacy_typescript_inputs() { + let tool = PtcRunCodeTool::new(); + for input in [ + json!({"code": "return 1;"}), + json!({"code": "return 1;", "language": "typescript"}), + json!({"code": "return 1", "language": "python"}), + ] { + assert!(tool.validate_input(&input, None).await.result); + } + for input in [ + json!({"code": "return 1", "language": "ruby"}), + json!({"code": "return 1", "language": null}), + json!({"code": "return 1", "language": 1}), + json!({"code": " ", "language": "python"}), + ] { + assert!(!tool.validate_input(&input, None).await.result); + } + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 15d2b6c616..17ffbd4cfa 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -49,7 +49,7 @@ use openbitfun_runtime_ports::{ PtcToolCallHandler, PtcToolSpecRequest, }; #[cfg(feature = "ptc-runtime")] -use openbitfun_services_integrations::ptc_runtime::NodePtcRuntime; +use openbitfun_services_integrations::ptc_runtime::{PtcScriptLanguage, ScriptPtcRuntime}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Instant, SystemTime}; @@ -889,8 +889,8 @@ impl PtcToolCallHandler for PtcPipelineToolCallHandler { let typescript_sdk = render_ptc_loaded_tool_typescript_sdk(&PtcToolContract::new( tool_name.clone(), description, - input_schema, - output_schema, + input_schema.clone(), + output_schema.clone(), )); let mut loaded = self.loaded_deferred_tool_specs.lock().await; @@ -904,6 +904,8 @@ impl PtcToolCallHandler for PtcPipelineToolCallHandler { } Ok(serde_json::json!({ "typescript_sdk": typescript_sdk, + "input_schema": input_schema, + "output_schema": output_schema, })) } } @@ -2974,6 +2976,20 @@ impl ToolPipeline { .effective_arguments() .get("timeout_ms") .and_then(serde_json::Value::as_u64); + let language = match task.effective_arguments().get("language") { + None => PtcScriptLanguage::TypeScript, + Some(serde_json::Value::String(language)) if language == "typescript" => { + PtcScriptLanguage::TypeScript + } + Some(serde_json::Value::String(language)) if language == "python" => { + PtcScriptLanguage::Python + } + _ => { + return Err(OpenBitFunError::Validation( + "RunCode language must be typescript or python".to_string(), + )) + } + }; let runtime = if context.is_remote() { #[cfg(feature = "ssh-remote")] { @@ -3002,7 +3018,7 @@ impl ToolPipeline { "Remote SSH services are not initialized".to_string(), ) })?; - NodePtcRuntime::for_remote(ssh_manager, connection_id) + ScriptPtcRuntime::for_remote(ssh_manager, connection_id) } #[cfg(not(feature = "ssh-remote"))] { @@ -3011,14 +3027,20 @@ impl ToolPipeline { )); } } else { - NodePtcRuntime::discover() - }; + ScriptPtcRuntime::discover() + } + .with_language(language); let operation_id = task.tool_call.tool_id.clone(); + let nested_cancellation = context + .cancellation_token() + .map(CancellationToken::child_token) + .unwrap_or_default(); + let _nested_cancellation_guard = nested_cancellation.clone().drop_guard(); let handler = Arc::new(PtcPipelineToolCallHandler { pipeline: Arc::new(self.clone()), context: task.context.clone(), options: ToolExecutionOptions { - parent_cancellation_token: context.cancellation_token().cloned(), + parent_cancellation_token: Some(nested_cancellation), ..task.options.clone() }, parent_tool_id: operation_id.clone(), @@ -4362,6 +4384,17 @@ mod tests { #[cfg(feature = "ptc-runtime")] #[tokio::test] async fn ptc_run_code_dispatches_nested_calls_through_the_pipeline() { + assert_ptc_pipeline_dispatch(None).await; + } + + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn ptc_python_run_code_dispatches_nested_calls_through_the_pipeline() { + assert_ptc_pipeline_dispatch(Some("python")).await; + } + + #[cfg(feature = "ptc-runtime")] + async fn assert_ptc_pipeline_dispatch(language: Option<&str>) { let pipeline = test_tool_pipeline(); register_static_test_tool(&pipeline, "Read", json!({"content": "ok"}), 0).await; let workspace_root = std::env::temp_dir().join("openbitfun-ptc-pipeline-test"); @@ -4374,14 +4407,22 @@ mod tests { serde_json::to_string(&vec!["Read"]).expect("nested allowlist should serialize"), ); + let mut arguments = json!({ + "code": if language == Some("python") { + "return await tools.Read({'path': 'src/lib.rs'})" + } else { + "return await tools.Read({ path: 'src/lib.rs' });" + } + }); + if let Some(language) = language { + arguments["language"] = json!(language); + } let results = pipeline .execute_tools( vec![ToolCall { tool_id: "outer-ptc".to_string(), tool_name: openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME.to_string(), - arguments: json!({ - "code": "return await tools.Read({ path: 'src/lib.rs' });" - }), + arguments, ..ToolCall::default() }], context, diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 1ec4ef44dc..4abd5cd903 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d44141ce2d62548faf2f081b7319fc529b5ed791891a68cd6733dcd4341508c1", + "digest": "44e81d200d46b1ffb031427d4812fbcaa388aa556d82408fa78f8e6193fdcf22", "ownerDigest": "df28e5eed6e3190741899ebf236bc5fb426d2469187e437839a349a7136c7c34", "searchAcceptance": [ { @@ -30177,8 +30177,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "titleEn": "Choose native mode or PTC TypeScript / Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -30381,8 +30381,8 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript / Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript / Python.", "valueSchema": { "type": "string", "enum": [ @@ -30550,8 +30550,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", - "选择模型使用原生模式还是 PTC TypeScript 模式", - "Choose native mode or PTC TypeScript mode for the model", + "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "Choose native mode or PTC TypeScript / Python mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/src/crates/execution/tool-contracts/src/ptc.rs b/src/crates/execution/tool-contracts/src/ptc.rs index 3154c182bb..976f6dd171 100644 --- a/src/crates/execution/tool-contracts/src/ptc.rs +++ b/src/crates/execution/tool-contracts/src/ptc.rs @@ -209,7 +209,13 @@ pub fn ptc_run_code_input_schema() -> Value { "properties": { "code": { "type": "string", - "description": "TypeScript program. Call tools by name through the generated tools binding and return a JSON value." + "description": "Async TypeScript or Python function body. Call tools through the generated tools binding and return a JSON value." + }, + "language": { + "type": "string", + "enum": ["typescript", "python"], + "default": "typescript", + "description": "Script language. TypeScript requires Node.js 22.6+; Python requires Python 3.8+ on the execution host. Omit for backward-compatible TypeScript execution." }, "timeout_ms": { "type": "integer", @@ -222,7 +228,7 @@ pub fn ptc_run_code_input_schema() -> Value { } pub fn ptc_run_code_description() -> String { - "Execute a TypeScript program that can call available tools through the provided `tools` binding and return a compact JSON value." + "Execute a TypeScript program (default) or a Python program (language: python) that can call available tools through the provided `tools` binding and return a compact JSON value." .to_string() } @@ -239,12 +245,12 @@ pub fn render_ptc_tool_calling_guide_with_options( include_deferred_tool_apis: bool, ) -> String { let deferred_guidance = if include_deferred_tool_apis { - "\n\nFor deferred tools:\n1. Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.\n2. Read the returned `typescript_sdk`, which contains the input and output types.\n3. Call `tools.callTool(\"tool_name\", args)` with arguments matching that SDK.\nDo not infer deferred tool arguments from the placeholder `Record` type." + "\n\nFor deferred tools:\n1. Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.\n2. Read the returned `typescript_sdk`, which contains the input and output types.\n3. Call `tools.callTool(\"tool_name\", args)` with arguments matching that SDK.\nPython can read the returned `input_schema` and `output_schema` instead of `typescript_sdk`. Do not infer deferred tool arguments from the placeholder `Record` type." } else { "" }; format!( - "# Tool Calling Guide\nUse `RunCode` with a TypeScript program. Call tools through the generated `tools` binding and return a JSON-serializable value. The binding includes direct tool signatures.{}\n\nGenerated SDK:\n```ts\n{}\n```", + "# Tool Calling Guide\nUse `RunCode` with a TypeScript program by default, or set `language` to `python` for Python. Both execute an async function body: use top-level await and return a JSON-serializable result. Python requires Python 3.8+ on the execution host, not Node.js. In Python use `await tools.Read({{\"file_path\": \"file.py\"}})`; tool arguments are Python dictionaries and results are dictionaries/lists, not attribute objects. Use `asyncio.gather` for concurrency and `print` for captured logs. The TypeScript SDK below describes the same tool names, argument fields and result shapes for both languages; do not execute TypeScript declarations in Python. Scripts are trusted code running in supervised processes, NOT an OS sandbox; direct filesystem/network access is not mediated by tool permissions. Call tools through the generated `tools` binding and return a JSON-serializable value. The binding includes direct tool signatures.{}\n\nGenerated SDK:\n```ts\n{}\n```", deferred_guidance, sdk_source.trim(), ) @@ -718,4 +724,20 @@ mod tests { render_ptc_tool_calling_guide("export type ToolArgsMap = {};").contains("ToolArgsMap") ); } + + #[test] + fn run_code_exposes_python_without_changing_legacy_required_fields() { + let schema = ptc_run_code_input_schema(); + assert_eq!( + schema["properties"]["language"]["enum"], + json!(["typescript", "python"]) + ); + assert_eq!(schema["properties"]["language"]["default"], "typescript"); + assert_eq!(schema["required"], json!(["code"])); + let guide = render_ptc_tool_calling_guide("sdk"); + assert!(guide.contains("set `language` to `python`")); + assert!(guide.contains("await tools.Read({\"file_path\": \"file.py\"})")); + assert!(guide.contains("Python dictionaries")); + assert!(guide.contains("NOT an OS sandbox")); + } } diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index c6b9af915e..b60c013bc1 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -121,6 +121,18 @@ slices that are outside pure product logic but still platform-neutral. workspaces; never probe or fall back to the host filesystem for a remote workspace path. +## PTC runtime + +`ptc_runtime::ScriptPtcRuntime` supervises both Node/TypeScript and Python +workers through the same `PtcRuntime` port and nested-tool protocol. The legacy +`NodePtcRuntime` name remains a compatibility alias defaulting to TypeScript. +Python uses isolated, unbuffered CPython 3.8+ (`python3`, then `python`), while +TypeScript requires Node.js 22.6+. Remote workspaces probe and launch only on +the SSH target. Neither backend is an OS sandbox. Tool admission and permission +orchestration remain in the Core pipeline. Add `remote-ssh-concrete` to the +focused PTC test command below to compile the remote launcher and exercise the +offline-target/no-local-fallback contract; this is not a live SSH end-to-end test. + ## Verification Select one integration family and its minimum feature set. Remote SSH uses a @@ -130,6 +142,7 @@ creating another Cargo target. Real transport/system boundaries such as MCP streamable HTTP stay independent. Representative stable entry points are: ```bash +cargo test --locked -p openbitfun-services-integrations --no-default-features --features ptc-runtime --lib ptc_runtime::tests:: cargo check -p openbitfun-services-integrations --no-default-features cargo test -p openbitfun-services-integrations --no-default-features --features browser-control --lib browser_control::cdp::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features remote-persistence --lib remote_persistence::tests:: diff --git a/src/crates/services/services-integrations/src/ptc_node.rs b/src/crates/services/services-integrations/src/ptc_node.rs new file mode 100644 index 0000000000..6df56a9918 --- /dev/null +++ b/src/crates/services/services-integrations/src/ptc_node.rs @@ -0,0 +1,103 @@ +//! Node-specific PTC worker. + +pub(super) const BOOTSTRAP: &str = r#" +import readline from 'node:readline'; +import { randomUUID } from 'node:crypto'; +import { stripTypeScriptTypes } from 'node:module'; +import vm from 'node:vm'; + +const stringify = JSON.stringify.bind(JSON); +const parse = JSON.parse.bind(JSON); +const writeRaw = process.stdout.write.bind(process.stdout); +const token = randomUUID(); +const pending = new Map(); +let maxOutputBytes = 8 * 1024 * 1024; +let logs = []; + +function write(value) { writeRaw(`${stringify(value)}\n`); } +function log(...values) { + logs.push(values.map((value) => typeof value === 'string' ? value : stringify(value)).join(' ')); +} +function failure(message, code = 'exception') { + const error = new Error(message); + error.ptcCode = code; + throw error; +} +function jsonValue(value) { + if (value === undefined) return null; + const encoded = stringify(value); + if (Buffer.byteLength(encoded, 'utf8') > maxOutputBytes) failure('PTC result exceeds output limit', 'output_limit'); + return parse(encoded); +} +function callTool(name, argumentsValue, allowed, enforceBinding = true) { + if (enforceBinding && !allowed.has(name)) return Promise.reject(new Error(`tool '${name}' is not available in this PTC snapshot`)); + if (!argumentsValue || typeof argumentsValue !== 'object' || Array.isArray(argumentsValue)) { + return Promise.reject(new Error(`arguments for '${name}' must be an object`)); + } + const operationId = randomUUID(); + write({ kind: 'tool_call', token, operationId, toolName: name, arguments: argumentsValue, enforceBinding }); + return new Promise((resolve, reject) => pending.set(operationId, { resolve, reject })); +} + +function getToolSpec(name) { + if (typeof name !== 'string' || name.length === 0) { + return Promise.reject(new Error('getToolSpec requires a non-empty tool name')); + } + const operationId = randomUUID(); + write({ kind: 'get_tool_spec', token, operationId, toolName: name }); + return new Promise((resolve, reject) => pending.set(operationId, { resolve, reject })); +} + +async function execute(message) { + maxOutputBytes = Number.isInteger(message.maxOutputBytes) && message.maxOutputBytes > 0 + ? message.maxOutputBytes : 8 * 1024 * 1024; + logs = []; + const names = new Set(Array.isArray(message.bindings) ? message.bindings.map((binding) => binding.name) : []); + const tools = Object.create(null); + for (const name of names) tools[name] = (args) => callTool(name, args, names); + tools.getToolSpec = getToolSpec; + tools.callTool = (name, args) => callTool(name, args, names, false); + const consoleFacade = Object.freeze({ + log, info: log, warn: log, error: log, debug: log, + }); + const context = vm.createContext({ + AbortController, AbortSignal, Buffer, URL, URLSearchParams, + clearImmediate, clearInterval, clearTimeout, console: consoleFacade, + queueMicrotask, setImmediate, setInterval, setTimeout, structuredClone, + }); + const wrappedSource = `(async (tools) => {\n${message.source}\n})`; + const source = stripTypeScriptTypes(wrappedSource, { mode: 'strip' }); + const program = new vm.Script(source, { + filename: 'openbitfun-ptc.ts', + }).runInContext(context); + const value = await program(Object.freeze(tools)); + const result = jsonValue(value); + const logBytes = Buffer.byteLength(stringify(logs), 'utf8'); + if (logBytes > maxOutputBytes) failure('PTC logs exceed output limit', 'output_limit'); + return { value: result, logs }; +} + +write({ kind: 'ready', token }); +const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on('line', (line) => { + let message; + try { message = parse(line); } catch { process.exit(1); return; } + if (message.type === 'tool_result') { + const call = pending.get(message.operationId); + if (!call) return; + pending.delete(message.operationId); + if (message.ok) call.resolve(message.value); + else call.reject(new Error(message.error || 'nested tool call failed')); + return; + } + if (message.type !== 'execute') return; + void execute(message).then( + (result) => write({ kind: 'complete', token, id: message.id, ok: true, result }), + (error) => write({ + kind: 'complete', token, id: message.id, ok: false, + errorKind: error?.ptcCode || 'exception', + error: error instanceof Error ? error.message.slice(0, 4096) : 'PTC execution failed', + }), + ); +}); +"#; diff --git a/src/crates/services/services-integrations/src/ptc_python.py b/src/crates/services/services-integrations/src/ptc_python.py new file mode 100644 index 0000000000..1a6e256cd8 --- /dev/null +++ b/src/crates/services/services-integrations/src/ptc_python.py @@ -0,0 +1,131 @@ +"""PTC Python worker. Process isolation is not a security sandbox.""" +import asyncio +import json +import sys +import textwrap +import uuid + +sys.stdin.reconfigure(encoding="utf-8") +sys.stdout.reconfigure(encoding="utf-8") +_wire_in = sys.stdin +_wire_out = sys.stdout +_token = uuid.uuid4().hex +_pending = {} +_logs = [] +_limit = 8 * 1024 * 1024 + + +class OutputLimitError(Exception): + pass + + +def write(value): + _wire_out.write(json.dumps(value, ensure_ascii=True, allow_nan=False) + "\n") + _wire_out.flush() + + +def log(*values, sep=" ", end="\n", **kwargs): + _logs.append(sep.join(str(value) for value in values) + end.rstrip("\n")) + if len(json.dumps(_logs).encode("utf-8")) > _limit: + raise OutputLimitError("PTC logs exceed output limit") + + +class Tools: + def __init__(self, bindings): + self._names = {binding["name"] for binding in bindings} + + def __getattr__(self, name): + async def call(arguments): + if name not in self._names: + raise ValueError("tool '%s' is not available in this PTC snapshot" % name) + return await self._call(name, arguments, True) + return call + + def __getitem__(self, name): + return self.__getattr__(name) + + async def _call(self, name, arguments, enforce_binding): + if not isinstance(arguments, dict): + raise ValueError("arguments for '%s' must be an object" % name) + return await self._request({"kind": "tool_call", "toolName": name, + "arguments": arguments, "enforceBinding": enforce_binding}) + + async def callTool(self, name, arguments): + return await self._call(name, arguments, False) + + async def getToolSpec(self, name): + if not isinstance(name, str) or not name: + raise ValueError("getToolSpec requires a non-empty tool name") + return await self._request({"kind": "get_tool_spec", "toolName": name}) + + async def _request(self, frame): + operation_id = uuid.uuid4().hex + future = asyncio.get_running_loop().create_future() + _pending[operation_id] = future + try: + write(dict(frame, token=_token, operationId=operation_id)) + return await future + finally: + _pending.pop(operation_id, None) + + +async def execute(message): + global _limit, _logs + _limit = message.get("maxOutputBytes") or 8 * 1024 * 1024 + _logs = [] + try: + # Like the Node worker, run an async function body with a return value. + source = "async def __ptc_program(tools):\n" + textwrap.indent(message["source"], " ") + namespace = {"print": log} + exec(compile(source, "openbitfun-ptc.py", "exec"), namespace) + value = await namespace["__ptc_program"](Tools(message.get("bindings", []))) + encoded = json.dumps(value, allow_nan=False) + if len(encoded.encode("utf-8")) > _limit: + raise OutputLimitError("PTC result exceeds output limit") + write({"kind": "complete", "token": _token, "id": message["id"], "ok": True, + "result": {"value": value, "logs": _logs}}) + except BaseException as error: + write({"kind": "complete", "token": _token, "id": message["id"], "ok": False, + "errorKind": "output_limit" if isinstance(error, OutputLimitError) else "exception", + "error": str(error)[:4096]}) + + +async def main(): + # A blocking stdin reader in an executor works with Windows anonymous pipes, + # unlike add_reader/connect_read_pipe. Only this task reads protocol frames. + loop = asyncio.get_running_loop() + task = None + write({"kind": "ready", "token": _token}) + while True: + line = await loop.run_in_executor(None, _wire_in.readline) + if not line: + break + message = json.loads(line) + if message.get("type") == "execute": + task = asyncio.create_task(execute(message)) + elif message.get("type") == "tool_result": + future = _pending.get(message["operationId"]) + if future is not None and not future.done(): + if message.get("ok"): + future.set_result(message.get("value")) + else: + future.set_exception(RuntimeError(message.get("error", "nested tool call failed"))) + if task is not None: + task.cancel() + + +# Keep ordinary Python stdout/stderr writes away from protocol framing. The +# worker remains trusted code execution, not an authenticated protocol boundary. +class LogStream: + def write(self, value): + if value.strip(): + log(value.rstrip("\n")) + return len(value) + + def flush(self): + pass + + +sys.stdout = LogStream() +sys.stderr = LogStream() +asyncio.run(main()) diff --git a/src/crates/services/services-integrations/src/ptc_runtime.rs b/src/crates/services/services-integrations/src/ptc_runtime.rs index 059f5ea76a..29695ed66e 100644 --- a/src/crates/services/services-integrations/src/ptc_runtime.rs +++ b/src/crates/services/services-integrations/src/ptc_runtime.rs @@ -1,8 +1,8 @@ -//! Node runtime for PTC programs on local or remote workspaces. +//! Supervised script runtime for PTC programs on local or remote workspaces. //! //! The child process is a failure-containment boundary, not a security -//! sandbox. The bootstrap exposes only the `tools` binding and a small set of -//! language primitives; host-side policy still owns nested tool admission. +//! sandbox. Both backends expose the same `tools` protocol; host-side policy +//! owns nested tool admission, not direct language filesystem/network access. #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] use crate::remote_ssh::{shell_quote_posix, SSHCommandOptions, SSHConnectionManager}; @@ -29,107 +29,51 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); const CANCEL_GRACE: Duration = Duration::from_millis(500); const MAX_FRAME_BYTES: usize = 128 * 1024 * 1024; -const BOOTSTRAP: &str = r#" -import readline from 'node:readline'; -import { randomUUID } from 'node:crypto'; -import { stripTypeScriptTypes } from 'node:module'; -import vm from 'node:vm'; - -const stringify = JSON.stringify.bind(JSON); -const parse = JSON.parse.bind(JSON); -const writeRaw = process.stdout.write.bind(process.stdout); -const token = randomUUID(); -const pending = new Map(); -let maxOutputBytes = 8 * 1024 * 1024; -let logs = []; - -function write(value) { writeRaw(`${stringify(value)}\n`); } -function log(...values) { - logs.push(values.map((value) => typeof value === 'string' ? value : stringify(value)).join(' ')); -} -function failure(message, code = 'exception') { - const error = new Error(message); - error.ptcCode = code; - throw error; -} -function jsonValue(value) { - if (value === undefined) return null; - const encoded = stringify(value); - if (Buffer.byteLength(encoded, 'utf8') > maxOutputBytes) failure('PTC result exceeds output limit', 'output_limit'); - return parse(encoded); -} -function callTool(name, argumentsValue, allowed, enforceBinding = true) { - if (enforceBinding && !allowed.has(name)) return Promise.reject(new Error(`tool '${name}' is not available in this PTC snapshot`)); - if (!argumentsValue || typeof argumentsValue !== 'object' || Array.isArray(argumentsValue)) { - return Promise.reject(new Error(`arguments for '${name}' must be an object`)); - } - const operationId = randomUUID(); - write({ kind: 'tool_call', token, operationId, toolName: name, arguments: argumentsValue, enforceBinding }); - return new Promise((resolve, reject) => pending.set(operationId, { resolve, reject })); -} +#[path = "ptc_node.rs"] +mod node; -function getToolSpec(name) { - if (typeof name !== 'string' || name.length === 0) { - return Promise.reject(new Error('getToolSpec requires a non-empty tool name')); - } - const operationId = randomUUID(); - write({ kind: 'get_tool_spec', token, operationId, toolName: name }); - return new Promise((resolve, reject) => pending.set(operationId, { resolve, reject })); +/// Script backend selection; omitted RunCode language remains TypeScript. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PtcScriptLanguage { + #[default] + TypeScript, + Python, } -async function execute(message) { - maxOutputBytes = Number.isInteger(message.maxOutputBytes) && message.maxOutputBytes > 0 - ? message.maxOutputBytes : 8 * 1024 * 1024; - logs = []; - const names = new Set(Array.isArray(message.bindings) ? message.bindings.map((binding) => binding.name) : []); - const tools = Object.create(null); - for (const name of names) tools[name] = (args) => callTool(name, args, names); - tools.getToolSpec = getToolSpec; - tools.callTool = (name, args) => callTool(name, args, names, false); - const consoleFacade = Object.freeze({ - log, info: log, warn: log, error: log, debug: log, - }); - const context = vm.createContext({ - AbortController, AbortSignal, Buffer, URL, URLSearchParams, - clearImmediate, clearInterval, clearTimeout, console: consoleFacade, - queueMicrotask, setImmediate, setInterval, setTimeout, structuredClone, - }); - const wrappedSource = `(async (tools) => {\n${message.source}\n})`; - const source = stripTypeScriptTypes(wrappedSource, { mode: 'strip' }); - const program = new vm.Script(source, { - filename: 'openbitfun-ptc.ts', - }).runInContext(context); - const value = await program(Object.freeze(tools)); - const result = jsonValue(value); - const logBytes = Buffer.byteLength(stringify(logs), 'utf8'); - if (logBytes > maxOutputBytes) failure('PTC logs exceed output limit', 'output_limit'); - return { value: result, logs }; -} +impl PtcScriptLanguage { + fn candidates(self) -> &'static [&'static str] { + match self { + Self::TypeScript => &["node"], + Self::Python => &["python3", "python"], + } + } -write({ kind: 'ready', token }); -const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); -input.on('line', (line) => { - let message; - try { message = parse(line); } catch { process.exit(1); return; } - if (message.type === 'tool_result') { - const call = pending.get(message.operationId); - if (!call) return; - pending.delete(message.operationId); - if (message.ok) call.resolve(message.value); - else call.reject(new Error(message.error || 'nested tool call failed')); - return; - } - if (message.type !== 'execute') return; - void execute(message).then( - (result) => write({ kind: 'complete', token, id: message.id, ok: true, result }), - (error) => write({ - kind: 'complete', token, id: message.id, ok: false, - errorKind: error?.ptcCode || 'exception', - error: error instanceof Error ? error.message.slice(0, 4096) : 'PTC execution failed', - }), - ); -}); -"#; + fn arguments(self) -> Vec<&'static str> { + match self { + Self::TypeScript => vec!["--input-type=module", "--eval", node::BOOTSTRAP], + Self::Python => vec!["-I", "-u", "-c", include_str!("ptc_python.py")], + } + } + + fn supports_version(self, version: &str) -> bool { + match self { + Self::TypeScript => supports_type_stripping(version), + Self::Python => version.strip_prefix("Python ").is_some_and(|version| { + let mut parts = version + .split('.') + .filter_map(|part| part.parse::().ok()); + matches!((parts.next(), parts.next()), (Some(3), Some(minor)) if minor >= 8) + }), + } + } + + fn requirement(self) -> &'static str { + match self { + Self::TypeScript => "Node.js 22.6 or newer with stripTypeScriptTypes", + Self::Python => "Python 3.8 or newer", + } + } +} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -222,7 +166,9 @@ impl PtcProcess { } } -pub struct NodePtcRuntime { +/// Shared framing, process supervision and nested tool dispatch for all backends. +pub struct ScriptPtcRuntime { + language: PtcScriptLanguage, executable: RwLock>, operations: Mutex>>, #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] @@ -236,16 +182,20 @@ struct RemotePtcTarget { connection_id: String, } -impl Default for NodePtcRuntime { +/// Compatibility name for existing Node-only callers. +pub type NodePtcRuntime = ScriptPtcRuntime; + +impl Default for ScriptPtcRuntime { fn default() -> Self { Self::discover() } } -impl NodePtcRuntime { +impl ScriptPtcRuntime { pub fn discover() -> Self { Self { - executable: RwLock::new(which::which("node").ok()), + language: PtcScriptLanguage::TypeScript, + executable: RwLock::new(None), operations: Mutex::new(HashMap::new()), #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] remote_target: None, @@ -255,6 +205,7 @@ impl NodePtcRuntime { #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] pub fn for_remote(ssh_manager: SSHConnectionManager, connection_id: impl Into) -> Self { Self { + language: PtcScriptLanguage::TypeScript, executable: RwLock::new(None), operations: Mutex::new(HashMap::new()), remote_target: Some(RemotePtcTarget { @@ -264,11 +215,28 @@ impl NodePtcRuntime { } } + pub fn with_language(mut self, language: PtcScriptLanguage) -> Self { + self.language = language; + self.executable = RwLock::new(None); + self + } + async fn executable(&self) -> Option { if let Some(path) = self.executable.read().await.clone() { return Some(path); } - let path = which::which("node").ok()?; + let mut selected = None; + for candidate in self.language.candidates() { + if let Ok(path) = which::which(candidate) { + if let Ok(version) = runtime_executable_version(&path).await { + if self.language.supports_version(&version) { + selected = Some(path); + break; + } + } + } + } + let path = selected?; *self.executable.write().await = Some(path.clone()); Some(path) } @@ -281,9 +249,7 @@ impl NodePtcRuntime { let mut command = openbitfun_services_core::process_manager::create_tokio_command(executable); command - .arg("--input-type=module") - .arg("--eval") - .arg(BOOTSTRAP) + .args(self.language.arguments()) .current_dir(cwd) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -295,7 +261,7 @@ impl NodePtcRuntime { .map_err(|error| { PortError::new( PortErrorKind::NotAvailable, - format!("failed to start Node PTC runtime: {error}"), + format!("failed to start {:?} PTC runtime: {error}", self.language), ) })?; let stdin = child @@ -322,10 +288,22 @@ impl NodePtcRuntime { target: &RemotePtcTarget, cwd: &str, ) -> PortResult<(Arc, BufReader)> { + let (executable, _) = self + .remote_runtime_version(target) + .await + .map_err(|reason| PortError::new(PortErrorKind::NotAvailable, reason))?; + let arguments = self + .language + .arguments() + .iter() + .map(|arg| shell_quote_posix(arg)) + .collect::>() + .join(" "); let command = format!( - "unset NODE_OPTIONS NODE_PATH; cd -- {} && exec node --input-type=module --eval {}", + "unset NODE_OPTIONS NODE_PATH; cd -- {} && exec {} {}", shell_quote_posix(cwd), - shell_quote_posix(BOOTSTRAP), + shell_quote_posix(&executable), + arguments, ); let transport = target .ssh_manager @@ -334,7 +312,10 @@ impl NodePtcRuntime { .map_err(|error| { PortError::new( PortErrorKind::NotAvailable, - format!("failed to start remote Node PTC runtime: {error}"), + format!( + "failed to start remote {:?} PTC runtime: {error}", + self.language + ), ) })?; let (stdin, stdout, mut stderr, control, completion) = transport.into_parts(); @@ -350,55 +331,52 @@ impl NodePtcRuntime { } #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] - async fn remote_node_version(target: &RemotePtcTarget) -> Result { - let result = target - .ssh_manager - .execute_command_with_options( - &target.connection_id, - "node --version", - SSHCommandOptions { - timeout_ms: Some(10_000), - cancellation_token: None, - }, - ) - .await - .map_err(|error| error.to_string())?; - if result.exit_code != 0 { - return Err(if result.stderr.trim().is_empty() { - "remote Node.js version probe failed".to_string() - } else { - result.stderr.trim().to_string() - }); - } - let version = result.stdout.trim().to_string(); - if version.is_empty() { - Err("remote Node.js version probe returned no version".to_string()) - } else { - Ok(version) + async fn remote_runtime_version( + &self, + target: &RemotePtcTarget, + ) -> Result<(String, String), String> { + for executable in self.language.candidates() { + let result = target + .ssh_manager + .execute_command_with_options( + &target.connection_id, + &format!("{} --version", shell_quote_posix(executable)), + SSHCommandOptions { + timeout_ms: Some(10_000), + cancellation_token: None, + }, + ) + .await + .map_err(|error| error.to_string())?; + let version = result.stdout.trim().to_string(); + if result.exit_code == 0 && self.language.supports_version(&version) { + return Ok((executable.to_string(), version)); + } } + Err(format!( + "remote PTC runtime requires {}; no local fallback is allowed", + self.language.requirement() + )) } async fn runtime_version(&self) -> PortResult<(Option, String)> { #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] if let Some(target) = &self.remote_target { - let version = Self::remote_node_version(target).await.map_err(|reason| { - PortError::new( - PortErrorKind::NotAvailable, - format!("remote Node.js PTC runtime is unavailable: {reason}"), - ) - })?; + let (_, version) = self + .remote_runtime_version(target) + .await + .map_err(|reason| PortError::new(PortErrorKind::NotAvailable, reason))?; return Ok((None, version)); } - let executable = self.executable().await.ok_or_else(|| { - PortError::new(PortErrorKind::NotAvailable, "Node.js is not available") - })?; - let version = node_version(&executable).await.map_err(|reason| { PortError::new( PortErrorKind::NotAvailable, - format!("Node.js PTC runtime is unavailable: {reason}"), + format!("PTC runtime requires {}", self.language.requirement()), ) })?; + let version = runtime_executable_version(&executable) + .await + .map_err(|reason| PortError::new(PortErrorKind::NotAvailable, reason))?; Ok((Some(executable), version)) } @@ -431,11 +409,12 @@ impl NodePtcRuntime { )); } let (executable, version) = self.runtime_version().await?; - if !supports_type_stripping(&version) { + if !self.language.supports_version(&version) { return Err(PortError::new( PortErrorKind::NotAvailable, format!( - "Node.js {version} does not provide stripTypeScriptTypes (requires Node.js 22.6 or newer)" + "PTC runtime {version} requires {}", + self.language.requirement() ), )); } @@ -446,6 +425,7 @@ impl NodePtcRuntime { .lock() .await .insert(request.operation_id.clone(), process.clone()); + let mut nested_calls = tokio::task::JoinSet::new(); let result = async { let ready = timeout(STARTUP_TIMEOUT, read_frame(&mut reader)) .await @@ -460,6 +440,7 @@ impl NodePtcRuntime { })).await?; let allowed = request.bindings.iter().map(|binding| binding.name.clone()).collect::>(); loop { + while nested_calls.try_join_next().is_some() {} let frame = read_frame(&mut reader).await?; if frame.token != ready.token { continue; } if frame.kind == "tool_call" { @@ -471,7 +452,7 @@ impl NodePtcRuntime { } let child_process = process.clone(); let handler = handler.clone(); - tokio::spawn(async move { + nested_calls.spawn(async move { let result = handler.call(PtcNestedToolCall { operation_id: operation_id.clone(), tool_name, arguments: frame.arguments }).await; let payload = match result { Ok(value) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":true,"value":value}), @@ -486,7 +467,7 @@ impl NodePtcRuntime { let tool_name = frame.tool_name.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool spec request has no tool name"))?; let child_process = process.clone(); let handler = handler.clone(); - tokio::spawn(async move { + nested_calls.spawn(async move { let result = handler .get_tool_spec(PtcToolSpecRequest { operation_id: operation_id.clone(), tool_name }) .await; @@ -511,6 +492,7 @@ impl NodePtcRuntime { } } }.await; + nested_calls.abort_all(); self.operations.lock().await.remove(&request.operation_id); process.terminate().await; result @@ -555,14 +537,15 @@ where }) } -async fn node_version(executable: &PathBuf) -> Result { - let output = openbitfun_services_core::process_manager::create_tokio_command(executable) - .arg("--version") - .output() +async fn runtime_executable_version(executable: &PathBuf) -> Result { + let mut command = openbitfun_services_core::process_manager::create_tokio_command(executable); + command.arg("--version").kill_on_drop(true); + let output = timeout(STARTUP_TIMEOUT, command.output()) .await + .map_err(|_| "PTC runtime version probe timed out".to_string())? .map_err(|error| error.to_string())?; if !output.status.success() { - return Err("Node.js version probe failed".to_string()); + return Err("PTC runtime version probe failed".to_string()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } @@ -580,24 +563,25 @@ fn supports_type_stripping(version: &str) -> bool { } #[async_trait] -impl PtcRuntime for NodePtcRuntime { +impl PtcRuntime for ScriptPtcRuntime { fn capability(&self) -> RuntimeServiceCapability { RuntimeServiceCapability::PtcRuntime } async fn availability(&self) -> PtcRuntimeAvailability { match self.runtime_version().await { - Ok((executable, version)) if supports_type_stripping(&version) => { + Ok((executable, version)) if self.language.supports_version(&version) => { PtcRuntimeAvailability::Available { executable: executable .map(|path| path.to_string_lossy().into_owned()) - .unwrap_or_else(|| "node (remote workspace)".to_string()), + .unwrap_or_else(|| format!("{:?} (remote workspace)", self.language)), version, } } Ok((_, version)) => PtcRuntimeAvailability::Unavailable { reason: format!( - "Node.js {version} does not provide stripTypeScriptTypes (requires Node.js 22.6 or newer)" + "PTC runtime {version} requires {}", + self.language.requirement() ), }, Err(error) => PtcRuntimeAvailability::Unavailable { @@ -645,7 +629,7 @@ impl PtcRuntime for NodePtcRuntime { #[cfg(test)] mod tests { - use super::{supports_type_stripping, NodePtcRuntime}; + use super::{supports_type_stripping, PtcScriptLanguage, ScriptPtcRuntime}; use async_trait::async_trait; use openbitfun_runtime_ports::{ PtcExecutionRequest, PtcNestedToolCall, PtcRuntime, PtcToolBinding, PtcToolCallHandler, @@ -687,7 +671,7 @@ mod tests { #[tokio::test] async fn executes_program_and_dispatches_nested_tool_call_when_node_is_available() { - let runtime = NodePtcRuntime::discover(); + let runtime = ScriptPtcRuntime::discover(); if !matches!( runtime.availability().await, openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } @@ -715,7 +699,7 @@ mod tests { #[tokio::test] async fn exposes_ptc_spec_loading_and_generic_call_api() { - let runtime = NodePtcRuntime::discover(); + let runtime = ScriptPtcRuntime::discover(); if !matches!( runtime.availability().await, openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } @@ -741,7 +725,7 @@ mod tests { #[tokio::test] async fn timeout_terminates_a_long_running_program() { - let runtime = NodePtcRuntime::discover(); + let runtime = ScriptPtcRuntime::discover(); if !matches!( runtime.availability().await, openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } @@ -767,4 +751,271 @@ mod tests { .expect_err("long-running PTC program should time out"); assert_eq!(error.kind, openbitfun_runtime_ports::PortErrorKind::Timeout); } + + fn python_request(source: &str) -> PtcExecutionRequest { + PtcExecutionRequest { + operation_id: "python-test".to_string(), + source: source.to_string(), + bindings: vec![PtcToolBinding { + name: "Read".to_string(), + }], + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + } + } + + async fn python_runtime() -> Option { + let runtime = ScriptPtcRuntime::discover().with_language(PtcScriptLanguage::Python); + if let openbitfun_runtime_ports::PtcRuntimeAvailability::Unavailable { reason } = + runtime.availability().await + { + eprintln!("Skipping Python PTC test: {reason}"); + return None; + } + Some(runtime) + } + + #[test] + fn python_version_requirement_is_independent_of_node() { + assert!(PtcScriptLanguage::Python.supports_version("Python 3.8.0")); + assert!(PtcScriptLanguage::Python.supports_version("Python 3.13.1")); + assert!(!PtcScriptLanguage::Python.supports_version("Python 3.7.9")); + assert!(!PtcScriptLanguage::Python.supports_version("v24.0.0")); + assert!(!PtcScriptLanguage::Python.supports_version("garbage")); + } + + #[tokio::test] + async fn python_dispatches_concurrent_calls_and_captures_unicode_logs() { + let Some(runtime) = python_runtime().await else { + return; + }; + let response = runtime.execute(python_request( + "import asyncio\nprint('hello \\u4e16\\u754c')\nresults = await asyncio.gather(tools.Read({'path': 'a.py'}), tools['Read']({'path': 'b.py'}))\nreturn results" + ), Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value[0]["arguments"]["path"], "a.py"); + assert_eq!(response.value[1]["arguments"]["path"], "b.py"); + assert_eq!(response.logs, vec!["hello \u{4e16}\u{754c}"]); + } + + #[tokio::test] + async fn python_loads_deferred_specs_and_calls_by_name() { + let Some(runtime) = python_runtime().await else { + return; + }; + let response = runtime.execute(python_request( + "spec = await tools.getToolSpec('Deferred')\nresult = await tools.callTool('Deferred', {'value': 1})\nreturn {'spec': spec, 'result': result}" + ), Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["spec"]["tool_name"], "Deferred"); + assert_eq!(response.value["result"]["arguments"]["value"], 1); + } + + #[tokio::test] + async fn python_reports_exceptions_and_rejects_unbound_tools() { + let Some(runtime) = python_runtime().await else { + return; + }; + for source in [ + "raise ValueError('failure')", + "return await tools.Unbound({})", + "return await tools.Read([])", + "return float('nan')", + "return (", + ] { + let error = runtime + .execute(python_request(source), Arc::new(Handler)) + .await + .unwrap_err(); + assert_eq!( + error.kind, + openbitfun_runtime_ports::PortErrorKind::Backend, + "{source}" + ); + } + } + + #[tokio::test] + async fn python_enforces_result_and_log_limits() { + let Some(runtime) = python_runtime().await else { + return; + }; + for source in ["return 'x' * 1000", "print('x' * 1000)\nreturn None"] { + let mut request = python_request(source); + request.max_output_bytes = Some(100); + let error = runtime + .execute(request, Arc::new(Handler)) + .await + .unwrap_err(); + assert_eq!( + error.kind, + openbitfun_runtime_ports::PortErrorKind::InvalidRequest + ); + } + } + + #[tokio::test] + async fn python_timeout_terminates_cpu_bound_program() { + let Some(runtime) = python_runtime().await else { + return; + }; + let mut request = python_request("while True:\n pass"); + request.timeout_ms = Some(500); + let error = runtime + .execute(request, Arc::new(Handler)) + .await + .unwrap_err(); + assert_eq!(error.kind, openbitfun_runtime_ports::PortErrorKind::Timeout); + assert!(runtime.operations.lock().await.is_empty()); + } + + #[tokio::test] + async fn python_cancellation_terminates_running_program() { + let Some(runtime) = python_runtime().await else { + return; + }; + let request = python_request("import asyncio\nawait asyncio.sleep(60)"); + let operation_id = request.operation_id.clone(); + let runtime = Arc::new(runtime); + let execution = { + let runtime = runtime.clone(); + tokio::spawn(async move { runtime.execute(request, Arc::new(Handler)).await }) + }; + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while runtime.operations.lock().await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + runtime.cancel(&operation_id).await.unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .is_err() + ); + assert!(runtime.operations.lock().await.is_empty()); + } + + #[tokio::test] + async fn python_protocol_uses_utf8_for_source_and_nested_results() { + let Some(runtime) = python_runtime().await else { + return; + }; + let response = runtime + .execute( + python_request("return await tools.Read({'path': '\u{4e16}\u{754c}.py'})"), + Arc::new(Handler), + ) + .await + .unwrap(); + assert_eq!(response.value["arguments"]["path"], "\u{4e16}\u{754c}.py"); + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + #[tokio::test] + async fn remote_python_never_falls_back_to_local_runtime() { + let data = tempfile::tempdir().unwrap(); + let manager = crate::remote_ssh::SSHConnectionManager::new(data.path().to_path_buf()); + let runtime = ScriptPtcRuntime::for_remote(manager, "offline-test") + .with_language(PtcScriptLanguage::Python); + assert!(matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Unavailable { .. } + )); + let mut request = python_request("return 'must not run locally'"); + request.working_directory = "/remote workspace".to_string(); + let error = runtime + .execute(request, Arc::new(Handler)) + .await + .unwrap_err(); + assert_eq!( + error.kind, + openbitfun_runtime_ports::PortErrorKind::NotAvailable + ); + assert!(runtime.executable.read().await.is_none()); + assert!(runtime.operations.lock().await.is_empty()); + } + + struct DeniedHandler; + + #[async_trait] + impl PtcToolCallHandler for DeniedHandler { + async fn call( + &self, + _request: PtcNestedToolCall, + ) -> openbitfun_runtime_ports::PortResult { + Err(openbitfun_runtime_ports::PortError::new( + openbitfun_runtime_ports::PortErrorKind::PermissionDenied, + "permission denied by host", + )) + } + } + + #[tokio::test] + async fn python_propagates_nested_permission_denial() { + let Some(runtime) = python_runtime().await else { + return; + }; + let response = runtime.execute(python_request( + "try:\n await tools.Read({})\nexcept RuntimeError as error:\n return str(error)" + ), Arc::new(DeniedHandler)).await.unwrap(); + assert_eq!(response.value, "permission denied by host"); + } + + struct PendingHandler { + started: tokio::sync::Notify, + dropped: Arc, + } + + struct PendingCallGuard(Arc); + impl Drop for PendingCallGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + #[async_trait] + impl PtcToolCallHandler for PendingHandler { + async fn call( + &self, + request: PtcNestedToolCall, + ) -> openbitfun_runtime_ports::PortResult { + if request.tool_name == "Wait" { + let _guard = PendingCallGuard(self.dropped.clone()); + self.started.notify_one(); + std::future::pending().await + } else { + self.started.notified().await; + Ok(Value::Null) + } + } + } + + #[tokio::test] + async fn python_completion_cancels_pending_nested_calls() { + let Some(runtime) = python_runtime().await else { + return; + }; + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let handler = Arc::new(PendingHandler { + started: tokio::sync::Notify::new(), + dropped: dropped.clone(), + }); + runtime.execute(python_request( + "import asyncio\nasyncio.create_task(tools.callTool('Wait', {}))\nawait tools.Read({})\nreturn None" + ), handler).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !dropped.load(std::sync::atomic::Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("nested handler future should be dropped when the program ends"); + } } diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index e5ac8d6035..4b4fc6568f 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -8112,8 +8112,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "titleEn": "Choose native mode or PTC TypeScript / Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -8334,8 +8334,8 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript." + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript / Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript / Python." }, { "id": "subagent-batch-policy", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 7748f32fce..1f95671bdd 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d44141ce2d62548faf2f081b7319fc529b5ed791891a68cd6733dcd4341508c1", + "digest": "44e81d200d46b1ffb031427d4812fbcaa388aa556d82408fa78f8e6193fdcf22", "ownerDigest": "df28e5eed6e3190741899ebf236bc5fb426d2469187e437839a349a7136c7c34", "searchAcceptance": [ { @@ -29795,8 +29795,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "titleEn": "Choose native mode or PTC TypeScript / Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -29992,8 +29992,8 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript / Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript / Python.", "valueSchema": { "type": "string", "enum": [ @@ -30129,8 +30129,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", - "选择模型使用原生模式还是 PTC TypeScript 模式", - "Choose native mode or PTC TypeScript mode for the model", + "选择模型使用原生模式还是 PTC TypeScript / Python 模式", + "Choose native mode or PTC TypeScript / Python mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 033ad7e0ef..e1ca68bdcb 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "d44141ce2d62548faf2f081b7319fc529b5ed791891a68cd6733dcd4341508c1" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "44e81d200d46b1ffb031427d4812fbcaa388aa556d82408fa78f8e6193fdcf22" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "feature.desktop-cards" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/locales/en-US/scenes/capabilities.json b/src/web-ui/src/locales/en-US/scenes/capabilities.json index 1aab22cf6c..172b76a92d 100644 --- a/src/web-ui/src/locales/en-US/scenes/capabilities.json +++ b/src/web-ui/src/locales/en-US/scenes/capabilities.json @@ -530,7 +530,7 @@ "global-project-rules": "Create, reorder, and save global or project-level Allow, Ask, and Deny rules", "remembered-grants": "Review, revoke, or clear remembered project permission grants and audit records", "deferred-tools": "Deferred tool loading on demand to reduce initial agent context", - "tool-presentation": "Choose native mode or PTC TypeScript mode for the model", + "tool-presentation": "Choose native mode or PTC TypeScript / Python mode for the model", "parallelism": "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "timeouts": "Set or disable the per-tool execution timeout", "review-capacity": "Set the maximum parallel Deep Review workers and queue wait time", diff --git a/src/web-ui/src/locales/en-US/settings/runtime.json b/src/web-ui/src/locales/en-US/settings/runtime.json index 0091b280a1..3c920e0e2f 100644 --- a/src/web-ui/src/locales/en-US/settings/runtime.json +++ b/src/web-ui/src/locales/en-US/settings/runtime.json @@ -100,8 +100,8 @@ "mode": "Tool presentation", "description": "Choose how tools are exposed to the model. This setting applies only to sessions created after it changes.", "native": "Native tools", - "ptc": "PTC (TypeScript)", - "ptcTooltip": "PTC executes TypeScript through RunCode." + "ptc": "PTC (TypeScript / Python)", + "ptcTooltip": "PTC executes TypeScript or Python through RunCode. Install the selected language runtime on the execution host." }, "projectPermissions": { "title": "Tool and command permissions", diff --git a/src/web-ui/src/locales/zh-CN/scenes/capabilities.json b/src/web-ui/src/locales/zh-CN/scenes/capabilities.json index 535c42e816..4c19535cfd 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/capabilities.json +++ b/src/web-ui/src/locales/zh-CN/scenes/capabilities.json @@ -530,7 +530,7 @@ "global-project-rules": "创建、排序和保存全局或项目级 Allow、Ask、Deny 规则", "remembered-grants": "查看、撤销或清空项目中记住的权限授权与审计记录", "deferred-tools": "按需延迟加载工具定义以减少智能体初始上下文", - "tool-presentation": "选择模型使用原生模式还是 PTC TypeScript 模式", + "tool-presentation": "选择模型使用原生模式还是 PTC TypeScript / Python 模式", "parallelism": "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "timeouts": "设置或取消单次工具调用超时", "review-capacity": "设置 Deep Review 最大并行评审者和队列等待时间", diff --git a/src/web-ui/src/locales/zh-CN/settings/runtime.json b/src/web-ui/src/locales/zh-CN/settings/runtime.json index d1c9e23084..18c3184104 100644 --- a/src/web-ui/src/locales/zh-CN/settings/runtime.json +++ b/src/web-ui/src/locales/zh-CN/settings/runtime.json @@ -100,8 +100,8 @@ "mode": "工具呈现模式", "description": "选择模型看到工具的方式,只对切换设置后新建的会话生效。", "native": "原生工具", - "ptc": "PTC(TypeScript)", - "ptcTooltip": "PTC 通过 RunCode 执行 TypeScript。" + "ptc": "PTC(TypeScript / Python)", + "ptcTooltip": "PTC 通过 RunCode 执行 TypeScript 或 Python,需在执行主机安装对应运行时。" }, "projectPermissions": { "title": "工具与命令权限", diff --git a/src/web-ui/src/locales/zh-TW/scenes/capabilities.json b/src/web-ui/src/locales/zh-TW/scenes/capabilities.json index 709f892571..239ca954ac 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/capabilities.json +++ b/src/web-ui/src/locales/zh-TW/scenes/capabilities.json @@ -530,7 +530,7 @@ "global-project-rules": "創建、排序和保存全局或項目級 Allow、Ask、Deny 規則", "remembered-grants": "查看、撤銷或清空項目中記住的權限授權與審計記錄", "deferred-tools": "按需延遲加載工具定義以減少智能體初始上下文", - "tool-presentation": "選擇模型使用原生模式或 PTC TypeScript 模式", + "tool-presentation": "選擇模型使用原生模式或 PTC TypeScript / Python 模式", "parallelism": "設定子智能體與 Swarm 最大並行及安全、強制並行或串行策略", "timeouts": "設定或取消單次工具調用超時", "review-capacity": "設定 Deep Review 最大並行評審者和隊列等待時間", diff --git a/src/web-ui/src/locales/zh-TW/settings/runtime.json b/src/web-ui/src/locales/zh-TW/settings/runtime.json index dfef0e4c83..c1b5e64fa5 100644 --- a/src/web-ui/src/locales/zh-TW/settings/runtime.json +++ b/src/web-ui/src/locales/zh-TW/settings/runtime.json @@ -100,8 +100,8 @@ "mode": "工具呈現模式", "description": "選擇模型看到工具的方式,只對切換設定後新建的工作階段生效。", "native": "原生工具", - "ptc": "PTC(TypeScript)", - "ptcTooltip": "PTC 透過 RunCode 執行 TypeScript。" + "ptc": "PTC(TypeScript / Python)", + "ptcTooltip": "PTC 透過 RunCode 執行 TypeScript 或 Python,需在執行主機安裝對應執行環境。" }, "projectPermissions": { "title": "工具與命令權限",