pennyrouter 0.3.45 → 0.3.47
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.
- package/README.md +34 -0
- package/package.json +2 -2
- package/src/cli.js +5 -0
- package/src/launch.js +10 -1
- package/src/remote.js +40 -0
- package/src/remote.test.js +37 -0
- package/standalone/remote/rc_echo.py +298 -0
- package/standalone/remote/rc_penny.py +205 -0
- package/standalone/remote/rc_project.py +120 -0
- package/standalone/remote/rc_restart.py +144 -0
- package/standalone/remote/rc_tools.py +171 -0
- package/standalone/web-demo/claude-meter.mjs +18 -3
- package/standalone/web-demo/compare.html +58 -31
- package/standalone/web-demo/gallery-submit.mjs +2 -3
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Text-only Remote Control probe backed by one persistent Penny Claude process.
|
|
2
|
+
|
|
3
|
+
Uses the active Penny profile and an existing gateway. Tools, hooks, plugins, MCP,
|
|
4
|
+
and session persistence are disabled; conversation continuity lives in the child.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from rc_echo import BASE, EchoWorker, read_login
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_profile():
|
|
20
|
+
config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
21
|
+
config = json.loads((config_root / "pennyrouter/config.json").read_text())
|
|
22
|
+
profile = config.get("profiles", {}).get(config.get("active_profile"), {})
|
|
23
|
+
if not profile.get("api_key") or not profile.get("gateway_base_url"):
|
|
24
|
+
raise RuntimeError("No active Penny profile with gateway and API key found.")
|
|
25
|
+
return profile, config.get("harnesses", {}).get("claude", {}).get("models", {}).get("sonnet", "claude-sonnet-5-5")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PennyClaude:
|
|
29
|
+
def __init__(self, profile, model, session_id, cwd, permission_handler=None):
|
|
30
|
+
self.profile, self.model, self.session_id, self.cwd = profile, model, session_id, cwd
|
|
31
|
+
self.process = None
|
|
32
|
+
self.stderr_task = None
|
|
33
|
+
self.permission_handler = permission_handler
|
|
34
|
+
self.effort = "low"
|
|
35
|
+
self.input_lock = asyncio.Lock()
|
|
36
|
+
self.interrupt_requested = False
|
|
37
|
+
self.persistence_dir = None
|
|
38
|
+
self.resume = False
|
|
39
|
+
|
|
40
|
+
async def start(self):
|
|
41
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("ANTHROPIC_")
|
|
42
|
+
and k not in {"CLAUDE_CODE_OAUTH_TOKEN", "CLAUDECODE", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"}}
|
|
43
|
+
env.update(ANTHROPIC_BASE_URL=self.profile["gateway_base_url"],
|
|
44
|
+
ANTHROPIC_API_KEY=self.profile["api_key"],
|
|
45
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
|
|
46
|
+
if self.persistence_dir:
|
|
47
|
+
env["CLAUDE_CONFIG_DIR"] = str(self.persistence_dir)
|
|
48
|
+
session_args = (["--resume", self.session_id] if self.resume else ["--session-id", self.session_id])
|
|
49
|
+
if not self.persistence_dir:
|
|
50
|
+
session_args.append("--no-session-persistence")
|
|
51
|
+
permissions = (["--permission-prompts", "host", "--permission-prompt-tool", "stdio",
|
|
52
|
+
"--permission-mode", "default", "--settings",
|
|
53
|
+
json.dumps({"permissions": {"ask": ["Read", "Edit"]}})]
|
|
54
|
+
if self.permission_handler else ["--permission-prompts", "none"])
|
|
55
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
56
|
+
"claude", "--bare", "-p", "--input-format", "stream-json", "--output-format", "stream-json",
|
|
57
|
+
"--verbose", "--include-partial-messages", *session_args,
|
|
58
|
+
"--model", self.model, "--effort", self.effort,
|
|
59
|
+
"--tools", "Read,Edit" if self.permission_handler else "", "--strict-mcp-config",
|
|
60
|
+
"--mcp-config", '{"mcpServers":{}}', *permissions,
|
|
61
|
+
"--disable-slash-commands", "--setting-sources", "",
|
|
62
|
+
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
63
|
+
cwd=self.cwd, env=env, limit=4 * 1024 * 1024)
|
|
64
|
+
# Drain diagnostic output so it cannot block the child, without logging credentials.
|
|
65
|
+
self.stderr_task = asyncio.create_task(self.drain_stderr())
|
|
66
|
+
if self.permission_handler:
|
|
67
|
+
# The SDK handshake activates the bidirectional permission channel.
|
|
68
|
+
request_id = self.session_id + "-init"
|
|
69
|
+
await self.send({"type": "control_request", "request_id": request_id,
|
|
70
|
+
"request": {"subtype": "initialize", "hooks": None}})
|
|
71
|
+
async with asyncio.timeout(60):
|
|
72
|
+
while True:
|
|
73
|
+
line = await self.process.stdout.readline()
|
|
74
|
+
if not line:
|
|
75
|
+
raise RuntimeError("Claude exited during permission-channel initialization.")
|
|
76
|
+
event = json.loads(line)
|
|
77
|
+
response = event.get("response", {})
|
|
78
|
+
if event.get("type") == "control_response" and response.get("request_id") == request_id:
|
|
79
|
+
if response.get("subtype") != "success":
|
|
80
|
+
raise RuntimeError("Claude refused permission-channel initialization.")
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
async def drain_stderr(self):
|
|
84
|
+
while await self.process.stderr.read(4096):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
async def send(self, payload):
|
|
88
|
+
async with self.input_lock:
|
|
89
|
+
self.process.stdin.write((json.dumps(payload) + "\n").encode())
|
|
90
|
+
await self.process.stdin.drain()
|
|
91
|
+
|
|
92
|
+
async def interrupt(self, request_id):
|
|
93
|
+
self.interrupt_requested = True
|
|
94
|
+
await self.send({"type": "control_request", "request_id": request_id,
|
|
95
|
+
"request": {"subtype": "interrupt"}})
|
|
96
|
+
|
|
97
|
+
async def turn(self, message):
|
|
98
|
+
self.interrupt_requested = False
|
|
99
|
+
if self.process is None:
|
|
100
|
+
await self.start()
|
|
101
|
+
content = message.get("content")
|
|
102
|
+
if not isinstance(content, str):
|
|
103
|
+
raise RuntimeError("This probe accepts text-only phone messages.")
|
|
104
|
+
await self.send({"type": "user", "message": {"role": "user", "content": content},
|
|
105
|
+
"session_id": self.session_id, "parent_tool_use_id": None})
|
|
106
|
+
async with asyncio.timeout(300 if self.permission_handler else 120):
|
|
107
|
+
while True:
|
|
108
|
+
line = await self.process.stdout.readline()
|
|
109
|
+
if not line:
|
|
110
|
+
raise RuntimeError("Penny Claude exited before completing the turn.")
|
|
111
|
+
event = json.loads(line)
|
|
112
|
+
kind = event.get("type")
|
|
113
|
+
if (kind == "result" and event.get("is_error") and self.interrupt_requested
|
|
114
|
+
and event.get("terminal_reason") == "aborted_streaming"):
|
|
115
|
+
# Claude's interrupted SDK turn carries an error flag even though
|
|
116
|
+
# the persistent process remains usable. Only an explicitly
|
|
117
|
+
# requested interrupt with this terminal reason is non-fatal.
|
|
118
|
+
event = {**event, "subtype": "success", "is_error": False,
|
|
119
|
+
"result": "Stopped by user."}
|
|
120
|
+
event.pop("errors", None)
|
|
121
|
+
if kind == "control_request" and self.permission_handler:
|
|
122
|
+
decision = await self.permission_handler(event)
|
|
123
|
+
await self.send({"type": "control_response", "response": {
|
|
124
|
+
"subtype": "success", "request_id": event["request_id"], "response": decision}})
|
|
125
|
+
continue
|
|
126
|
+
if kind == "control_response" and self.permission_handler:
|
|
127
|
+
yield event
|
|
128
|
+
if kind == "user" and isinstance(event.get("message", {}).get("content"), list):
|
|
129
|
+
yield event
|
|
130
|
+
if kind in {"stream_event", "assistant", "result"}:
|
|
131
|
+
yield event
|
|
132
|
+
if kind == "result":
|
|
133
|
+
if event.get("is_error"):
|
|
134
|
+
raise RuntimeError("Penny Claude reported a failed turn; see the phone result.")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
async def close(self):
|
|
138
|
+
if self.process is not None and self.process.returncode is None:
|
|
139
|
+
self.process.terminate()
|
|
140
|
+
try:
|
|
141
|
+
await asyncio.wait_for(self.process.wait(), 5)
|
|
142
|
+
except asyncio.TimeoutError:
|
|
143
|
+
self.process.kill()
|
|
144
|
+
await self.process.wait()
|
|
145
|
+
if self.stderr_task is not None:
|
|
146
|
+
await self.stderr_task
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PennyWorker(EchoWorker):
|
|
150
|
+
def __init__(self, client, oauth, profile, model, cwd):
|
|
151
|
+
super().__init__(client, oauth)
|
|
152
|
+
self.title = "Penny live bridge test"
|
|
153
|
+
self.model = model
|
|
154
|
+
self.claude = PennyClaude(profile, model, self.local_session, cwd)
|
|
155
|
+
|
|
156
|
+
async def answer(self, payload):
|
|
157
|
+
async for event in self.claude.turn(payload["message"]):
|
|
158
|
+
event = {**event, "session_id": self.local_session}
|
|
159
|
+
event.setdefault("uuid", self.payload("unused")["uuid"])
|
|
160
|
+
if event["type"] == "result":
|
|
161
|
+
event["user_message_uuid"] = payload.get("uuid")
|
|
162
|
+
await self.events(event)
|
|
163
|
+
|
|
164
|
+
async def close(self):
|
|
165
|
+
try:
|
|
166
|
+
await self.claude.close()
|
|
167
|
+
finally:
|
|
168
|
+
await super().close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def run(seconds):
|
|
172
|
+
profile, model = read_profile()
|
|
173
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
174
|
+
"User-Agent": "penny-rc-text-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
175
|
+
# A failing health check never starts or restarts a gateway.
|
|
176
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
177
|
+
if response.status_code != 200:
|
|
178
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="penny-rc-text-") as cwd:
|
|
180
|
+
worker = PennyWorker(client, read_login(), profile, model, cwd)
|
|
181
|
+
tasks = []
|
|
182
|
+
try:
|
|
183
|
+
await worker.start()
|
|
184
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
|
|
185
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
186
|
+
for task in done:
|
|
187
|
+
task.result()
|
|
188
|
+
finally:
|
|
189
|
+
for task in tasks:
|
|
190
|
+
task.cancel()
|
|
191
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
192
|
+
await worker.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
197
|
+
parser.add_argument("--seconds", type=int, default=600)
|
|
198
|
+
args = parser.parse_args()
|
|
199
|
+
try:
|
|
200
|
+
asyncio.run(run(max(1, min(args.seconds, 1800))))
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
pass
|
|
203
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError):
|
|
204
|
+
print("Penny text probe failed; check gateway availability and Claude login. No gateway was restarted.")
|
|
205
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Experimental Penny project launcher with phone Read/Edit approvals and Stop.
|
|
2
|
+
|
|
3
|
+
Sessions persist after Ctrl+C. Resume is supported only at completed-turn boundaries.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import asyncio
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shlex
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from rc_echo import EchoWorker, read_login
|
|
18
|
+
from rc_penny import read_profile
|
|
19
|
+
from rc_restart import RestartWorker
|
|
20
|
+
from rc_tools import ToolsWorker
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectWorker(RestartWorker, ToolsWorker):
|
|
24
|
+
def __init__(self, client, oauth, profile, model, directory, project):
|
|
25
|
+
super().__init__(client, oauth, profile, model, directory, project)
|
|
26
|
+
self.title = "Penny: " + Path(self.cwd).name
|
|
27
|
+
self.phase = "idle"
|
|
28
|
+
|
|
29
|
+
async def handle(self, envelope):
|
|
30
|
+
event_id = envelope.get("event_id")
|
|
31
|
+
is_new_user = (event_id and event_id not in self.seen and event_id not in self.queued_ids
|
|
32
|
+
and envelope.get("payload", {}).get("type") == "user")
|
|
33
|
+
if is_new_user:
|
|
34
|
+
self.phase = "busy"
|
|
35
|
+
self.save(self.phase)
|
|
36
|
+
await ToolsWorker.handle(self, envelope)
|
|
37
|
+
self.save(self.phase)
|
|
38
|
+
|
|
39
|
+
async def process_turns(self):
|
|
40
|
+
while not self.stopping:
|
|
41
|
+
envelope = await self.inbox.get()
|
|
42
|
+
self.active_turn = True
|
|
43
|
+
try:
|
|
44
|
+
await EchoWorker.handle(self, envelope)
|
|
45
|
+
self.queued_ids.discard(envelope["event_id"])
|
|
46
|
+
self.phase = "busy" if self.queued_ids else "idle"
|
|
47
|
+
self.save(self.phase)
|
|
48
|
+
print("Turn saved." if self.phase == "idle" else "Next turn queued.", flush=True)
|
|
49
|
+
finally:
|
|
50
|
+
self.active_turn = False
|
|
51
|
+
self.inbox.task_done()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def run(project, state_dir, effort):
|
|
55
|
+
directory = Path(state_dir).resolve()
|
|
56
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
|
+
with (directory / "lock").open("w") as lock:
|
|
58
|
+
try:
|
|
59
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
60
|
+
except BlockingIOError:
|
|
61
|
+
raise RuntimeError("This remote session is already running.") from None
|
|
62
|
+
profile, model = read_profile()
|
|
63
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
64
|
+
"User-Agent": "penny-rc-project/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
65
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
66
|
+
if response.status_code != 200:
|
|
67
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
68
|
+
worker = ProjectWorker(client, read_login(), profile, model, directory, project)
|
|
69
|
+
worker.claude.effort = effort
|
|
70
|
+
tasks = []
|
|
71
|
+
try:
|
|
72
|
+
await worker.start()
|
|
73
|
+
print("PROJECT " + worker.cwd, flush=True)
|
|
74
|
+
print("Read/Edit ask on your phone. Stop cancels the current turn. Ctrl+C disconnects.", flush=True)
|
|
75
|
+
print("Resume: penny remote --resume " + shlex.quote(str(directory)), flush=True)
|
|
76
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
|
|
77
|
+
asyncio.create_task(worker.process_turns())]
|
|
78
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
79
|
+
for task in done:
|
|
80
|
+
task.result()
|
|
81
|
+
finally:
|
|
82
|
+
for task in tasks:
|
|
83
|
+
task.cancel()
|
|
84
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
85
|
+
await worker.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
90
|
+
parser.add_argument("project", nargs="?", default=None, help="Project folder (defaults to current directory)")
|
|
91
|
+
parser.add_argument("--resume", metavar="STATE_FOLDER", help="Resume a saved Penny remote session")
|
|
92
|
+
parser.add_argument("--state-dir", help="State folder for a new session")
|
|
93
|
+
parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
|
|
94
|
+
args = parser.parse_args()
|
|
95
|
+
if args.resume and args.state_dir:
|
|
96
|
+
parser.error("Use --resume or --state-dir, not both.")
|
|
97
|
+
config_root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
98
|
+
directory = args.resume or args.state_dir or str(config_root / "pennyrouter/remote" / str(uuid.uuid4()))
|
|
99
|
+
project = args.project
|
|
100
|
+
if args.resume:
|
|
101
|
+
state_path = Path(directory).expanduser() / "bridge.json"
|
|
102
|
+
if not state_path.is_file():
|
|
103
|
+
parser.error("No saved remote session found in that folder.")
|
|
104
|
+
saved = json.loads(state_path.read_text())
|
|
105
|
+
project = project or saved["cwd"]
|
|
106
|
+
else:
|
|
107
|
+
project = project or str(Path.cwd())
|
|
108
|
+
if (Path(directory).expanduser() / "bridge.json").exists():
|
|
109
|
+
parser.error("State folder already contains a session; use --resume.")
|
|
110
|
+
asyncio.run(run(Path(project).expanduser(), Path(directory).expanduser(), args.effort))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
try:
|
|
115
|
+
main()
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
print("Disconnected. The session remains available to resume.")
|
|
118
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError) as error:
|
|
119
|
+
print(str(error) if isinstance(error, RuntimeError) else "Remote session stopped after a network or timeout failure.")
|
|
120
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Completed-turn restart probe. State survives exit; credentials are never saved.
|
|
2
|
+
|
|
3
|
+
Run with a fresh --state-dir, send one phone message, then rerun the same command.
|
|
4
|
+
Tools are disabled. An unfinished turn blocks resume rather than being replayed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import fcntl
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from rc_echo import BASE, read_login
|
|
17
|
+
from rc_penny import PennyWorker, read_profile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RestartWorker(PennyWorker):
|
|
21
|
+
def __init__(self, client, oauth, profile, model, directory, project=None):
|
|
22
|
+
self.directory = Path(directory).resolve()
|
|
23
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
24
|
+
self.state_path = self.directory / "bridge.json"
|
|
25
|
+
cwd = Path(project).resolve() if project else self.directory / "workspace"
|
|
26
|
+
if project:
|
|
27
|
+
if not cwd.is_dir():
|
|
28
|
+
raise RuntimeError("Project folder does not exist.")
|
|
29
|
+
else:
|
|
30
|
+
cwd.mkdir(mode=0o700, exist_ok=True)
|
|
31
|
+
super().__init__(client, oauth, profile, model, str(cwd))
|
|
32
|
+
self.cwd = str(cwd)
|
|
33
|
+
self.title = "Penny restart recovery test"
|
|
34
|
+
self.completed = False
|
|
35
|
+
self.resuming = self.state_path.exists()
|
|
36
|
+
if self.resuming:
|
|
37
|
+
state = json.loads(self.state_path.read_text())
|
|
38
|
+
if state["phase"] != "idle":
|
|
39
|
+
raise RuntimeError("An unfinished turn was recorded. Resume is blocked to avoid replaying it.")
|
|
40
|
+
if state["model"] != model:
|
|
41
|
+
raise RuntimeError("Resume requires the original model configuration.")
|
|
42
|
+
if state.get("cwd", str(cwd)) != str(cwd):
|
|
43
|
+
raise RuntimeError("Resume requires the original project folder.")
|
|
44
|
+
self.session = state["remote_session"]
|
|
45
|
+
self.local_session = state["local_session"]
|
|
46
|
+
self.last_event_id = state["cursor"]
|
|
47
|
+
self.seen = set(state["seen"])
|
|
48
|
+
self.replies = state["replies"]
|
|
49
|
+
self.claude.session_id = self.local_session
|
|
50
|
+
self.claude.resume = self.replies > 0
|
|
51
|
+
self.claude.persistence_dir = self.directory / "claude"
|
|
52
|
+
self.claude.persistence_dir.mkdir(mode=0o700, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
def save(self, phase):
|
|
55
|
+
state = {"version": 1, "phase": phase, "remote_session": self.session,
|
|
56
|
+
"local_session": self.local_session, "model": self.model,
|
|
57
|
+
"cursor": self.last_event_id, "seen": sorted(self.seen), "replies": self.replies,
|
|
58
|
+
"cwd": self.cwd}
|
|
59
|
+
temporary = self.state_path.with_suffix(".tmp")
|
|
60
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
61
|
+
with os.fdopen(descriptor, "w") as handle:
|
|
62
|
+
json.dump(state, handle)
|
|
63
|
+
handle.flush()
|
|
64
|
+
os.fsync(handle.fileno())
|
|
65
|
+
os.replace(temporary, self.state_path)
|
|
66
|
+
|
|
67
|
+
async def start(self):
|
|
68
|
+
if not self.resuming:
|
|
69
|
+
await super().start()
|
|
70
|
+
else:
|
|
71
|
+
bridge = await self.api("POST", self.path + "/bridge", {}, account=True)
|
|
72
|
+
if bridge["api_base_url"].rstrip("/") != BASE:
|
|
73
|
+
raise RuntimeError("Unexpected worker host; refusing to forward credentials.")
|
|
74
|
+
self.worker_token = bridge["worker_jwt"]
|
|
75
|
+
self.epoch = int(bridge["worker_epoch"])
|
|
76
|
+
await self.status("idle")
|
|
77
|
+
print(f"RESUMED https://claude.ai/code/{self.session}", flush=True)
|
|
78
|
+
self.save("idle")
|
|
79
|
+
|
|
80
|
+
async def handle(self, envelope):
|
|
81
|
+
is_turn = envelope.get("payload", {}).get("type") == "user" and envelope.get("event_id") not in self.seen
|
|
82
|
+
if is_turn:
|
|
83
|
+
self.save("busy")
|
|
84
|
+
await super().handle(envelope)
|
|
85
|
+
self.save("idle")
|
|
86
|
+
if is_turn:
|
|
87
|
+
self.completed = True
|
|
88
|
+
self.stopping = True
|
|
89
|
+
print("Completed turn saved. Restart this command to continue on the same phone link.", flush=True)
|
|
90
|
+
|
|
91
|
+
async def close(self):
|
|
92
|
+
self.stopping = True
|
|
93
|
+
await self.claude.close()
|
|
94
|
+
# Preserve the remote session instead of the disposable probes' archive policy.
|
|
95
|
+
if self.worker_token:
|
|
96
|
+
await self.api("PUT", self.path + "/worker", {
|
|
97
|
+
"worker_epoch": self.epoch, "connection_status": "disconnected"})
|
|
98
|
+
self.oauth = self.worker_token = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def run(directory, seconds):
|
|
102
|
+
directory = Path(directory).resolve()
|
|
103
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
104
|
+
with (directory / "lock").open("w") as lock:
|
|
105
|
+
try:
|
|
106
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
107
|
+
except BlockingIOError:
|
|
108
|
+
raise RuntimeError("Another restart probe is using this state folder.") from None
|
|
109
|
+
profile, model = read_profile()
|
|
110
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
111
|
+
"User-Agent": "penny-rc-restart-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
112
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
113
|
+
if response.status_code != 200:
|
|
114
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
115
|
+
worker = RestartWorker(client, read_login(), profile, model, directory)
|
|
116
|
+
tasks = []
|
|
117
|
+
try:
|
|
118
|
+
await worker.start()
|
|
119
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat())]
|
|
120
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
121
|
+
for task in done:
|
|
122
|
+
task.result()
|
|
123
|
+
if worker.completed:
|
|
124
|
+
# The stream cursor advances after handle() returns.
|
|
125
|
+
worker.save("idle")
|
|
126
|
+
finally:
|
|
127
|
+
for task in tasks:
|
|
128
|
+
task.cancel()
|
|
129
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
130
|
+
await worker.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
135
|
+
parser.add_argument("--state-dir", required=True)
|
|
136
|
+
parser.add_argument("--seconds", type=int, default=900)
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
try:
|
|
139
|
+
asyncio.run(run(args.state_dir, max(1, min(args.seconds, 1800))))
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
pass
|
|
142
|
+
except (RuntimeError, httpx.HTTPError) as error:
|
|
143
|
+
print(str(error) if isinstance(error, RuntimeError) else "Network request failed.")
|
|
144
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Remote file-tool approval probe confined to a disposable test folder.
|
|
2
|
+
|
|
3
|
+
Only Read and Edit are available; every call asks the phone. No shell, MCP,
|
|
4
|
+
permission-mode changes, or arbitrary control commands are forwarded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import copy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from rc_echo import EchoWorker, read_login
|
|
16
|
+
from rc_penny import PennyClaude, PennyWorker, read_profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def confined_input(cwd, request, tool_input):
|
|
20
|
+
if request.get("tool_name") not in {"Read", "Edit"} or not isinstance(tool_input, dict):
|
|
21
|
+
return False
|
|
22
|
+
path = tool_input.get("file_path")
|
|
23
|
+
if not isinstance(path, str) or not path:
|
|
24
|
+
return False
|
|
25
|
+
root = Path(cwd).resolve()
|
|
26
|
+
target = Path(path)
|
|
27
|
+
if not target.is_absolute():
|
|
28
|
+
target = root / target
|
|
29
|
+
try:
|
|
30
|
+
return target.resolve().is_relative_to(root) and target.resolve() != root
|
|
31
|
+
except (OSError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolsWorker(PennyWorker):
|
|
36
|
+
def __init__(self, client, oauth, profile, model, cwd):
|
|
37
|
+
super().__init__(client, oauth, profile, model, cwd)
|
|
38
|
+
self.title = "Penny file approvals test"
|
|
39
|
+
self.cwd = cwd
|
|
40
|
+
self.tools = ["Read", "Edit"]
|
|
41
|
+
self.claude = PennyClaude(profile, model, self.local_session, cwd, self.permission)
|
|
42
|
+
self.pending_permissions = {}
|
|
43
|
+
self.permission_futures = {}
|
|
44
|
+
self.inbox = asyncio.Queue(maxsize=10)
|
|
45
|
+
self.queued_ids = set()
|
|
46
|
+
self.active_turn = False
|
|
47
|
+
self.disconnect_after_turns = 0
|
|
48
|
+
|
|
49
|
+
async def permission(self, event):
|
|
50
|
+
request = event.get("request", {})
|
|
51
|
+
denied = {"behavior": "deny", "message": "Only file operations inside this test folder are permitted."}
|
|
52
|
+
if request.get("subtype") != "can_use_tool" or not confined_input(self.cwd, request, request.get("input")):
|
|
53
|
+
return denied
|
|
54
|
+
request_id = event["request_id"]
|
|
55
|
+
future = asyncio.get_running_loop().create_future()
|
|
56
|
+
saved = copy.deepcopy(event)
|
|
57
|
+
self.pending_permissions[request_id] = saved
|
|
58
|
+
self.permission_futures[request_id] = future
|
|
59
|
+
print(f"Phone approval needed: {request['tool_name']}", flush=True)
|
|
60
|
+
try:
|
|
61
|
+
await self.events({**saved, "session_id": self.local_session, "uuid": self.payload("unused")["uuid"]})
|
|
62
|
+
async with asyncio.timeout(180):
|
|
63
|
+
decision = await future
|
|
64
|
+
if decision.get("behavior") == "allow":
|
|
65
|
+
tool_input = decision.get("updatedInput", request["input"])
|
|
66
|
+
if not confined_input(self.cwd, request, tool_input):
|
|
67
|
+
return denied
|
|
68
|
+
# Accept one tool decision, never persistent permission updates.
|
|
69
|
+
return {"behavior": "allow", "updatedInput": tool_input}
|
|
70
|
+
return {"behavior": "deny", "message": "The user denied this tool from the phone."}
|
|
71
|
+
except TimeoutError:
|
|
72
|
+
return {"behavior": "deny", "message": "Phone approval timed out."}
|
|
73
|
+
finally:
|
|
74
|
+
self.pending_permissions.pop(request_id, None)
|
|
75
|
+
self.permission_futures.pop(request_id, None)
|
|
76
|
+
|
|
77
|
+
async def handle(self, envelope):
|
|
78
|
+
event_id = envelope.get("event_id")
|
|
79
|
+
if not event_id or event_id in self.seen or event_id in self.queued_ids:
|
|
80
|
+
return
|
|
81
|
+
payload = envelope.get("payload", {})
|
|
82
|
+
kind = payload.get("type")
|
|
83
|
+
if kind == "user":
|
|
84
|
+
self.queued_ids.add(event_id)
|
|
85
|
+
await self.inbox.put(envelope)
|
|
86
|
+
return
|
|
87
|
+
if kind == "control_response":
|
|
88
|
+
await self.delivery(event_id, "received")
|
|
89
|
+
response = payload.get("response", {})
|
|
90
|
+
request_id = response.get("request_id")
|
|
91
|
+
future = self.permission_futures.get(request_id)
|
|
92
|
+
if future is not None and not future.done():
|
|
93
|
+
decision = response.get("response", {}) if response.get("subtype") == "success" else {}
|
|
94
|
+
future.set_result(decision)
|
|
95
|
+
print(f"Phone decision: {decision.get('behavior', 'deny')}", flush=True)
|
|
96
|
+
else:
|
|
97
|
+
print("Ignored unmatched permission response", flush=True)
|
|
98
|
+
await self.delivery(event_id, "processed")
|
|
99
|
+
self.seen.add(event_id)
|
|
100
|
+
return
|
|
101
|
+
if kind == "control_request" and payload.get("request", {}).get("subtype") == "interrupt":
|
|
102
|
+
await self.delivery(event_id, "received")
|
|
103
|
+
for future in self.permission_futures.values():
|
|
104
|
+
if not future.done():
|
|
105
|
+
future.set_result({"behavior": "deny"})
|
|
106
|
+
if self.active_turn:
|
|
107
|
+
await self.claude.interrupt(payload["request_id"])
|
|
108
|
+
else:
|
|
109
|
+
await self.events(self.payload("control_response", response={
|
|
110
|
+
"subtype": "success", "request_id": payload.get("request_id"), "response": {}}))
|
|
111
|
+
await self.delivery(event_id, "processed")
|
|
112
|
+
self.seen.add(event_id)
|
|
113
|
+
return
|
|
114
|
+
await EchoWorker.handle(self, envelope)
|
|
115
|
+
|
|
116
|
+
async def process_turns(self):
|
|
117
|
+
while not self.stopping:
|
|
118
|
+
envelope = await self.inbox.get()
|
|
119
|
+
self.active_turn = True
|
|
120
|
+
try:
|
|
121
|
+
await EchoWorker.handle(self, envelope)
|
|
122
|
+
if self.disconnect_after_turns and self.replies == self.disconnect_after_turns:
|
|
123
|
+
self.force_reconnect.set()
|
|
124
|
+
finally:
|
|
125
|
+
self.active_turn = False
|
|
126
|
+
self.queued_ids.discard(envelope["event_id"])
|
|
127
|
+
self.inbox.task_done()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def run(seconds, effort="low", disconnect_after_turns=0):
|
|
131
|
+
profile, model = read_profile()
|
|
132
|
+
async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
|
|
133
|
+
"User-Agent": "penny-rc-file-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
|
|
134
|
+
response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
|
|
135
|
+
if response.status_code != 200:
|
|
136
|
+
raise RuntimeError("Configured Penny gateway is not healthy.")
|
|
137
|
+
with tempfile.TemporaryDirectory(prefix="penny-rc-files-") as cwd:
|
|
138
|
+
(Path(cwd) / "note.txt").write_text("original test content\n")
|
|
139
|
+
worker = ToolsWorker(client, read_login(), profile, model, cwd)
|
|
140
|
+
worker.claude.effort = effort
|
|
141
|
+
worker.disconnect_after_turns = disconnect_after_turns
|
|
142
|
+
tasks = []
|
|
143
|
+
try:
|
|
144
|
+
await worker.start()
|
|
145
|
+
print("TEST FOLDER " + cwd, flush=True)
|
|
146
|
+
tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
|
|
147
|
+
asyncio.create_task(worker.process_turns())]
|
|
148
|
+
done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
149
|
+
for task in done:
|
|
150
|
+
task.result()
|
|
151
|
+
finally:
|
|
152
|
+
for task in tasks:
|
|
153
|
+
task.cancel()
|
|
154
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
155
|
+
await worker.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
160
|
+
parser.add_argument("--seconds", type=int, default=900)
|
|
161
|
+
parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
|
|
162
|
+
parser.add_argument("--disconnect-after-turns", type=int, default=0,
|
|
163
|
+
help="Simulate one three-second stream drop after this many completed turns")
|
|
164
|
+
args = parser.parse_args()
|
|
165
|
+
try:
|
|
166
|
+
asyncio.run(run(max(1, min(args.seconds, 1800)), args.effort, args.disconnect_after_turns))
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
pass
|
|
169
|
+
except (RuntimeError, TimeoutError, httpx.HTTPError):
|
|
170
|
+
print("File approval probe stopped after a protocol, gateway, or timeout failure.")
|
|
171
|
+
raise SystemExit(1)
|