pal-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional
|
|
2
|
+
|
|
3
|
+
from pal.access_manager.components.base import AccessDecision, OperationType
|
|
4
|
+
from pal.access_manager.pipeline import AccessManagerPipeline
|
|
5
|
+
from pal.llm_wrapper.components.base import LLMSyscall, LLMWrapper
|
|
6
|
+
from pal.orchestrator.components.agent import AgentConfig
|
|
7
|
+
from pal.orchestrator.components.cgroup_manager import CgroupManager, create_resource_manager
|
|
8
|
+
from pal.orchestrator.components.scheduler import Scheduler
|
|
9
|
+
from pal.orchestrator.components.syscall_scheduler import SchedulingStrategy, SyscallRequest, SyscallScheduler
|
|
10
|
+
from pal.orchestrator.utils.device import ToolDevice
|
|
11
|
+
from pal.storage_manager.components.base import StorageSyscall
|
|
12
|
+
from pal.storage_manager.components.storage_queue import StorageRequest
|
|
13
|
+
from pal.storage_manager.pipeline import StorageManagerPipeline
|
|
14
|
+
from pal.storage_manager.utils.sdk import StorageSDK
|
|
15
|
+
from pal.tool_manager.components.base import ToolCallResult, ToolParam
|
|
16
|
+
from pal.tool_manager.components.tool_call_queue import QueuedToolCall
|
|
17
|
+
from pal.tool_manager.pipeline import ToolManagerPipeline
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pal.memory.pipeline import MemoryPipeline
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OrchestratorPipeline:
|
|
24
|
+
"""
|
|
25
|
+
Top-level entry point for agent orchestration: submit agent configs,
|
|
26
|
+
admit them onto a bounded number of concurrent cgroup-isolated
|
|
27
|
+
processes, and optionally expose a tool device those agent
|
|
28
|
+
processes can call back into.
|
|
29
|
+
|
|
30
|
+
Also owns the centralized syscall queue (SyscallScheduler): every
|
|
31
|
+
LLM inference request in the system, from any module, is meant to
|
|
32
|
+
go through submit_syscall() here rather than each caller hitting an
|
|
33
|
+
LLMWrapper directly -- that's what "centralize all queues within the
|
|
34
|
+
scheduler module" means in practice (AIOS 3.3).
|
|
35
|
+
|
|
36
|
+
And owns the Storage Manager (AIOS 3.6): one StorageManagerPipeline
|
|
37
|
+
shared by every agent/pipeline in the system, for both the memory
|
|
38
|
+
manager's RAM-to-disk swaps (see pal/memory/pipeline.py's
|
|
39
|
+
storage_manager= parameter) and an agent's own direct storage SDK
|
|
40
|
+
calls (storage_sdk_for()).
|
|
41
|
+
|
|
42
|
+
And owns the Tool Manager (AIOS 3.7): one ToolManagerPipeline
|
|
43
|
+
(standardized loading, parameter validation, parallel-access
|
|
44
|
+
conflict resolution), wired into the ToolDevice transport so every
|
|
45
|
+
tool call an agent makes over the socket gets the same treatment
|
|
46
|
+
regardless of which agent or which tool.
|
|
47
|
+
|
|
48
|
+
And owns the Access Manager (AIOS 3.8): one AccessManagerPipeline,
|
|
49
|
+
wired into the StorageManagerPipeline so every cross-agent storage
|
|
50
|
+
request (StorageSDK.read_from()/write_to()/delete_from()) is
|
|
51
|
+
checked against a privilege-group hashmap, and every destructive
|
|
52
|
+
operation (delete, overwrite) goes through user intervention.
|
|
53
|
+
Agents are assigned a privilege group via assign_privilege_group()
|
|
54
|
+
or submit_agent(..., privilege_group=...).
|
|
55
|
+
|
|
56
|
+
And provides the AIOS SDK (AIOS 3.9) via sdk_for(agent_id,
|
|
57
|
+
core_config): one AgentSDK per agent, wired to every manager above,
|
|
58
|
+
which is the actual interface an agent developer builds against --
|
|
59
|
+
chat()/remember()/recall()/read()/write()/call_tool()/etc. -- rather
|
|
60
|
+
than any manager or syscall type directly.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
parent_slice: str = "pal.slice",
|
|
66
|
+
max_concurrent: int = 3,
|
|
67
|
+
tool_socket_path: str = None, # a Unix socket path, or host:port (used on Windows)
|
|
68
|
+
syscall_strategy: SchedulingStrategy = SchedulingStrategy.FIFO,
|
|
69
|
+
rr_quantum: float = 2.0,
|
|
70
|
+
storage_root: str = None,
|
|
71
|
+
vector_persist_directory: str = None,
|
|
72
|
+
confirmation_handler=None,
|
|
73
|
+
resource_manager=None,
|
|
74
|
+
):
|
|
75
|
+
self.cgroup_manager = resource_manager or create_resource_manager(parent_slice, manager_cls=CgroupManager)
|
|
76
|
+
self.scheduler = Scheduler(self.cgroup_manager, max_concurrent=max_concurrent)
|
|
77
|
+
self.syscall_scheduler = SyscallScheduler(strategy=syscall_strategy, rr_quantum=rr_quantum)
|
|
78
|
+
self.access_manager = AccessManagerPipeline(confirmation_handler=confirmation_handler)
|
|
79
|
+
self.storage_manager = StorageManagerPipeline(
|
|
80
|
+
root=storage_root, vector_persist_directory=vector_persist_directory, access_manager=self.access_manager
|
|
81
|
+
)
|
|
82
|
+
self.tool_manager = ToolManagerPipeline()
|
|
83
|
+
self.tool_device = ToolDevice(tool_socket_path, tool_manager=self.tool_manager) if tool_socket_path \
|
|
84
|
+
else ToolDevice(tool_manager=self.tool_manager)
|
|
85
|
+
|
|
86
|
+
def submit_agent(self, config: AgentConfig, priority: int = 0, weight: int = 100, privilege_group: str = None) -> None:
|
|
87
|
+
self.scheduler.submit(config, priority=priority, weight=weight)
|
|
88
|
+
if privilege_group is not None:
|
|
89
|
+
self.access_manager.assign_privilege_group(config.name, privilege_group)
|
|
90
|
+
|
|
91
|
+
def tick(self) -> list[str]:
|
|
92
|
+
return self.scheduler.tick()
|
|
93
|
+
|
|
94
|
+
def run_turn(self, agent_name: str, system_prompt: str, user_message: str, timeout: float = 60) -> dict:
|
|
95
|
+
agent = self.scheduler.running.get(agent_name)
|
|
96
|
+
if agent is None:
|
|
97
|
+
raise ValueError(f"agent {agent_name} is not running -- submit + tick first")
|
|
98
|
+
return agent.run_turn(system_prompt, user_message, timeout=timeout)
|
|
99
|
+
|
|
100
|
+
# -- centralized syscall scheduling (AIOS 3.3) -----------------------------
|
|
101
|
+
|
|
102
|
+
def submit_syscall(self, agent_name: str, core: LLMWrapper, syscall: LLMSyscall, priority: int = 0) -> str:
|
|
103
|
+
"""Every LLM inference request funnels through here rather than
|
|
104
|
+
callers hitting core.chat()/core.stream_chat() directly, so
|
|
105
|
+
request ordering and time-slicing are handled in exactly one
|
|
106
|
+
place regardless of which module -- an Agent, the top-level
|
|
107
|
+
Pipeline, anything else -- issued the request."""
|
|
108
|
+
return self.syscall_scheduler.submit(agent_name, core, syscall, priority=priority)
|
|
109
|
+
|
|
110
|
+
def syscall_tick(self) -> SyscallRequest | None:
|
|
111
|
+
"""Advances the syscall queue by one step, using whichever
|
|
112
|
+
strategy this orchestrator was configured with (FIFO or Round
|
|
113
|
+
Robin)."""
|
|
114
|
+
return self.syscall_scheduler.tick()
|
|
115
|
+
|
|
116
|
+
def drain_syscalls(self, max_ticks: int = 10_000) -> list[SyscallRequest]:
|
|
117
|
+
return self.syscall_scheduler.drain(max_ticks=max_ticks)
|
|
118
|
+
|
|
119
|
+
def syscall_result(self, request_id: str) -> SyscallRequest:
|
|
120
|
+
return self.syscall_scheduler.get_result(request_id)
|
|
121
|
+
|
|
122
|
+
# -- Storage Manager (AIOS 3.6) --------------------------------------------
|
|
123
|
+
|
|
124
|
+
def storage_sdk_for(self, agent_id: str) -> StorageSDK:
|
|
125
|
+
"""What an agent calls to read/write its own persistent data --
|
|
126
|
+
"the agent calls the storage API in the SDK, which is further
|
|
127
|
+
converted into storage-related system calls and put into the
|
|
128
|
+
storage queue." Every StorageSDK instance shares this
|
|
129
|
+
orchestrator's one StorageManagerPipeline/queue, scoped by
|
|
130
|
+
agent_id."""
|
|
131
|
+
return StorageSDK(agent_id, self.storage_manager)
|
|
132
|
+
|
|
133
|
+
def submit_storage_syscall(self, syscall: StorageSyscall) -> str:
|
|
134
|
+
return self.storage_manager.submit(syscall)
|
|
135
|
+
|
|
136
|
+
def storage_tick(self) -> StorageRequest | None:
|
|
137
|
+
return self.storage_manager.tick()
|
|
138
|
+
|
|
139
|
+
def drain_storage_syscalls(self, max_iterations: int = 10_000) -> list[StorageRequest]:
|
|
140
|
+
return self.storage_manager.drain(max_iterations=max_iterations)
|
|
141
|
+
|
|
142
|
+
def storage_result(self, request_id: str) -> StorageRequest:
|
|
143
|
+
return self.storage_manager.get_result(request_id)
|
|
144
|
+
|
|
145
|
+
# -- Access Manager (AIOS 3.8) ----------------------------------------------
|
|
146
|
+
|
|
147
|
+
def assign_privilege_group(self, agent_id: str, group: str) -> None:
|
|
148
|
+
self.access_manager.assign_privilege_group(agent_id, group)
|
|
149
|
+
|
|
150
|
+
def set_confirmation_handler(self, handler) -> None:
|
|
151
|
+
"""Registers the callback that answers user-intervention
|
|
152
|
+
prompts for irreversible operations (delete, overwrite,
|
|
153
|
+
privilege modification). The default handler denies everything,
|
|
154
|
+
so a real application needs to set one -- e.g. a CLI y/n prompt
|
|
155
|
+
or a UI confirmation dialog."""
|
|
156
|
+
self.access_manager.set_confirmation_handler(handler)
|
|
157
|
+
|
|
158
|
+
def check_access(self, requesting_agent_id: str, target_agent_id: str, operation: OperationType, resource: str = "") -> AccessDecision:
|
|
159
|
+
return self.access_manager.check_access(requesting_agent_id, target_agent_id, operation, resource=resource)
|
|
160
|
+
|
|
161
|
+
def register_tool(self, name: str, loader, params: list[ToolParam] = None,
|
|
162
|
+
parallel_limit: int = None, dependencies: list[str] = None) -> None:
|
|
163
|
+
"""Registers a tool with the Tool Manager (AIOS 3.7): `loader`
|
|
164
|
+
is called at most once, the first time this tool is actually
|
|
165
|
+
invoked (dynamic loading -- see ToolRegistry.get_callable).
|
|
166
|
+
Reachable both via the ToolDevice socket transport and directly
|
|
167
|
+
through invoke_tool() below."""
|
|
168
|
+
self.tool_device.register_tool(
|
|
169
|
+
name, loader, params=params, parallel_limit=parallel_limit, dependencies=dependencies
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def invoke_tool(self, agent_id: str, tool_name: str, args: dict) -> ToolCallResult:
|
|
173
|
+
return self.tool_manager.invoke(agent_id, tool_name, args)
|
|
174
|
+
|
|
175
|
+
def submit_tool_call(self, agent_id: str, tool_name: str, args: dict) -> str:
|
|
176
|
+
return self.tool_manager.submit(agent_id, tool_name, args)
|
|
177
|
+
|
|
178
|
+
def dispatch_tool_call(self) -> QueuedToolCall | None:
|
|
179
|
+
return self.tool_manager.dispatch_next()
|
|
180
|
+
|
|
181
|
+
def drain_tool_calls(self, max_iterations: int = 10_000) -> list[QueuedToolCall]:
|
|
182
|
+
return self.tool_manager.drain(max_iterations=max_iterations)
|
|
183
|
+
|
|
184
|
+
# -- AIOS SDK (AIOS 3.9) -----------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def sdk_for(self, agent_id: str, core_config, memory: Optional["MemoryPipeline"] = None):
|
|
187
|
+
"""Builds an AgentSDK for one agent, wired to this
|
|
188
|
+
orchestrator's shared managers (storage, tools, access control)
|
|
189
|
+
and centralized LLM syscall scheduler -- the single object an
|
|
190
|
+
agent developer actually interacts with, abstracting away every
|
|
191
|
+
manager and syscall type above it. `core_config` is
|
|
192
|
+
agent-specific (different agents may run against different
|
|
193
|
+
backends/models), everything else is shared."""
|
|
194
|
+
from pal.llm_wrapper.pipeline import LLMWrapperPipeline
|
|
195
|
+
from pal.sdk.pipeline import AgentSDK
|
|
196
|
+
|
|
197
|
+
llm_wrapper = LLMWrapperPipeline(core_config)
|
|
198
|
+
return AgentSDK(
|
|
199
|
+
agent_id=agent_id,
|
|
200
|
+
llm_wrapper=llm_wrapper,
|
|
201
|
+
storage_manager=self.storage_manager,
|
|
202
|
+
tool_manager=self.tool_manager,
|
|
203
|
+
access_manager=self.access_manager,
|
|
204
|
+
memory=memory,
|
|
205
|
+
syscall_scheduler=self.syscall_scheduler,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def register_builtin_tools(self) -> None:
|
|
209
|
+
"""Opt-in: registers the SDK's small built-in tool catalog
|
|
210
|
+
(pal/sdk/components/tool_catalog.py) with this
|
|
211
|
+
orchestrator's ToolManagerPipeline. Not called automatically --
|
|
212
|
+
a fresh OrchestratorPipeline starts with no tools registered,
|
|
213
|
+
exactly as before this existed; call this explicitly to make
|
|
214
|
+
the built-in catalog available."""
|
|
215
|
+
from pal.sdk.components.tool_catalog import register_builtin_tools
|
|
216
|
+
register_builtin_tools(self.tool_manager)
|
|
217
|
+
|
|
218
|
+
def start_tool_device(self) -> None:
|
|
219
|
+
self.tool_device.start()
|
|
220
|
+
|
|
221
|
+
def stop_tool_device(self) -> None:
|
|
222
|
+
self.tool_device.stop()
|
|
223
|
+
|
|
224
|
+
def status(self) -> dict:
|
|
225
|
+
return self.scheduler.status()
|
|
226
|
+
|
|
227
|
+
def stop_all(self) -> None:
|
|
228
|
+
self.scheduler.stop_all()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tool transport between agent processes and the orchestrator.
|
|
2
|
+
|
|
3
|
+
Portable: a Unix domain socket where the OS has one (Linux, macOS), a loopback TCP socket where it
|
|
4
|
+
does not (Windows). Requests and responses are one JSON object per line, so a message of any size
|
|
5
|
+
is read completely (the old single recv() truncated anything over 64 KB). The Unix socket file is
|
|
6
|
+
created owner-only (0600); the TCP fallback binds 127.0.0.1 only.
|
|
7
|
+
|
|
8
|
+
Stands in for a future /dev/pal-tools character device: porting later means swapping this
|
|
9
|
+
transport, not the protocol or how tool calls are handled.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from pal.orchestrator.utils.protocol import ToolRequest, ToolResponse
|
|
23
|
+
from pal.tool_manager.pipeline import ToolManagerPipeline
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
HAS_UNIX_SOCKETS = hasattr(socket, "AF_UNIX")
|
|
28
|
+
DEFAULT_SOCKET_PATH = (
|
|
29
|
+
os.path.join(tempfile.gettempdir(), "pal-tools.sock") if HAS_UNIX_SOCKETS else "127.0.0.1:47613")
|
|
30
|
+
MAX_MESSAGE_BYTES = 1_000_000
|
|
31
|
+
_TCP = re.compile(r"^[A-Za-z0-9.\-]+:\d+$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_tcp(address: str) -> bool:
|
|
35
|
+
return bool(_TCP.match(address))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _recv_line(conn: socket.socket) -> bytes:
|
|
39
|
+
buf = bytearray()
|
|
40
|
+
while b"\n" not in buf:
|
|
41
|
+
chunk = conn.recv(65536)
|
|
42
|
+
if not chunk:
|
|
43
|
+
break
|
|
44
|
+
buf += chunk
|
|
45
|
+
if len(buf) > MAX_MESSAGE_BYTES:
|
|
46
|
+
raise ValueError("message too large")
|
|
47
|
+
return bytes(buf)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _connect(address: str, timeout: float) -> socket.socket:
|
|
51
|
+
if _is_tcp(address) or not HAS_UNIX_SOCKETS:
|
|
52
|
+
host, _, port = address.rpartition(":")
|
|
53
|
+
return socket.create_connection((host or "127.0.0.1", int(port)), timeout=timeout)
|
|
54
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
55
|
+
sock.settimeout(timeout)
|
|
56
|
+
sock.connect(address)
|
|
57
|
+
return sock
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ToolDevice:
|
|
61
|
+
"""Agents (in their own OS process) connect here to invoke tools; the orchestrator's process
|
|
62
|
+
executes them, giving one place to apply permissions and auditing per tool call.
|
|
63
|
+
|
|
64
|
+
With a ToolManagerPipeline, register_tool() registers a full ToolSpec (loader, params,
|
|
65
|
+
parallel_limit, dependencies). Without one it falls back to a plain name -> callable dict.
|
|
66
|
+
`address` is a Unix socket path or "host:port"; after start(), `.address` is the real one
|
|
67
|
+
(port 0 picks a free port)."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, address: Optional[str] = None, tool_manager: Optional[ToolManagerPipeline] = None,
|
|
70
|
+
socket_path: Optional[str] = None):
|
|
71
|
+
self.address = address or socket_path or DEFAULT_SOCKET_PATH
|
|
72
|
+
self.tool_manager = tool_manager
|
|
73
|
+
self._handlers: dict[str, Callable[[dict], dict]] = {}
|
|
74
|
+
self._server: Optional[socket.socket] = None
|
|
75
|
+
self._thread: Optional[threading.Thread] = None
|
|
76
|
+
self._running = False
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def socket_path(self) -> str: # backwards-compatible name
|
|
80
|
+
return self.address
|
|
81
|
+
|
|
82
|
+
def register_tool(self, name: str, handler: Callable[[dict], dict], **tool_spec_kwargs) -> None:
|
|
83
|
+
if self.tool_manager is not None:
|
|
84
|
+
self.tool_manager.register_tool(name, handler, **tool_spec_kwargs)
|
|
85
|
+
else:
|
|
86
|
+
self._handlers[name] = handler
|
|
87
|
+
|
|
88
|
+
def start(self) -> None:
|
|
89
|
+
if self._running:
|
|
90
|
+
return
|
|
91
|
+
if _is_tcp(self.address) or not HAS_UNIX_SOCKETS:
|
|
92
|
+
host, _, port = self.address.rpartition(":")
|
|
93
|
+
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
94
|
+
self._server.bind((host or "127.0.0.1", int(port or 0)))
|
|
95
|
+
self.address = f"{self._server.getsockname()[0]}:{self._server.getsockname()[1]}"
|
|
96
|
+
else:
|
|
97
|
+
if os.path.exists(self.address):
|
|
98
|
+
os.remove(self.address)
|
|
99
|
+
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
100
|
+
self._server.bind(self.address)
|
|
101
|
+
os.chmod(self.address, 0o600) # owner only
|
|
102
|
+
self._server.listen(5)
|
|
103
|
+
self._running = True
|
|
104
|
+
self._thread = threading.Thread(target=self._serve_loop, daemon=True)
|
|
105
|
+
self._thread.start()
|
|
106
|
+
|
|
107
|
+
def _serve_loop(self) -> None:
|
|
108
|
+
while self._running:
|
|
109
|
+
try:
|
|
110
|
+
self._server.settimeout(0.5)
|
|
111
|
+
conn, _ = self._server.accept()
|
|
112
|
+
except TimeoutError:
|
|
113
|
+
continue
|
|
114
|
+
except OSError:
|
|
115
|
+
break
|
|
116
|
+
threading.Thread(target=self._handle_conn, args=(conn,), daemon=True).start()
|
|
117
|
+
|
|
118
|
+
def _handle_conn(self, conn: socket.socket) -> None:
|
|
119
|
+
with conn:
|
|
120
|
+
conn.settimeout(30)
|
|
121
|
+
try:
|
|
122
|
+
raw = _recv_line(conn)
|
|
123
|
+
if not raw:
|
|
124
|
+
return
|
|
125
|
+
req = ToolRequest.decode(raw)
|
|
126
|
+
if self.tool_manager is not None:
|
|
127
|
+
result = self.tool_manager.invoke(req.agent_name, req.tool_name, req.args)
|
|
128
|
+
resp = ToolResponse(ok=result.ok, result=result.result, error=result.error)
|
|
129
|
+
else:
|
|
130
|
+
handler = self._handlers.get(req.tool_name)
|
|
131
|
+
if handler is None:
|
|
132
|
+
resp = ToolResponse(ok=False, error=f"unknown tool: {req.tool_name}")
|
|
133
|
+
else:
|
|
134
|
+
resp = ToolResponse(ok=True, result=handler(req.args))
|
|
135
|
+
except Exception as e: # noqa: BLE001 (any failure becomes an error response)
|
|
136
|
+
log.warning("tool request failed: %s", e)
|
|
137
|
+
resp = ToolResponse(ok=False, error=str(e))
|
|
138
|
+
try:
|
|
139
|
+
conn.sendall(resp.encode())
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def stop(self) -> None:
|
|
144
|
+
self._running = False
|
|
145
|
+
if self._server:
|
|
146
|
+
self._server.close()
|
|
147
|
+
if HAS_UNIX_SOCKETS and not _is_tcp(self.address) and os.path.exists(self.address):
|
|
148
|
+
os.remove(self.address)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def call_tool(agent_name: str, tool_name: str, args: dict, socket_path: Optional[str] = None,
|
|
152
|
+
timeout: float = 30, address: Optional[str] = None) -> dict:
|
|
153
|
+
"""Client-side helper: what an agent process calls to invoke a tool."""
|
|
154
|
+
req = ToolRequest(agent_name=agent_name, tool_name=tool_name, args=args)
|
|
155
|
+
sock = _connect(address or socket_path or DEFAULT_SOCKET_PATH, timeout)
|
|
156
|
+
try:
|
|
157
|
+
sock.sendall(req.encode())
|
|
158
|
+
raw = _recv_line(sock)
|
|
159
|
+
finally:
|
|
160
|
+
sock.close()
|
|
161
|
+
resp = ToolResponse.decode(raw)
|
|
162
|
+
if not resp.ok:
|
|
163
|
+
raise RuntimeError(f"tool call failed: {resp.error}")
|
|
164
|
+
return resp.result
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ToolRequest:
|
|
7
|
+
agent_name: str
|
|
8
|
+
tool_name: str
|
|
9
|
+
args: dict
|
|
10
|
+
|
|
11
|
+
def encode(self) -> bytes:
|
|
12
|
+
return (json.dumps(asdict(self)) + "\n").encode("utf-8")
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def decode(raw: bytes) -> "ToolRequest":
|
|
16
|
+
data = json.loads(raw.decode("utf-8"))
|
|
17
|
+
return ToolRequest(**data)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ToolResponse:
|
|
22
|
+
ok: bool
|
|
23
|
+
result: object = None
|
|
24
|
+
error: str | None = None
|
|
25
|
+
|
|
26
|
+
def encode(self) -> bytes:
|
|
27
|
+
return (json.dumps(asdict(self)) + "\n").encode("utf-8")
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def decode(raw: bytes) -> "ToolResponse":
|
|
31
|
+
data = json.loads(raw.decode("utf-8"))
|
|
32
|
+
return ToolResponse(**data)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""File operations: the part of the OS kernel's filesystem an agent may touch.
|
|
2
|
+
|
|
3
|
+
Every agent gets its own workspace folder. A path must be relative and stay inside it: no `..`, absolute paths,
|
|
4
|
+
drive letters, backslashes or names Windows forbids, and a symlink that points outside is refused. Writes are atomic.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pal.errors import UserInputError
|
|
11
|
+
from pal.paths import atomic_write_bytes, is_safe_relative_key
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OSKernelError(UserInputError):
|
|
15
|
+
"""A request to the OS kernel was refused or failed. The message is safe to show."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Workspace:
|
|
19
|
+
def __init__(self, root: Path, max_read: int, max_write: int, max_binary: int = 20_000_000):
|
|
20
|
+
self.root = Path(root)
|
|
21
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
self.max_read = max_read
|
|
23
|
+
self.max_write = max_write
|
|
24
|
+
self.max_binary = max_binary
|
|
25
|
+
|
|
26
|
+
def resolve(self, rel: str) -> Path:
|
|
27
|
+
if not isinstance(rel, str) or not is_safe_relative_key(rel):
|
|
28
|
+
raise OSKernelError(f"invalid path: {rel!r} (use a relative path inside your workspace)")
|
|
29
|
+
target = (self.root / rel).resolve()
|
|
30
|
+
if self.root.resolve() not in target.parents:
|
|
31
|
+
raise OSKernelError(f"path escapes the workspace: {rel!r}")
|
|
32
|
+
return target
|
|
33
|
+
|
|
34
|
+
def exists(self, rel: str) -> bool:
|
|
35
|
+
return self.resolve(rel).is_file()
|
|
36
|
+
|
|
37
|
+
def read(self, rel: str) -> str:
|
|
38
|
+
p = self.resolve(rel)
|
|
39
|
+
if not p.is_file():
|
|
40
|
+
raise OSKernelError(f"no such file: {rel}")
|
|
41
|
+
if p.stat().st_size > self.max_read:
|
|
42
|
+
raise OSKernelError(f"{rel} is larger than the {self.max_read:,}-byte read limit")
|
|
43
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
44
|
+
|
|
45
|
+
def read_bytes(self, rel: str) -> bytes:
|
|
46
|
+
p = self.resolve(rel)
|
|
47
|
+
if not p.is_file():
|
|
48
|
+
raise OSKernelError(f"no such file: {rel}")
|
|
49
|
+
if p.stat().st_size > self.max_binary:
|
|
50
|
+
raise OSKernelError(f"{rel} is larger than the {self.max_binary:,}-byte limit for binary files")
|
|
51
|
+
return p.read_bytes()
|
|
52
|
+
|
|
53
|
+
def write_bytes(self, rel: str, data: bytes) -> int:
|
|
54
|
+
if len(data) > self.max_binary:
|
|
55
|
+
raise OSKernelError(f"the data is larger than the {self.max_binary:,}-byte limit for binary files")
|
|
56
|
+
atomic_write_bytes(self.resolve(rel), bytes(data))
|
|
57
|
+
return len(data)
|
|
58
|
+
|
|
59
|
+
def write(self, rel: str, content: str) -> int:
|
|
60
|
+
data = str(content).encode("utf-8")
|
|
61
|
+
if len(data) > self.max_write:
|
|
62
|
+
raise OSKernelError(f"content is larger than the {self.max_write:,}-byte write limit")
|
|
63
|
+
atomic_write_bytes(self.resolve(rel), data)
|
|
64
|
+
return len(data)
|
|
65
|
+
|
|
66
|
+
def delete(self, rel: str) -> None:
|
|
67
|
+
p = self.resolve(rel)
|
|
68
|
+
if not p.is_file():
|
|
69
|
+
raise OSKernelError(f"no such file: {rel}")
|
|
70
|
+
p.unlink()
|
|
71
|
+
|
|
72
|
+
def list(self, prefix: str = "") -> list[str]:
|
|
73
|
+
base = self.root.resolve()
|
|
74
|
+
out = []
|
|
75
|
+
for p in sorted(base.rglob("*")):
|
|
76
|
+
if p.is_file() and not p.name.endswith(".tmp"):
|
|
77
|
+
rel = p.relative_to(base).as_posix()
|
|
78
|
+
if rel.startswith(prefix):
|
|
79
|
+
out.append(rel)
|
|
80
|
+
return out
|
pal/os_kernel/network.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Network requests through the OS network stack, with the checks an agent-facing call needs.
|
|
2
|
+
|
|
3
|
+
http and https only; no credentials in the URL; the host must resolve to a public address unless the settings allow
|
|
4
|
+
private ones (blocks requests to localhost, the home network and cloud metadata endpoints); an optional host
|
|
5
|
+
allow-list; a timeout; a response size cap; redirects are followed by hand so every hop is checked again.
|
|
6
|
+
Residual risk, stated plainly: the address is checked before connecting, so a hostile DNS server that answers
|
|
7
|
+
differently the second time (DNS rebinding) is not fully covered.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import ipaddress
|
|
13
|
+
import logging
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urljoin, urlsplit
|
|
19
|
+
|
|
20
|
+
from pal.os_kernel.filesystem import OSKernelError
|
|
21
|
+
|
|
22
|
+
_MAX_REDIRECTS = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_url(url: str, allow_private: bool, allowed_hosts: list[str]) -> None:
|
|
26
|
+
p = urlsplit(url)
|
|
27
|
+
if p.scheme not in ("http", "https") or not p.hostname:
|
|
28
|
+
raise OSKernelError("only http and https URLs are allowed")
|
|
29
|
+
if p.username or p.password:
|
|
30
|
+
raise OSKernelError("credentials inside a URL are not allowed")
|
|
31
|
+
host = p.hostname.lower()
|
|
32
|
+
if allowed_hosts and host not in {h.lower() for h in allowed_hosts}:
|
|
33
|
+
raise OSKernelError(f"host {host!r} is not on the allowed list")
|
|
34
|
+
try:
|
|
35
|
+
infos = socket.getaddrinfo(host, p.port or (443 if p.scheme == "https" else 80), type=socket.SOCK_STREAM)
|
|
36
|
+
except OSError as e:
|
|
37
|
+
raise OSKernelError(f"could not resolve {host!r}") from e
|
|
38
|
+
if allow_private:
|
|
39
|
+
return
|
|
40
|
+
for info in infos:
|
|
41
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
42
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
|
43
|
+
raise OSKernelError(f"{host!r} resolves to a private or reserved address, which is blocked")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Headers a caller may set. Everything else (Host, Cookie, Proxy-*, X-Forwarded-*, ...) is refused, so a request cannot be made
|
|
47
|
+
# to look like it came from somewhere else. These are the ones service APIs use for their keys.
|
|
48
|
+
SAFE_HEADERS = frozenset({"authorization", "x-api-key", "x-rapidapi-key", "x-rapidapi-host", "ocp-apim-subscription-key",
|
|
49
|
+
"x-goog-api-key", "x-goog-fieldmask", "content-type", "accept"})
|
|
50
|
+
_TEXT_TYPES = ("application/json", "application/xml", "application/atom+xml", "application/xhtml+xml", "application/javascript")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clean_headers(headers: Any) -> dict:
|
|
54
|
+
out: dict = {}
|
|
55
|
+
for k, v in (headers or {}).items():
|
|
56
|
+
if not isinstance(k, str) or not isinstance(v, str):
|
|
57
|
+
raise OSKernelError("header names and values must be text")
|
|
58
|
+
if k.lower() not in SAFE_HEADERS:
|
|
59
|
+
raise OSKernelError(f"header {k!r} is not allowed")
|
|
60
|
+
if len(v) > 2000 or any(c in v for c in "\r\n\x00"):
|
|
61
|
+
raise OSKernelError(f"header {k!r} has an invalid value")
|
|
62
|
+
out[k] = v
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_text(content_type: str) -> bool:
|
|
67
|
+
ct = content_type.split(";")[0].strip().lower()
|
|
68
|
+
return not ct or ct.startswith("text/") or ct in _TEXT_TYPES or ct.endswith(("+json", "+xml"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def safe_target(url: str) -> str:
|
|
72
|
+
"""The URL without its query string, fragment or credentials: what is written to logs, because the query may hold an API key."""
|
|
73
|
+
p = urlsplit(url)
|
|
74
|
+
if not (p.scheme and p.hostname):
|
|
75
|
+
return ""
|
|
76
|
+
return f"{p.scheme}://{p.hostname}{f':{p.port}' if p.port else ''}{p.path}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_quiet_lock = threading.Lock()
|
|
80
|
+
_quiet_depth = 0
|
|
81
|
+
_quiet_saved = logging.NOTSET
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextlib.contextmanager
|
|
85
|
+
def _quiet_http_logs() -> Iterator[None]:
|
|
86
|
+
"""urllib3's DEBUG log line holds the full request URL, query string included, and so would put an API key in a log file for
|
|
87
|
+
anyone running with debug logging. Silence it for the length of a request (counted, so overlapping requests are safe)."""
|
|
88
|
+
global _quiet_depth, _quiet_saved
|
|
89
|
+
lg = logging.getLogger("urllib3")
|
|
90
|
+
with _quiet_lock:
|
|
91
|
+
if _quiet_depth == 0:
|
|
92
|
+
_quiet_saved = lg.level
|
|
93
|
+
lg.setLevel(logging.WARNING)
|
|
94
|
+
_quiet_depth += 1
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
finally:
|
|
98
|
+
with _quiet_lock:
|
|
99
|
+
_quiet_depth -= 1
|
|
100
|
+
if _quiet_depth == 0:
|
|
101
|
+
lg.setLevel(_quiet_saved)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def request(method: str, url: str, *, allow_private: bool, allowed_hosts: list[str], timeout: float, max_bytes: int,
|
|
105
|
+
json: Any = None, headers: dict | None = None, data: bytes | None = None, max_binary_bytes: int | None = None,
|
|
106
|
+
keep_binary: bool = False) -> dict:
|
|
107
|
+
"""One HTTP request with the checks above. `headers` must already be cleaned. A text answer comes back as `text` (at most
|
|
108
|
+
`max_bytes`); with `keep_binary` any other answer comes back as `body` bytes (at most `max_binary_bytes`) and `text` is empty.
|
|
109
|
+
Redirects are followed by hand and the caller's headers are NOT sent to the new address (they hold keys)."""
|
|
110
|
+
with _quiet_http_logs():
|
|
111
|
+
return _request(method, url, allow_private, allowed_hosts, timeout, max_bytes, json, headers, data, max_binary_bytes, keep_binary)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _request(method: str, url: str, allow_private: bool, allowed_hosts: list[str], timeout: float, max_bytes: int, json: Any,
|
|
115
|
+
headers: dict | None, data: bytes | None, max_binary_bytes: int | None, keep_binary: bool) -> dict:
|
|
116
|
+
import requests
|
|
117
|
+
|
|
118
|
+
sent = dict(headers or {})
|
|
119
|
+
for _ in range(_MAX_REDIRECTS + 1):
|
|
120
|
+
check_url(url, allow_private, allowed_hosts)
|
|
121
|
+
try:
|
|
122
|
+
resp = requests.request(method, url, json=json, data=data, timeout=timeout, allow_redirects=False, stream=True,
|
|
123
|
+
headers={"User-Agent": "pal-code", **sent})
|
|
124
|
+
except requests.RequestException as e:
|
|
125
|
+
raise OSKernelError(f"request failed: {type(e).__name__}") from e
|
|
126
|
+
if resp.is_redirect and resp.headers.get("Location"):
|
|
127
|
+
url, json, data, method, sent = urljoin(url, resp.headers["Location"]), None, None, "GET", {}
|
|
128
|
+
resp.close()
|
|
129
|
+
continue
|
|
130
|
+
ctype = resp.headers.get("Content-Type", "")
|
|
131
|
+
textual = is_text(ctype) or not keep_binary
|
|
132
|
+
cap = max_bytes if textual else (max_binary_bytes or max_bytes)
|
|
133
|
+
body = b""
|
|
134
|
+
for chunk in resp.iter_content(8192):
|
|
135
|
+
body += chunk
|
|
136
|
+
if len(body) > cap:
|
|
137
|
+
body = body[:cap]
|
|
138
|
+
break
|
|
139
|
+
resp.close()
|
|
140
|
+
out = {"status": resp.status_code, "content_type": ctype, "truncated": len(body) >= cap, "bytes": len(body)}
|
|
141
|
+
if textual:
|
|
142
|
+
out["text"] = body.decode(resp.encoding or "utf-8", errors="replace")
|
|
143
|
+
else:
|
|
144
|
+
out["text"], out["body"] = "", body
|
|
145
|
+
return out
|
|
146
|
+
raise OSKernelError("too many redirects")
|