onecoder 0.0.2__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.
- onecoder/agent.py +95 -0
- onecoder/agentic_tool_search/__init__.py +0 -0
- onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
- onecoder/agentic_tool_search/registry.py +33 -0
- onecoder/agents/__init__.py +7 -0
- onecoder/agents/documentation_agent.py +12 -0
- onecoder/agents/file_reader_agent.py +19 -0
- onecoder/agents/file_writer_agent.py +19 -0
- onecoder/agents/orchestrator_agent.py +51 -0
- onecoder/agents/refactoring_agent.py +12 -0
- onecoder/agents/research_agent.py +31 -0
- onecoder/agents/task_suggestion_agent.py +88 -0
- onecoder/alignment.py +236 -0
- onecoder/api.py +162 -0
- onecoder/api_client.py +112 -0
- onecoder/backends/base.py +22 -0
- onecoder/backends/local_tui.py +65 -0
- onecoder/blackboard.py +102 -0
- onecoder/cli.py +108 -0
- onecoder/commands/__init__.py +1 -0
- onecoder/commands/auth.py +78 -0
- onecoder/commands/ci.py +29 -0
- onecoder/commands/delegate.py +557 -0
- onecoder/commands/doctor.py +40 -0
- onecoder/commands/issue.py +136 -0
- onecoder/commands/logs.py +45 -0
- onecoder/commands/project.py +270 -0
- onecoder/commands/server.py +170 -0
- onecoder/config_manager.py +87 -0
- onecoder/constants.py +9 -0
- onecoder/diagnostics/__init__.py +2 -0
- onecoder/diagnostics/env_scan.py +207 -0
- onecoder/discovery.py +101 -0
- onecoder/distillation.py +236 -0
- onecoder/evaluation/__init__.py +1 -0
- onecoder/evaluation/ttu.py +176 -0
- onecoder/governance/__init__.py +0 -0
- onecoder/governance/probllm.py +91 -0
- onecoder/hooks.py +74 -0
- onecoder/ipc_auth.py +200 -0
- onecoder/issues.py +188 -0
- onecoder/jules_client.py +343 -0
- onecoder/knowledge.py +106 -0
- onecoder/llm.py +61 -0
- onecoder/logger.py +42 -0
- onecoder/metrics.py +129 -0
- onecoder/models/delegation.py +46 -0
- onecoder/onboarding.py +264 -0
- onecoder/review.py +233 -0
- onecoder/services/delegation_service.py +209 -0
- onecoder/services/validation_service.py +104 -0
- onecoder/sessions.py +186 -0
- onecoder/sprint_collector.py +165 -0
- onecoder/sync.py +167 -0
- onecoder/tmux.py +86 -0
- onecoder/tools/__init__.py +10 -0
- onecoder/tools/executor.py +53 -0
- onecoder/tools/external_tools.py +106 -0
- onecoder/tools/file_tools.py +77 -0
- onecoder/tools/interface.py +25 -0
- onecoder/tools/jules_tools.py +122 -0
- onecoder/tools/kit_tools.py +122 -0
- onecoder/tools/registry.py +32 -0
- onecoder/tui/__init__.py +5 -0
- onecoder/tui/app.py +263 -0
- onecoder/tui/commands.py +150 -0
- onecoder/tui/widgets.py +92 -0
- onecoder/worktree.py +186 -0
- onecoder-0.0.2.dist-info/METADATA +17 -0
- onecoder-0.0.2.dist-info/RECORD +73 -0
- onecoder-0.0.2.dist-info/WHEEL +5 -0
- onecoder-0.0.2.dist-info/entry_points.txt +2 -0
- onecoder-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional, Dict, Any
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TTUResult:
|
|
9
|
+
"""Result of a Time-To-Understand (TTU) evaluation."""
|
|
10
|
+
score: float # 0.0 to 1.0
|
|
11
|
+
passed: bool
|
|
12
|
+
context_found: List[str] = field(default_factory=list)
|
|
13
|
+
missing_context: List[str] = field(default_factory=list)
|
|
14
|
+
failure_modes: List[str] = field(default_factory=list)
|
|
15
|
+
recommendations: List[str] = field(default_factory=list)
|
|
16
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
class TTUEvaluator:
|
|
19
|
+
"""Evaluates whether the current context is sufficient to start a task."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, threshold: float = 0.8): # Increased threshold due to strict governance
|
|
22
|
+
self.threshold = threshold
|
|
23
|
+
|
|
24
|
+
def evaluate(self, sprint_path: Optional[Path] = None) -> TTUResult:
|
|
25
|
+
"""
|
|
26
|
+
Evaluates the context of the given sprint path.
|
|
27
|
+
If no path is provided, attempts to find the current active sprint.
|
|
28
|
+
"""
|
|
29
|
+
repo_root = self._find_repo_root()
|
|
30
|
+
|
|
31
|
+
if sprint_path is None:
|
|
32
|
+
sprint_path = self._find_active_sprint(repo_root)
|
|
33
|
+
|
|
34
|
+
if sprint_path is None:
|
|
35
|
+
return TTUResult(
|
|
36
|
+
score=0.0,
|
|
37
|
+
passed=False,
|
|
38
|
+
missing_context=["Active Sprint Directory"],
|
|
39
|
+
failure_modes=["Uninitialized Environment"],
|
|
40
|
+
recommendations=["Initialize a sprint using 'sprint init' or 'onecoder init'"]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
score = 0.0
|
|
44
|
+
missing = []
|
|
45
|
+
found = []
|
|
46
|
+
failure_modes = []
|
|
47
|
+
recommendations = []
|
|
48
|
+
details = {}
|
|
49
|
+
|
|
50
|
+
# 0. Check for AGENTS.md (Governance) - Weight: 0.2
|
|
51
|
+
agents_md = repo_root / "AGENTS.md"
|
|
52
|
+
if agents_md.exists():
|
|
53
|
+
score += 0.2
|
|
54
|
+
found.append("AGENTS.md")
|
|
55
|
+
else:
|
|
56
|
+
missing.append("AGENTS.md (Root)")
|
|
57
|
+
failure_modes.append("Governance Blindness")
|
|
58
|
+
recommendations.append("Create AGENTS.md in repo root to define agent policies.")
|
|
59
|
+
|
|
60
|
+
# 1. Check for sprint.json (Metadata) - Weight: 0.2
|
|
61
|
+
sprint_json = sprint_path / "sprint.json"
|
|
62
|
+
if sprint_json.exists():
|
|
63
|
+
found.append("sprint.json")
|
|
64
|
+
try:
|
|
65
|
+
with open(sprint_json, "r") as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
|
|
68
|
+
# Check for goals
|
|
69
|
+
if data.get("goals", {}).get("primary"):
|
|
70
|
+
score += 0.15
|
|
71
|
+
details["goals"] = "Present"
|
|
72
|
+
else:
|
|
73
|
+
missing.append("Primary Goal in sprint.json")
|
|
74
|
+
failure_modes.append("Goal Ambiguity")
|
|
75
|
+
recommendations.append("Define a primary goal in sprint.json")
|
|
76
|
+
|
|
77
|
+
# Check for tasks
|
|
78
|
+
if data.get("tasks"):
|
|
79
|
+
score += 0.05
|
|
80
|
+
details["tasks"] = f"{len(data['tasks'])} tasks found"
|
|
81
|
+
else:
|
|
82
|
+
# Not strictly missing if just starting, but good to have
|
|
83
|
+
details["tasks"] = "None"
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
missing.append(f"Valid sprint.json ({str(e)})")
|
|
87
|
+
failure_modes.append("Corrupt Metadata")
|
|
88
|
+
else:
|
|
89
|
+
missing.append("sprint.json")
|
|
90
|
+
failure_modes.append("Missing Metadata")
|
|
91
|
+
recommendations.append("Run 'sprint init' to generate sprint structure.")
|
|
92
|
+
|
|
93
|
+
# 2. Check for Context/Walkthrough - Weight: 0.3
|
|
94
|
+
# Look for README.md or WALKTHROUGH.md or context folder
|
|
95
|
+
context_files = ["README.md", "WALKTHROUGH.md", "context/context.md"]
|
|
96
|
+
context_found_count = 0
|
|
97
|
+
for cf in context_files:
|
|
98
|
+
if (sprint_path / cf).exists():
|
|
99
|
+
context_found_count += 1
|
|
100
|
+
found.append(cf)
|
|
101
|
+
|
|
102
|
+
if context_found_count > 0:
|
|
103
|
+
score += 0.3
|
|
104
|
+
else:
|
|
105
|
+
missing.append("Context Documentation (README.md or WALKTHROUGH.md)")
|
|
106
|
+
failure_modes.append("Context Blindness")
|
|
107
|
+
recommendations.append("Add a README.md or WALKTHROUGH.md describing the sprint.")
|
|
108
|
+
|
|
109
|
+
# 3. Check for Media/Reference (Optional but good) - Weight: 0.1
|
|
110
|
+
media_dir = sprint_path / "media"
|
|
111
|
+
if media_dir.exists() and any(media_dir.iterdir()):
|
|
112
|
+
score += 0.1
|
|
113
|
+
found.append("media/")
|
|
114
|
+
|
|
115
|
+
# 4. Check for Planning - Weight: 0.2
|
|
116
|
+
planning_dir = sprint_path / "planning"
|
|
117
|
+
if planning_dir.exists():
|
|
118
|
+
score += 0.2
|
|
119
|
+
found.append("planning/")
|
|
120
|
+
else:
|
|
121
|
+
# Maybe TODO.md?
|
|
122
|
+
if (sprint_path / "TODO.md").exists():
|
|
123
|
+
score += 0.2
|
|
124
|
+
found.append("TODO.md")
|
|
125
|
+
else:
|
|
126
|
+
missing.append("Planning Documents")
|
|
127
|
+
failure_modes.append("Unplanned Execution")
|
|
128
|
+
recommendations.append("Create a plan in planning/ or TODO.md")
|
|
129
|
+
|
|
130
|
+
# Normalize score if needed, but here simple sum max is 1.0
|
|
131
|
+
|
|
132
|
+
passed = score >= self.threshold
|
|
133
|
+
|
|
134
|
+
return TTUResult(
|
|
135
|
+
score=min(score, 1.0),
|
|
136
|
+
passed=passed,
|
|
137
|
+
context_found=found,
|
|
138
|
+
missing_context=missing,
|
|
139
|
+
failure_modes=failure_modes,
|
|
140
|
+
recommendations=recommendations,
|
|
141
|
+
details=details
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _find_repo_root(self) -> Path:
|
|
145
|
+
"""Finds the repository root."""
|
|
146
|
+
curr = Path.cwd()
|
|
147
|
+
root = curr
|
|
148
|
+
# Traverse up
|
|
149
|
+
while root != root.parent:
|
|
150
|
+
if (root / ".git").exists() or (root / ".sprint").exists():
|
|
151
|
+
return root
|
|
152
|
+
root = root.parent
|
|
153
|
+
return curr # Default to cwd if not found
|
|
154
|
+
|
|
155
|
+
def _find_active_sprint(self, root: Optional[Path] = None) -> Optional[Path]:
|
|
156
|
+
# Simple heuristic: Look for .sprint folder in root
|
|
157
|
+
if root:
|
|
158
|
+
sprint_dir = root / ".sprint"
|
|
159
|
+
if sprint_dir.exists():
|
|
160
|
+
# Get latest subdir
|
|
161
|
+
subdirs = sorted([d for d in sprint_dir.iterdir() if d.is_dir()])
|
|
162
|
+
if subdirs:
|
|
163
|
+
return subdirs[-1]
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# Fallback if root not provided (legacy)
|
|
167
|
+
cwd = Path.cwd()
|
|
168
|
+
curr_root = cwd
|
|
169
|
+
while curr_root != curr_root.parent:
|
|
170
|
+
sprint_dir = curr_root / ".sprint"
|
|
171
|
+
if sprint_dir.exists():
|
|
172
|
+
subdirs = sorted([d for d in sprint_dir.iterdir() if d.is_dir()])
|
|
173
|
+
if subdirs:
|
|
174
|
+
return subdirs[-1]
|
|
175
|
+
curr_root = curr_root.parent
|
|
176
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
class ProbLLMGuardian:
|
|
6
|
+
"""
|
|
7
|
+
Enforces governance policies to prevent ProbLLM vulnerabilities (Prompt Injection,
|
|
8
|
+
Automatic Tool Invocation, Data Exfiltration).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, governance_path="governance.yaml"):
|
|
12
|
+
self.governance_path = governance_path
|
|
13
|
+
self.policy = self._load_policy()
|
|
14
|
+
|
|
15
|
+
def _load_policy(self):
|
|
16
|
+
"""Loads the governance policy from yaml."""
|
|
17
|
+
if not os.path.exists(self.governance_path):
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
with open(self.governance_path, "r") as f:
|
|
22
|
+
data = yaml.safe_load(f)
|
|
23
|
+
return data.get("probllm_prevention", {})
|
|
24
|
+
except Exception:
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
def is_enabled(self):
|
|
28
|
+
"""Checks if ProbLLM prevention is enabled."""
|
|
29
|
+
return self.policy.get("enabled", False)
|
|
30
|
+
|
|
31
|
+
def validate_tool_execution(self, tool_name: str, args: dict):
|
|
32
|
+
"""
|
|
33
|
+
Validates whether a tool execution is safe based on the policy.
|
|
34
|
+
Returns (is_safe, message).
|
|
35
|
+
"""
|
|
36
|
+
if not self.is_enabled():
|
|
37
|
+
return True, "Policy disabled"
|
|
38
|
+
|
|
39
|
+
# 1. Check for High-Risk Tools requiring Confirmation
|
|
40
|
+
restricted_tools = self.policy.get("require_human_confirmation_for_tools", [])
|
|
41
|
+
if tool_name in restricted_tools:
|
|
42
|
+
# In a real CLI, we would prompt here.
|
|
43
|
+
# For now, we return a warning status that the caller must handle (e.g., prompt user).
|
|
44
|
+
return False, f"Tool '{tool_name}' is restricted and requires Human Confirmation."
|
|
45
|
+
|
|
46
|
+
# 2. Check for Secret Exposure in Args (Input Sanitization)
|
|
47
|
+
# (Simplified check: looks for env var patterns like $SECRET or generic key patterns)
|
|
48
|
+
if self.policy.get("block_secret_exposure", True):
|
|
49
|
+
if self._contains_secrets(str(args)):
|
|
50
|
+
return False, "Tool arguments appear to contain secrets or environment variables."
|
|
51
|
+
|
|
52
|
+
return True, "Safe"
|
|
53
|
+
|
|
54
|
+
def validate_output(self, output: str):
|
|
55
|
+
"""
|
|
56
|
+
Scans LLM or Tool output for leaked secrets.
|
|
57
|
+
"""
|
|
58
|
+
if not self.is_enabled():
|
|
59
|
+
return True, "Policy disabled"
|
|
60
|
+
|
|
61
|
+
if self.policy.get("block_secret_exposure", True):
|
|
62
|
+
if self._contains_secrets(output):
|
|
63
|
+
return False, "Output blocked: Potential secret leakage detected."
|
|
64
|
+
|
|
65
|
+
return True, "Safe"
|
|
66
|
+
|
|
67
|
+
def _contains_secrets(self, text: str) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
Heuristic check for secrets.
|
|
70
|
+
"""
|
|
71
|
+
# 1. Check for common API Key patterns (simplified)
|
|
72
|
+
# Starts with sk-, gh-, etc. followed by alphanumeric
|
|
73
|
+
patterns = [
|
|
74
|
+
r"sk-[a-zA-Z0-9]{20,}", # OpenAI/Stripe style
|
|
75
|
+
r"gh[pousr]-[a-zA-Z0-9]{20,}", # GitHub tokens
|
|
76
|
+
r"xox[baprs]-[a-zA-Z0-9]{10,}", # Slack tokens
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for pattern in patterns:
|
|
80
|
+
if re.search(pattern, text):
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
# 2. Check if any known environment variable values are present
|
|
84
|
+
# (Be careful not to block common words if an env var is common)
|
|
85
|
+
sensitive_keys = ["API_KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIALS"]
|
|
86
|
+
for key, value in os.environ.items():
|
|
87
|
+
if any(s in key for s in sensitive_keys) and len(value) > 8: # Only check long secrets
|
|
88
|
+
if value in text:
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
return False
|
onecoder/hooks.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import fnmatch
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
class HooksManager:
|
|
9
|
+
def __init__(self, config_filename: str = "onecoder.hooks.json"):
|
|
10
|
+
self.config_filename = config_filename
|
|
11
|
+
self.config = self._load_config()
|
|
12
|
+
|
|
13
|
+
def _load_config(self) -> Dict[str, Any]:
|
|
14
|
+
"""Loads the configuration from the hooks file in the current directory."""
|
|
15
|
+
# Look for config in current working directory
|
|
16
|
+
config_path = Path.cwd() / self.config_filename
|
|
17
|
+
if config_path.exists():
|
|
18
|
+
try:
|
|
19
|
+
with open(config_path, "r") as f:
|
|
20
|
+
return json.load(f)
|
|
21
|
+
except json.JSONDecodeError as e:
|
|
22
|
+
print(f"Error parsing {self.config_filename}: {e}")
|
|
23
|
+
return {}
|
|
24
|
+
except Exception as e:
|
|
25
|
+
print(f"Error reading {self.config_filename}: {e}")
|
|
26
|
+
return {}
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
def _run_command(self, command: str):
|
|
30
|
+
"""Runs a shell command."""
|
|
31
|
+
print(f"[Hooks] Running: {command}")
|
|
32
|
+
try:
|
|
33
|
+
# shell=True is used to allow running complex commands like "cargo check"
|
|
34
|
+
# Security note: command comes from a user-defined config file.
|
|
35
|
+
subprocess.run(command, shell=True, check=True)
|
|
36
|
+
except subprocess.CalledProcessError as e:
|
|
37
|
+
print(f"[Hooks] Command failed: {command} (Exit code: {e.returncode})")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"[Hooks] Error running command {command}: {e}")
|
|
40
|
+
|
|
41
|
+
def on_file_edit(self, filepath: str):
|
|
42
|
+
"""Triggers hooks configured for file edits."""
|
|
43
|
+
# Reload config to pick up changes without restart
|
|
44
|
+
self.config = self._load_config()
|
|
45
|
+
|
|
46
|
+
file_patterns = self.config.get("file_patterns", [])
|
|
47
|
+
|
|
48
|
+
# Determine relative path for matching
|
|
49
|
+
try:
|
|
50
|
+
rel_path = os.path.relpath(filepath, os.getcwd())
|
|
51
|
+
except ValueError:
|
|
52
|
+
# If filepath is on a different drive or invalid, use absolute
|
|
53
|
+
rel_path = filepath
|
|
54
|
+
|
|
55
|
+
for item in file_patterns:
|
|
56
|
+
pattern = item.get("pattern")
|
|
57
|
+
command = item.get("command")
|
|
58
|
+
|
|
59
|
+
if pattern and command:
|
|
60
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
61
|
+
print(f"[Hooks] File {rel_path} matches pattern {pattern}")
|
|
62
|
+
self._run_command(command)
|
|
63
|
+
|
|
64
|
+
def on_stop(self):
|
|
65
|
+
"""Triggers hooks configured for session stop."""
|
|
66
|
+
self.config = self._load_config()
|
|
67
|
+
commands = self.config.get("on_stop", [])
|
|
68
|
+
if commands:
|
|
69
|
+
print("[Hooks] Running on_stop hooks...")
|
|
70
|
+
for command in commands:
|
|
71
|
+
self._run_command(command)
|
|
72
|
+
|
|
73
|
+
# Global instance
|
|
74
|
+
hooks_manager = HooksManager()
|
onecoder/ipc_auth.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
import uuid
|
|
4
|
+
import secrets
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenStore:
|
|
11
|
+
"""Manages session-based tokens with TTL for API authentication."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, ttl_seconds: int = 3600):
|
|
14
|
+
"""
|
|
15
|
+
Initialize token store.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
ttl_seconds: Time-to-live for tokens in seconds (default: 1 hour)
|
|
19
|
+
"""
|
|
20
|
+
self._tokens: Dict[str, float] = {} # token -> expiry_timestamp
|
|
21
|
+
self.ttl_seconds = ttl_seconds
|
|
22
|
+
|
|
23
|
+
def generate_token(self) -> str:
|
|
24
|
+
"""Generate a new session token with TTL."""
|
|
25
|
+
token = secrets.token_urlsafe(32)
|
|
26
|
+
expiry = time.time() + self.ttl_seconds
|
|
27
|
+
self._tokens[token] = expiry
|
|
28
|
+
return token
|
|
29
|
+
|
|
30
|
+
def validate_token(self, token: str) -> bool:
|
|
31
|
+
"""
|
|
32
|
+
Validates a token without consuming it (session-based).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
token: The token to validate
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if token exists and hasn't expired, False otherwise
|
|
39
|
+
"""
|
|
40
|
+
if token not in self._tokens:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
# Check if token has expired
|
|
44
|
+
if time.time() > self._tokens[token]:
|
|
45
|
+
# Clean up expired token
|
|
46
|
+
del self._tokens[token]
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def cleanup_expired(self) -> int:
|
|
52
|
+
"""
|
|
53
|
+
Remove expired tokens from the store.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Number of tokens cleaned up
|
|
57
|
+
"""
|
|
58
|
+
current_time = time.time()
|
|
59
|
+
expired_tokens = [
|
|
60
|
+
token for token, expiry in self._tokens.items() if current_time > expiry
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for token in expired_tokens:
|
|
64
|
+
del self._tokens[token]
|
|
65
|
+
|
|
66
|
+
return len(expired_tokens)
|
|
67
|
+
|
|
68
|
+
def revoke_token(self, token: str) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
Manually revoke a token.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
token: The token to revoke
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if token was revoked, False if it didn't exist
|
|
77
|
+
"""
|
|
78
|
+
if token in self._tokens:
|
|
79
|
+
del self._tokens[token]
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Global store for the running process
|
|
85
|
+
TOKEN_STORE = TokenStore()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class IPCAuthServer:
|
|
89
|
+
"""
|
|
90
|
+
A Unix Domain Socket server that vends one-time tokens to local clients.
|
|
91
|
+
This ensures that only processes with access to the socket (local users)
|
|
92
|
+
can obtain authorization to hit the agent API.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, socket_path: str = "/tmp/onecoder_auth.sock"):
|
|
96
|
+
self.socket_path = socket_path
|
|
97
|
+
|
|
98
|
+
async def start(self):
|
|
99
|
+
if os.path.exists(self.socket_path):
|
|
100
|
+
os.remove(self.socket_path)
|
|
101
|
+
|
|
102
|
+
server = await asyncio.start_unix_server(self.handle_client, self.socket_path)
|
|
103
|
+
|
|
104
|
+
# Ensure only the current user can read/write to the socket
|
|
105
|
+
os.chmod(self.socket_path, 0o600)
|
|
106
|
+
|
|
107
|
+
print(f"IPC Auth Server started on {self.socket_path}")
|
|
108
|
+
|
|
109
|
+
# Start periodic cleanup task
|
|
110
|
+
cleanup_task = asyncio.create_task(self._periodic_cleanup())
|
|
111
|
+
|
|
112
|
+
async with server:
|
|
113
|
+
try:
|
|
114
|
+
await server.serve_forever()
|
|
115
|
+
finally:
|
|
116
|
+
cleanup_task.cancel()
|
|
117
|
+
|
|
118
|
+
async def _periodic_cleanup(self):
|
|
119
|
+
"""Periodically clean up expired tokens (every 5 minutes)."""
|
|
120
|
+
while True:
|
|
121
|
+
await asyncio.sleep(300) # 5 minutes
|
|
122
|
+
cleaned = TOKEN_STORE.cleanup_expired()
|
|
123
|
+
if cleaned > 0:
|
|
124
|
+
print(f"Cleaned up {cleaned} expired tokens")
|
|
125
|
+
|
|
126
|
+
async def handle_client(
|
|
127
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
128
|
+
):
|
|
129
|
+
"""
|
|
130
|
+
Handles token requests from local UI launchers.
|
|
131
|
+
Protocol:
|
|
132
|
+
- Client sends: "REQUEST_TOKEN\n"
|
|
133
|
+
- Server replies: "<token>\n"
|
|
134
|
+
"""
|
|
135
|
+
data = await reader.readline()
|
|
136
|
+
message = data.decode().strip()
|
|
137
|
+
|
|
138
|
+
if message == "REQUEST_TOKEN":
|
|
139
|
+
token = TOKEN_STORE.generate_token()
|
|
140
|
+
writer.write(f"{token}\n".encode())
|
|
141
|
+
await writer.drain()
|
|
142
|
+
|
|
143
|
+
writer.close()
|
|
144
|
+
await writer.wait_closed()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def get_token_from_ipc(
|
|
148
|
+
socket_path: str = "/tmp/onecoder_auth.sock",
|
|
149
|
+
) -> Optional[str]:
|
|
150
|
+
"""Client utility to fetch a token via the IPC socket."""
|
|
151
|
+
try:
|
|
152
|
+
reader, writer = await asyncio.open_unix_connection(socket_path)
|
|
153
|
+
writer.write(b"REQUEST_TOKEN\n")
|
|
154
|
+
await writer.drain()
|
|
155
|
+
|
|
156
|
+
data = await reader.readline()
|
|
157
|
+
token = data.decode().strip()
|
|
158
|
+
|
|
159
|
+
writer.close()
|
|
160
|
+
await writer.wait_closed()
|
|
161
|
+
return token
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"Error fetching token from IPC: {e}")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
# Test execution
|
|
169
|
+
async def main():
|
|
170
|
+
server = IPCAuthServer()
|
|
171
|
+
# Run server in background
|
|
172
|
+
server_task = asyncio.create_task(server.start())
|
|
173
|
+
await asyncio.sleep(1) # Wait for startup
|
|
174
|
+
|
|
175
|
+
# Simulate client
|
|
176
|
+
token = await get_token_from_ipc()
|
|
177
|
+
if token:
|
|
178
|
+
print(f"Client fetched token: {token}")
|
|
179
|
+
|
|
180
|
+
# Validate (should work - session-based)
|
|
181
|
+
is_valid = TOKEN_STORE.validate_token(token)
|
|
182
|
+
print(f"Token is valid: {is_valid}")
|
|
183
|
+
|
|
184
|
+
# Validate again (should still work - not consumed)
|
|
185
|
+
is_valid_again = TOKEN_STORE.validate_token(token)
|
|
186
|
+
print(f"Token is still valid after first check: {is_valid_again}")
|
|
187
|
+
|
|
188
|
+
# Revoke token
|
|
189
|
+
revoked = TOKEN_STORE.revoke_token(token)
|
|
190
|
+
print(f"Token revoked: {revoked}")
|
|
191
|
+
|
|
192
|
+
# Validate after revocation (should fail)
|
|
193
|
+
is_valid_after_revoke = TOKEN_STORE.validate_token(token)
|
|
194
|
+
print(f"Token valid after revocation: {is_valid_after_revoke}")
|
|
195
|
+
else:
|
|
196
|
+
print("Failed to fetch token from IPC")
|
|
197
|
+
|
|
198
|
+
server_task.cancel()
|
|
199
|
+
|
|
200
|
+
asyncio.run(main())
|