agents_control 0.2.1 → 0.3.0
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.
- checksums.yaml +4 -4
- data/README.md +131 -2
- data/lib/agents_control/agents/claude_code.rb +15 -0
- data/lib/agents_control/channels/telegram/api.rb +74 -0
- data/lib/agents_control/channels/telegram/router.rb +367 -19
- data/lib/agents_control/channels/telegram/settings_menu.rb +12 -2
- data/lib/agents_control/cli.rb +103 -1
- data/lib/agents_control/config.rb +16 -1
- data/lib/agents_control/daemon.rb +22 -3
- data/lib/agents_control/doctor.rb +19 -1
- data/lib/agents_control/inbox.rb +161 -0
- data/lib/agents_control/passphrase.rb +154 -0
- data/lib/agents_control/policy.rb +94 -0
- data/lib/agents_control/screen_watcher.rb +14 -3
- data/lib/agents_control/secrets.rb +5 -1
- data/lib/agents_control/terminals/base.rb +12 -1
- data/lib/agents_control/version.rb +1 -1
- data/lib/agents_control.rb +4 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7cbdfdaec647d99052be4572b93c02e8a1ba8f1169219a85c3abebb1ce1d794b
|
|
4
|
+
data.tar.gz: 5d486d2543a88f451eec31e8261ddf8fafb8170446f09f1f13596b07e639f19a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e47eca628d62039dd339691b713ad2b123f5fe9205b0394f623fe51806cbb0711b3ee93f5708548855049d6ebf3631776276e8f21b3b977e776392f7bb842be1
|
|
7
|
+
data.tar.gz: cdec6b32aade79e9b165c72ac9fc121116b1a90ab1c7e16066737561653c9e5090ab87b593e55da04ca78dbe02099a4903bc626b008fe16170a448a65ab8ecf3
|
data/README.md
CHANGED
|
@@ -89,6 +89,14 @@ agents_control # after that, just open the console
|
|
|
89
89
|
The wizard catches your `chat_id` from your own message and adds it to
|
|
90
90
|
the allowed list — no need to type in a long number by hand.
|
|
91
91
|
|
|
92
|
+
At the end it offers to set a **passphrase for destructive commands**.
|
|
93
|
+
A few commands are held back when they arrive from Telegram — `rm -rf ~`,
|
|
94
|
+
`mkfs`, `dd` writing to a raw device, a fork bomb. With a passphrase set
|
|
95
|
+
you're asked to type it before one of those is sent; without one they
|
|
96
|
+
ask for a tap on a button instead. It's optional and skippable, and
|
|
97
|
+
`agents_control passphrase set` adds one later. See
|
|
98
|
+
[Destructive commands ask first](#destructive-commands-ask-first).
|
|
99
|
+
|
|
92
100
|
Bot commands:
|
|
93
101
|
|
|
94
102
|
| Command | What it does |
|
|
@@ -126,6 +134,14 @@ shell command that finishes in a couple of seconds, so there's no
|
|
|
126
134
|
telling Telegram when it's actually done or needs something, the same
|
|
127
135
|
as replying to one of its own questions.
|
|
128
136
|
|
|
137
|
+
A tab stuck inside `less`, `vim`, a REPL, or anything else that reads
|
|
138
|
+
keystrokes as its own input rather than a line to submit gets a
|
|
139
|
+
confirmation first instead of a blind send — the text would go to
|
|
140
|
+
whatever's actually running there, not run as a command. A pager not
|
|
141
|
+
on that recognized list still gets caught: a bare `:` as the entire
|
|
142
|
+
last line is the one thing practically every pager agrees on for
|
|
143
|
+
"waiting on you," and a real shell prompt never looks like that.
|
|
144
|
+
|
|
129
145
|
The result stays a live target: replying to it — "y", "n", anything —
|
|
130
146
|
types straight into that same pane and shows what came back, so
|
|
131
147
|
something like `git add -p`'s hunk-by-hunk prompts works as an actual
|
|
@@ -143,6 +159,50 @@ that, it's Telegram's client caching the old list, not a missing step
|
|
|
143
159
|
on your end: close and reopen the chat, or restart the Telegram app,
|
|
144
160
|
to force it to refresh.
|
|
145
161
|
|
|
162
|
+
### Sending files in
|
|
163
|
+
|
|
164
|
+
Photos, videos, voice notes and documents — logs, a CSV, a spreadsheet,
|
|
165
|
+
a screenshot — can be sent to the bot and handed to a session. The file
|
|
166
|
+
is downloaded to `~/.local/state/agents_control/inbox/` and what reaches
|
|
167
|
+
the pane is its **local path**, so the agent opens it the way it opens
|
|
168
|
+
anything else:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
you › [screenshot.png] caption: /run 3 why does this render wrong?
|
|
172
|
+
|
|
173
|
+
pane › why does this render wrong? /Users/you/.local/state/agents_control/inbox/20260907-171530-screenshot.png
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Three ways to say where it goes:
|
|
177
|
+
|
|
178
|
+
| | |
|
|
179
|
+
|---|---|
|
|
180
|
+
| caption `/run N …` | that tab, with the rest of the caption as the message |
|
|
181
|
+
| reply with a file to a session's own message | that session |
|
|
182
|
+
| neither | saved, and the path comes back so you can use it |
|
|
183
|
+
|
|
184
|
+
Guessing a target for a file is how it ends up pasted into the wrong
|
|
185
|
+
project, so with nothing to go on it isn't guessed. However it gets
|
|
186
|
+
there, a file's path is checked and escaped like anything else typed
|
|
187
|
+
into a pane — a caption is a way of saying `/run`, so it's guarded like
|
|
188
|
+
one.
|
|
189
|
+
|
|
190
|
+
**A path and never a link, deliberately.** Telegram serves file bytes
|
|
191
|
+
from `https://api.telegram.org/file/bot<TOKEN>/…` — the download URL
|
|
192
|
+
*is* the bot token. Pasting that into a pane would put the token in the
|
|
193
|
+
terminal, in the agent's context, and in the shell history, and a
|
|
194
|
+
leaked token is remote code execution on this machine. The URL is built
|
|
195
|
+
inside the API client and never leaves it.
|
|
196
|
+
|
|
197
|
+
The name comes from whoever sent the file, so it doesn't get to be a
|
|
198
|
+
path: no separators, no `..`, no leading dot, and cut to a length any
|
|
199
|
+
filesystem takes. Files are written `0600` inside a `0700` directory —
|
|
200
|
+
logs and screenshots are exactly what carries a token or a customer's
|
|
201
|
+
name through in passing — and swept after `telegram.inbox_keep_days`
|
|
202
|
+
(14 by default), since an agent reads them within minutes and the
|
|
203
|
+
directory would otherwise only grow. Telegram caps what a bot can
|
|
204
|
+
download at 20MB; anything larger is refused before the round trip.
|
|
205
|
+
|
|
146
206
|
### Two modes
|
|
147
207
|
|
|
148
208
|
This whole section is about Claude Code specifically — its hooks are
|
|
@@ -249,8 +309,10 @@ the full `Registry` — they see bare iTerm2 tabs and tmux panes alike.
|
|
|
249
309
|
|
|
250
310
|
**CLI menus** (`terminal.watch_menus`, on by default, polled every 20
|
|
251
311
|
seconds — `terminal.menu_poll_interval`). Notices the "❯ 1. … / 2. …"
|
|
252
|
-
pattern Claude Code uses to draw any choice
|
|
253
|
-
|
|
312
|
+
pattern Claude Code (or a skill's own wizard) uses to draw any choice —
|
|
313
|
+
options can carry a couple of lines of description each, or a divider
|
|
314
|
+
before a trailing one — and sends it to Telegram as buttons; pressing
|
|
315
|
+
one types the option's number straight into the pane.
|
|
254
316
|
|
|
255
317
|
**Limit reset** (`answers.auto_resume_after_limit`, on by default,
|
|
256
318
|
polled once a minute — `terminal.rate_limit_poll_interval`). Notices a
|
|
@@ -308,6 +370,73 @@ This tool lets you run commands on your machine from Telegram. That comes with s
|
|
|
308
370
|
- Auto-replying "continue" is on by default; automatic tool approval is
|
|
309
371
|
off. These are separate settings on purpose.
|
|
310
372
|
|
|
373
|
+
### Destructive commands ask first
|
|
374
|
+
|
|
375
|
+
A few commands are held back when they arrive from Telegram: a
|
|
376
|
+
recursive force delete aimed at `/` or `$HOME`, a fork bomb, `mkfs`,
|
|
377
|
+
`dd` writing to a raw device. Each comes back saying what it actually
|
|
378
|
+
does — "this recursively deletes `~` — there is no undo" — rather than
|
|
379
|
+
which rule caught it. `rm -rf /tmp/build` goes straight through, as it
|
|
380
|
+
should: the list is a seatbelt for a thumb on the wrong line in a tab
|
|
381
|
+
listing, not a policy engine, and it stays short enough to hold in
|
|
382
|
+
your head.
|
|
383
|
+
|
|
384
|
+
By default that's a confirmation button, and the first times one of
|
|
385
|
+
these turns up the bot offers a passphrase alongside it — that's the
|
|
386
|
+
one moment the reason for it is on screen. **Don't ask again** makes
|
|
387
|
+
the refusal stick; `/settings` keeps the offer reachable either way.
|
|
388
|
+
The bot also says so once, on the first daemon start with no
|
|
389
|
+
passphrase set, because someone who never types a destructive command
|
|
390
|
+
themselves would otherwise never learn the option exists — and the
|
|
391
|
+
first one that does turn up might not be theirs.
|
|
392
|
+
|
|
393
|
+
A passphrase can be set from the chat itself (two entries, both
|
|
394
|
+
deleted) or with `agents_control passphrase set` at the machine. Set
|
|
395
|
+
from the chat, it guards against whoever gets to that chat *later*,
|
|
396
|
+
not whoever has it right now — the bot says so when it saves. That's
|
|
397
|
+
the honest trade: the alternative is that most people never set one,
|
|
398
|
+
since you only think about this while away from the keyboard.
|
|
399
|
+
|
|
400
|
+
Set a passphrase — during `agents_control setup`, from the bot, or any
|
|
401
|
+
time after — and the button becomes a question instead:
|
|
402
|
+
|
|
403
|
+
```sh
|
|
404
|
+
agents_control passphrase set # entered without echo, stored in the Keychain
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
you › /run 3 rm -rf ~
|
|
409
|
+
|
|
410
|
+
bot › this recursively deletes ~ — there is no undo
|
|
411
|
+
|
|
412
|
+
rm -rf ~
|
|
413
|
+
|
|
414
|
+
Reply to this message with the passphrase to send it to
|
|
415
|
+
mobile-app. Anything else cancels.
|
|
416
|
+
|
|
417
|
+
you › ················ ← deleted from the chat once read
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A tap answers "did you mean this?" — which covers the wrong tab
|
|
421
|
+
number. A passphrase answers "are you you?", which is the other half:
|
|
422
|
+
an unlocked phone in someone else's hand taps just as well as its
|
|
423
|
+
owner does. It replaces the button rather than joining it — two steps
|
|
424
|
+
for one decision is how people learn to hurry through both — and the
|
|
425
|
+
message carrying it is deleted from the chat the moment it's read,
|
|
426
|
+
right or wrong, since a chat history syncs to every device the account
|
|
427
|
+
is signed in on.
|
|
428
|
+
|
|
429
|
+
The passphrase is stored derived (PBKDF2-HMAC-SHA256, salted) next to
|
|
430
|
+
the bot token, and is never accepted as a command-line argument.
|
|
431
|
+
`agents_control passphrase clear` goes back to the button.
|
|
432
|
+
|
|
433
|
+
## Changes between versions
|
|
434
|
+
|
|
435
|
+
[CHANGELOG.md](CHANGELOG.md) — what changed and why, newest first. Read
|
|
436
|
+
the current entry before upgrading if you rely on `/run`: while the
|
|
437
|
+
major is 0, a minor bump may change behaviour, and the entry says so
|
|
438
|
+
where it does.
|
|
439
|
+
|
|
311
440
|
## Development
|
|
312
441
|
|
|
313
442
|
```sh
|
|
@@ -31,6 +31,21 @@ module AgentsControl
|
|
|
31
31
|
|
|
32
32
|
def binaries = %w[claude]
|
|
33
33
|
|
|
34
|
+
# Our own Stop entry, found by the marker rather than by
|
|
35
|
+
# position. merge_hook appends ours last, so index 0 is someone
|
|
36
|
+
# else's the moment the user has a Stop hook of their own —
|
|
37
|
+
# which is common, and which made the startup self-check probe a
|
|
38
|
+
# stranger's endpoint with a stranger's credentials.
|
|
39
|
+
def own_hook(path = settings_path)
|
|
40
|
+
return unless File.exist?(path)
|
|
41
|
+
|
|
42
|
+
groups = JSON.parse(File.read(path)).dig("hooks", "Stop")
|
|
43
|
+
Array(groups).flat_map { |group| Array(group["hooks"]) }
|
|
44
|
+
.find { |hook| hook["_agents_control"] }
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
def handles?(payload)
|
|
35
50
|
payload.is_a?(Hash) && payload.key?("hook_event_name")
|
|
36
51
|
end
|
|
@@ -26,6 +26,10 @@ module AgentsControl
|
|
|
26
26
|
# noticeably shorter than that.
|
|
27
27
|
CALL_TIMEOUT = 8
|
|
28
28
|
|
|
29
|
+
# A phone on a train uploading a screen recording is a slow
|
|
30
|
+
# request that's still working, not a stuck one.
|
|
31
|
+
DOWNLOAD_TIMEOUT = 120
|
|
32
|
+
|
|
29
33
|
Error = Class.new(AgentsControl::Error)
|
|
30
34
|
|
|
31
35
|
# Another process is already reading updates with this same
|
|
@@ -66,6 +70,26 @@ module AgentsControl
|
|
|
66
70
|
)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
# Two steps by design on Telegram's side: getFile resolves a
|
|
74
|
+
# file_id to a path that's valid for about an hour, and the
|
|
75
|
+
# bytes come from a different host prefix.
|
|
76
|
+
def file_path_for(file_id)
|
|
77
|
+
call("getFile", { file_id: file_id })["file_path"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Streams to disk rather than through memory: a 20MB video read
|
|
81
|
+
# into a String would sit in the daemon's heap for as long as
|
|
82
|
+
# Ruby felt like keeping it.
|
|
83
|
+
#
|
|
84
|
+
# The download URL carries the token in its path, which is why
|
|
85
|
+
# this method builds it and nothing else ever sees it — no
|
|
86
|
+
# caller, no log line, no message back to the chat.
|
|
87
|
+
def download(file_path, to:, max_bytes: nil)
|
|
88
|
+
@http.get_to_file(file_url_for(file_path), to, read_timeout: DOWNLOAD_TIMEOUT, max_bytes: max_bytes)
|
|
89
|
+
rescue Http::NetworkError => e
|
|
90
|
+
raise Unavailable, redact(e.message)
|
|
91
|
+
end
|
|
92
|
+
|
|
69
93
|
def send_message(chat_id:, text:, reply_markup: nil, parse_mode: nil)
|
|
70
94
|
call("sendMessage", {
|
|
71
95
|
chat_id: chat_id, text: text, parse_mode: parse_mode,
|
|
@@ -82,6 +106,16 @@ module AgentsControl
|
|
|
82
106
|
|
|
83
107
|
# The command menu in the Telegram UI: a button next to the
|
|
84
108
|
# input field instead of hunting through the chat for a list.
|
|
109
|
+
# Best-effort on purpose: a passphrase typed into a chat is
|
|
110
|
+
# deleted the moment it's read, and a bot that can't delete it
|
|
111
|
+
# (too old, permissions) must still not leave the command it
|
|
112
|
+
# was unlocking half-done.
|
|
113
|
+
def delete_message(chat_id:, message_id:)
|
|
114
|
+
call("deleteMessage", chat_id: chat_id, message_id: message_id)
|
|
115
|
+
rescue Error, Unavailable
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
85
119
|
def set_my_commands(commands)
|
|
86
120
|
payload = commands.map { |name, description| { command: name, description: description } }
|
|
87
121
|
|
|
@@ -128,6 +162,17 @@ module AgentsControl
|
|
|
128
162
|
|
|
129
163
|
def url_for(method) = URI("https://#{HOST}/bot#{@token}/#{method}")
|
|
130
164
|
|
|
165
|
+
# Telegram serves file bytes from /file/bot<token>/<path>.
|
|
166
|
+
# Escaped per path segment: the path comes back from the API
|
|
167
|
+
# and gets to decide part of a URL, so it doesn't get to smuggle
|
|
168
|
+
# a "?" or a "../" into one.
|
|
169
|
+
def file_url_for(file_path)
|
|
170
|
+
segments = file_path.to_s.split("/").reject(&:empty?).map { |part| URI::DEFAULT_PARSER.escape(part) }
|
|
171
|
+
raise Error, "getFile: refusing a path that escapes its directory" if segments.any? { |s| s == ".." }
|
|
172
|
+
|
|
173
|
+
URI("https://#{HOST}/file/bot#{@token}/#{segments.join('/')}")
|
|
174
|
+
end
|
|
175
|
+
|
|
131
176
|
# Drop nil fields: Telegram treats them as values that were actually passed.
|
|
132
177
|
def compact(params) = params.compact
|
|
133
178
|
|
|
@@ -156,6 +201,35 @@ module AgentsControl
|
|
|
156
201
|
class Http
|
|
157
202
|
NetworkError = Class.new(StandardError)
|
|
158
203
|
|
|
204
|
+
# Written straight to the open file as chunks arrive. The caller
|
|
205
|
+
# owns the path; a failure part-way leaves a partial file, which
|
|
206
|
+
# is why callers download to a temporary name and rename.
|
|
207
|
+
# max_bytes is counted as the bytes arrive, not taken from a
|
|
208
|
+
# header or from what the sender said the file weighs: both are
|
|
209
|
+
# claims, and the disk fills either way if they're wrong.
|
|
210
|
+
def get_to_file(uri, path, read_timeout:, max_bytes: nil)
|
|
211
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
212
|
+
use_ssl: true, open_timeout: 10, read_timeout: read_timeout) do |http|
|
|
213
|
+
http.request(Net::HTTP::Get.new(uri)) do |response|
|
|
214
|
+
raise NetworkError, "download failed (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess)
|
|
215
|
+
|
|
216
|
+
written = 0
|
|
217
|
+
File.open(path, "wb") do |file|
|
|
218
|
+
response.read_body do |chunk|
|
|
219
|
+
written += chunk.bytesize
|
|
220
|
+
raise NetworkError, "the file is larger than expected" if max_bytes && written > max_bytes
|
|
221
|
+
|
|
222
|
+
file.write(chunk)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
path
|
|
229
|
+
rescue SystemCallError, Timeout::Error, IOError, OpenSSL::SSL::SSLError, SocketError => e
|
|
230
|
+
raise NetworkError, e.message
|
|
231
|
+
end
|
|
232
|
+
|
|
159
233
|
def post(uri, params, read_timeout:)
|
|
160
234
|
request = Net::HTTP::Post.new(uri)
|
|
161
235
|
request.set_form_data(params)
|