@borgee/agents-host 0.2.33 → 0.2.44
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 +28 -7
- package/dist/agents-host.d.ts +19 -0
- package/dist/agents-host.js +163 -44
- package/dist/chat/chat-control-plane.d.ts +9 -0
- package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
- package/dist/chat/sdk-chat-control-plane.js +9 -0
- package/dist/cli-args.d.ts +1 -1
- package/dist/cli-args.js +5 -0
- package/dist/compatibility-gates.d.ts +1 -0
- package/dist/compatibility-gates.js +2 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +21 -3
- package/dist/context/injection.d.ts +5 -3
- package/dist/context/injection.js +45 -29
- package/dist/context/main-session-delegation.d.ts +1 -0
- package/dist/context/main-session-delegation.js +6 -0
- package/dist/context/prompt.js +25 -35
- package/dist/context/skill-manual.d.ts +13 -0
- package/dist/context/skill-manual.js +18 -0
- package/dist/context/turn-preparation.js +13 -4
- package/dist/gateway/localhost-gateway.js +75 -1
- package/dist/hosted-turn-content.d.ts +15 -0
- package/dist/hosted-turn-content.js +50 -0
- package/dist/local-config.js +10 -1
- package/dist/managed-daemon.d.ts +3 -2
- package/dist/managed-daemon.js +82 -29
- package/dist/plugin-sdk.js +58 -1
- package/dist/plugin-sdk.js.map +2 -2
- package/dist/policy/gateway-authorization.d.ts +18 -3
- package/dist/policy/gateway-authorization.js +33 -1
- package/dist/providers/claude/adapter.d.ts +3 -1
- package/dist/providers/claude/adapter.js +13 -1
- package/dist/providers/claude/cli-client.d.ts +36 -3
- package/dist/providers/claude/cli-client.js +225 -37
- package/dist/providers/codex/adapter.d.ts +3 -1
- package/dist/providers/codex/adapter.js +13 -1
- package/dist/providers/codex/cli-client.d.ts +35 -3
- package/dist/providers/codex/cli-client.js +212 -31
- package/dist/providers/codex/project-doc.js +16 -29
- package/dist/providers/copilot/adapter.d.ts +3 -1
- package/dist/providers/copilot/adapter.js +13 -1
- package/dist/providers/copilot/cli-client.d.ts +34 -2
- package/dist/providers/copilot/cli-client.js +196 -18
- package/dist/providers/create-provider.d.ts +1 -1
- package/dist/providers/create-provider.js +30 -14
- package/dist/providers/idle-backend-shutdown.d.ts +16 -0
- package/dist/providers/idle-backend-shutdown.js +53 -0
- package/dist/providers/provider-adapter.d.ts +35 -0
- package/dist/providers/provider-adapter.js +44 -1
- package/dist/state-paths.d.ts +9 -1
- package/dist/state-paths.js +22 -3
- package/dist/types.d.ts +40 -2
- package/package.json +2 -2
- package/skills/borgee-agent/SKILL.md +133 -35
- package/skills/borgee-agent/references/errors.md +38 -0
- package/skills/borgee-agent/references/task-properties.md +30 -0
- package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
- package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
- package/skills/borgee-agent/borgee-agent.mjs +0 -507
- package/skills/borgee-agent/borgee-agent.py +0 -438
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
12
|
+
|
|
13
|
+
MANUAL = """borgee-agent — read the Borgee channel this turn runs in, and act on its tasks.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
borgee-agent --gateway <absolute path> <command> [arguments]
|
|
17
|
+
borgee-agent --help
|
|
18
|
+
|
|
19
|
+
Commands:
|
|
20
|
+
health
|
|
21
|
+
Gateway reachability.
|
|
22
|
+
bootstrap
|
|
23
|
+
Channel bootstrap snapshot.
|
|
24
|
+
whoami
|
|
25
|
+
This agent's identity in this channel.
|
|
26
|
+
history [--limit <n>] [--before <n>] [--after <n>]
|
|
27
|
+
Recent messages in this channel.
|
|
28
|
+
users
|
|
29
|
+
Participants visible in this channel.
|
|
30
|
+
draft --turn-execution-id <id>
|
|
31
|
+
This turn's host-private in-flight draft.
|
|
32
|
+
send --body <text> --turn-execution-id <id> (--reply-to <message-id> | --mention <user-id>)...
|
|
33
|
+
Post a short auxiliary message. It must address someone.
|
|
34
|
+
mention <user-id> --body <text> --turn-execution-id <id> [--reply-to <message-id>]
|
|
35
|
+
Post a short auxiliary message addressed to one participant.
|
|
36
|
+
task list
|
|
37
|
+
Tasks in this channel. Parent channel only.
|
|
38
|
+
task create --title <text> [--description <text>] [--assignee-id <user-id>]
|
|
39
|
+
Create a task. Parent channel only.
|
|
40
|
+
task get [<task-id>]
|
|
41
|
+
One task with its properties.
|
|
42
|
+
task update [<task-id>] [--status <open|in_progress|in_review|done|cancelled>] [--title <text>] [--description <text>] [--assignee-id <user-id>]
|
|
43
|
+
Update the task. At least one field is required.
|
|
44
|
+
task history <task-id> [--limit <n>] [--before <n>] [--after <n>]
|
|
45
|
+
Messages in that task's thread.
|
|
46
|
+
task set-property [<task-id>] --key <key> --value <value>
|
|
47
|
+
Set one task property.
|
|
48
|
+
task delete-property [<task-id>] --key <key>
|
|
49
|
+
Remove one task property.
|
|
50
|
+
|
|
51
|
+
Inside a task thread every task command with an optional task id acts on this thread's own task when the id is omitted, and task list / task create are refused there. In a parent channel the task id is required.
|
|
52
|
+
|
|
53
|
+
Exit codes: 0 success, 1 gateway failure, 2 usage error.
|
|
54
|
+
|
|
55
|
+
The full manual, including the task property registry and the error table, is SKILL.md in this skill directory."""
|
|
56
|
+
|
|
57
|
+
# Python ints are arbitrary precision, so nothing can be rounded on the way into the query string;
|
|
58
|
+
# the bound is here only so both packaged CLIs accept and reject exactly the same inputs. It is the
|
|
59
|
+
# Node CLI that needs it: there an out-of-range magnitude re-stringifies as `1e+21`, which the
|
|
60
|
+
# gateway's `Number.parseInt(value, 10)` reads back as `1`.
|
|
61
|
+
INTEGER_ARGUMENT_PATTERN = re.compile(r"[+-]?[0-9]+")
|
|
62
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
63
|
+
|
|
64
|
+
# Spelled out instead of `str.isspace()` because the Node CLI must trim arguments and append the
|
|
65
|
+
# mention separator on exactly the same inputs, and the two languages disagree on U+00A0, U+3000,
|
|
66
|
+
# U+FEFF and U+001C…U+001F.
|
|
67
|
+
PORTABLE_WHITESPACE = " \t\n\r\f\v"
|
|
68
|
+
|
|
69
|
+
COMMANDS = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"health",
|
|
72
|
+
"bootstrap",
|
|
73
|
+
"whoami",
|
|
74
|
+
"history",
|
|
75
|
+
"users",
|
|
76
|
+
"draft",
|
|
77
|
+
"send",
|
|
78
|
+
"mention",
|
|
79
|
+
"task",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
TASK_COMMANDS = frozenset(
|
|
84
|
+
{
|
|
85
|
+
"list",
|
|
86
|
+
"create",
|
|
87
|
+
"get",
|
|
88
|
+
"update",
|
|
89
|
+
"history",
|
|
90
|
+
"set-property",
|
|
91
|
+
"delete-property",
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UsageError(Exception):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def trim_argument(value: str) -> str:
|
|
101
|
+
return value.strip(PORTABLE_WHITESPACE)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_non_empty_string(value: object) -> bool:
|
|
105
|
+
return isinstance(value, str) and bool(trim_argument(value))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_args(argv: list[str]) -> dict[str, object]:
|
|
109
|
+
positionals: list[str] = []
|
|
110
|
+
mentions: list[str] = []
|
|
111
|
+
gateway_credential_path: str | None = None
|
|
112
|
+
turn_execution_id: str | None = None
|
|
113
|
+
limit: int | None = None
|
|
114
|
+
before: int | None = None
|
|
115
|
+
after: int | None = None
|
|
116
|
+
body: str | None = None
|
|
117
|
+
reply_to_id: str | None = None
|
|
118
|
+
title: str | None = None
|
|
119
|
+
description: str | None = None
|
|
120
|
+
assignee_id: str | None = None
|
|
121
|
+
status: str | None = None
|
|
122
|
+
property_key: str | None = None
|
|
123
|
+
property_value: str | None = None
|
|
124
|
+
index = 0
|
|
125
|
+
|
|
126
|
+
def take_value(flag: str) -> str:
|
|
127
|
+
nonlocal index
|
|
128
|
+
index += 1
|
|
129
|
+
if index >= len(argv):
|
|
130
|
+
raise UsageError(f"Missing value after {flag}")
|
|
131
|
+
return argv[index]
|
|
132
|
+
|
|
133
|
+
def take_integer(flag: str) -> int:
|
|
134
|
+
value = take_value(flag)
|
|
135
|
+
if not INTEGER_ARGUMENT_PATTERN.fullmatch(value):
|
|
136
|
+
raise UsageError(f"Invalid integer for {flag}: {value}")
|
|
137
|
+
parsed = int(value)
|
|
138
|
+
if abs(parsed) > MAX_SAFE_INTEGER:
|
|
139
|
+
raise UsageError(f"Integer out of range for {flag}: {value}")
|
|
140
|
+
return parsed
|
|
141
|
+
|
|
142
|
+
while index < len(argv):
|
|
143
|
+
arg = argv[index]
|
|
144
|
+
if arg in ("--help", "-h"):
|
|
145
|
+
return {"help": True}
|
|
146
|
+
if not arg.startswith("-"):
|
|
147
|
+
positionals.append(arg)
|
|
148
|
+
elif arg == "--gateway":
|
|
149
|
+
gateway_credential_path = take_value(arg)
|
|
150
|
+
elif arg == "--turn-execution-id":
|
|
151
|
+
turn_execution_id = trim_argument(take_value(arg))
|
|
152
|
+
elif arg == "--limit":
|
|
153
|
+
limit = take_integer(arg)
|
|
154
|
+
elif arg == "--before":
|
|
155
|
+
before = take_integer(arg)
|
|
156
|
+
elif arg == "--after":
|
|
157
|
+
after = take_integer(arg)
|
|
158
|
+
elif arg == "--body":
|
|
159
|
+
body = take_value(arg)
|
|
160
|
+
elif arg == "--reply-to":
|
|
161
|
+
reply_to_id = take_value(arg)
|
|
162
|
+
elif arg == "--mention":
|
|
163
|
+
mention = trim_argument(take_value(arg))
|
|
164
|
+
if not mention:
|
|
165
|
+
raise UsageError("Missing value after --mention")
|
|
166
|
+
mentions.append(mention)
|
|
167
|
+
elif arg == "--title":
|
|
168
|
+
title = take_value(arg)
|
|
169
|
+
elif arg == "--description":
|
|
170
|
+
description = take_value(arg)
|
|
171
|
+
elif arg == "--assignee-id":
|
|
172
|
+
assignee_id = take_value(arg)
|
|
173
|
+
elif arg == "--status":
|
|
174
|
+
status = take_value(arg)
|
|
175
|
+
elif arg == "--key":
|
|
176
|
+
property_key = trim_argument(take_value(arg))
|
|
177
|
+
elif arg == "--value":
|
|
178
|
+
property_value = take_value(arg)
|
|
179
|
+
else:
|
|
180
|
+
raise UsageError(f"Unknown argument: {arg}")
|
|
181
|
+
index += 1
|
|
182
|
+
|
|
183
|
+
if not positionals:
|
|
184
|
+
return {"help": False, "command": None}
|
|
185
|
+
command = positionals[0]
|
|
186
|
+
if command not in COMMANDS:
|
|
187
|
+
raise UsageError(f"Unknown command: {command}")
|
|
188
|
+
|
|
189
|
+
task_command: str | None = None
|
|
190
|
+
task_id: str | None = None
|
|
191
|
+
mention_target_id: str | None = None
|
|
192
|
+
consumed = 1
|
|
193
|
+
if command == "task":
|
|
194
|
+
if len(positionals) < 2:
|
|
195
|
+
raise UsageError(
|
|
196
|
+
"Missing task command; expected one of list, create, get, update, history, set-property, delete-property"
|
|
197
|
+
)
|
|
198
|
+
task_command = positionals[1]
|
|
199
|
+
if task_command not in TASK_COMMANDS:
|
|
200
|
+
raise UsageError(f"Unknown task command: {task_command}")
|
|
201
|
+
consumed = 2
|
|
202
|
+
if task_command not in ("list", "create") and len(positionals) > consumed:
|
|
203
|
+
task_id = trim_argument(positionals[consumed])
|
|
204
|
+
# Only an absent positional may mean the thread's own task. Treating an empty one as
|
|
205
|
+
# absent would send a write the caller meant for a named task to whatever task the
|
|
206
|
+
# thread resolves.
|
|
207
|
+
if not task_id:
|
|
208
|
+
raise UsageError(
|
|
209
|
+
f'Empty task id for "task {task_command}"; pass a task id or omit the argument entirely'
|
|
210
|
+
)
|
|
211
|
+
consumed += 1
|
|
212
|
+
elif command == "mention" and len(positionals) > consumed:
|
|
213
|
+
mention_target_id = trim_argument(positionals[consumed])
|
|
214
|
+
consumed += 1
|
|
215
|
+
if len(positionals) > consumed:
|
|
216
|
+
raise UsageError(f"Unexpected argument: {positionals[consumed]}")
|
|
217
|
+
|
|
218
|
+
if gateway_credential_path is None:
|
|
219
|
+
raise UsageError("Missing required --gateway <path> argument")
|
|
220
|
+
if not os.path.isabs(gateway_credential_path):
|
|
221
|
+
raise UsageError(f"--gateway requires an absolute path: {gateway_credential_path}")
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"help": False,
|
|
225
|
+
"command": command,
|
|
226
|
+
"task_command": task_command,
|
|
227
|
+
"task_id": task_id,
|
|
228
|
+
"mention_target_id": mention_target_id,
|
|
229
|
+
"gateway_credential_path": gateway_credential_path,
|
|
230
|
+
"turn_execution_id": turn_execution_id,
|
|
231
|
+
"limit": limit,
|
|
232
|
+
"before": before,
|
|
233
|
+
"after": after,
|
|
234
|
+
"body": body,
|
|
235
|
+
"reply_to_id": reply_to_id,
|
|
236
|
+
"mentions": mentions,
|
|
237
|
+
"title": title,
|
|
238
|
+
"description": description,
|
|
239
|
+
"assignee_id": assignee_id,
|
|
240
|
+
"status": status,
|
|
241
|
+
"property_key": property_key,
|
|
242
|
+
"property_value": property_value,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def command_label(parsed: dict[str, object]) -> str:
|
|
247
|
+
if parsed["command"] == "task":
|
|
248
|
+
return f"task {parsed['task_command']}"
|
|
249
|
+
return str(parsed["command"])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def require_text(value: object, flag: str, label: str) -> None:
|
|
253
|
+
if not is_non_empty_string(value):
|
|
254
|
+
raise UsageError(f'Missing required {flag} <value> for "{label}"')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def validate_command(parsed: dict[str, object]) -> None:
|
|
258
|
+
label = command_label(parsed)
|
|
259
|
+
command = parsed["command"]
|
|
260
|
+
if command == "draft":
|
|
261
|
+
require_text(parsed["turn_execution_id"], "--turn-execution-id", label)
|
|
262
|
+
return
|
|
263
|
+
if command == "send":
|
|
264
|
+
require_text(parsed["body"], "--body", label)
|
|
265
|
+
require_text(parsed["turn_execution_id"], "--turn-execution-id", label)
|
|
266
|
+
return
|
|
267
|
+
if command == "mention":
|
|
268
|
+
if not is_non_empty_string(parsed["mention_target_id"]):
|
|
269
|
+
raise UsageError('Missing required user id for "mention"')
|
|
270
|
+
if parsed["mentions"]:
|
|
271
|
+
raise UsageError("Do not combine the mention command with --mention flags")
|
|
272
|
+
require_text(parsed["body"], "--body", label)
|
|
273
|
+
require_text(parsed["turn_execution_id"], "--turn-execution-id", label)
|
|
274
|
+
return
|
|
275
|
+
if command != "task":
|
|
276
|
+
return
|
|
277
|
+
task_command = parsed["task_command"]
|
|
278
|
+
if task_command == "create":
|
|
279
|
+
require_text(parsed["title"], "--title", label)
|
|
280
|
+
return
|
|
281
|
+
if task_command == "update":
|
|
282
|
+
updated = (parsed["status"], parsed["title"], parsed["description"], parsed["assignee_id"])
|
|
283
|
+
if all(value is None for value in updated):
|
|
284
|
+
raise UsageError(
|
|
285
|
+
'At least one of --status, --title, --description or --assignee-id is required for "task update"'
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
if task_command == "history":
|
|
289
|
+
if parsed["task_id"] is None:
|
|
290
|
+
raise UsageError('Missing required task id for "task history"')
|
|
291
|
+
return
|
|
292
|
+
if task_command == "set-property":
|
|
293
|
+
require_text(parsed["property_key"], "--key", label)
|
|
294
|
+
# An empty property value is a legitimate string, so presence of the flag is the requirement.
|
|
295
|
+
if parsed["property_value"] is None:
|
|
296
|
+
raise UsageError('Missing required --value <value> for "task set-property"')
|
|
297
|
+
return
|
|
298
|
+
if task_command == "delete-property":
|
|
299
|
+
require_text(parsed["property_key"], "--key", label)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# The credential path is used exactly as handed over: no walk-up, no glob, no environment lookup.
|
|
303
|
+
# Task workspaces live inside the git repository and are not gitignored, so any search would
|
|
304
|
+
# traverse attacker-influenceable directories where a planted file could redirect the gateway base
|
|
305
|
+
# URL or point at another channel's credentials.
|
|
306
|
+
def read_gateway_credential(gateway_credential_path: str) -> dict[str, str]:
|
|
307
|
+
try:
|
|
308
|
+
with open(gateway_credential_path, "rb") as handle:
|
|
309
|
+
encoded = handle.read()
|
|
310
|
+
except FileNotFoundError as exc:
|
|
311
|
+
raise UsageError(f"Gateway credential file not found: {gateway_credential_path}") from exc
|
|
312
|
+
try:
|
|
313
|
+
# Decoded strictly, like the Node CLI: a replacement character would silently mangle the
|
|
314
|
+
# channel id the request is issued against.
|
|
315
|
+
payload = json.loads(encoded.decode("utf-8"))
|
|
316
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
317
|
+
raise UsageError(
|
|
318
|
+
f"{gateway_credential_path} is not a Borgee gateway credential"
|
|
319
|
+
) from exc
|
|
320
|
+
channel_id = payload.get("channelId") if isinstance(payload, dict) else None
|
|
321
|
+
localhost_gateway = payload.get("localhostGateway") if isinstance(payload, dict) else None
|
|
322
|
+
base_url = localhost_gateway.get("baseUrl") if isinstance(localhost_gateway, dict) else None
|
|
323
|
+
token = localhost_gateway.get("token") if isinstance(localhost_gateway, dict) else None
|
|
324
|
+
if not (
|
|
325
|
+
is_non_empty_string(channel_id)
|
|
326
|
+
and is_non_empty_string(base_url)
|
|
327
|
+
and is_non_empty_string(token)
|
|
328
|
+
):
|
|
329
|
+
raise UsageError(f"{gateway_credential_path} is not a Borgee gateway credential")
|
|
330
|
+
return {
|
|
331
|
+
"channel_id": str(channel_id),
|
|
332
|
+
"base_url": str(base_url).rstrip("/"),
|
|
333
|
+
"token": str(token),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def ensure_visible_mention(body: str, participant_id: str) -> str:
|
|
338
|
+
token = f"<@{participant_id}>"
|
|
339
|
+
if token in body:
|
|
340
|
+
return body
|
|
341
|
+
separator = "" if body[-1:] in PORTABLE_WHITESPACE else " "
|
|
342
|
+
return f"{body}{separator}{token}"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def ensure_visible_mentions(body: str, participant_ids: list[str]) -> str:
|
|
346
|
+
next_body = body
|
|
347
|
+
for participant_id in participant_ids:
|
|
348
|
+
next_body = ensure_visible_mention(next_body, participant_id)
|
|
349
|
+
return next_body
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def encode_component(value: str) -> str:
|
|
353
|
+
# quote() escapes more than JavaScript's encodeURIComponent does; widening the safe set to the
|
|
354
|
+
# difference keeps both CLIs issuing byte-identical request lines.
|
|
355
|
+
return quote(value, safe="!~*'()")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def build_query(entries: list[tuple[str, object]]) -> str:
|
|
359
|
+
parts = [
|
|
360
|
+
f"{key}={encode_component(str(value))}" for key, value in entries if value is not None
|
|
361
|
+
]
|
|
362
|
+
return f"?{'&'.join(parts)}" if parts else ""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def history_query(parsed: dict[str, object]) -> str:
|
|
366
|
+
return build_query(
|
|
367
|
+
[("limit", parsed["limit"]), ("before", parsed["before"]), ("after", parsed["after"])]
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def build_message_body(parsed: dict[str, object]) -> dict[str, object]:
|
|
372
|
+
if parsed["command"] == "mention":
|
|
373
|
+
mentions = [str(parsed["mention_target_id"])]
|
|
374
|
+
else:
|
|
375
|
+
mentions = list(parsed["mentions"])
|
|
376
|
+
body = str(parsed["body"])
|
|
377
|
+
request_body: dict[str, object] = {
|
|
378
|
+
"body": ensure_visible_mentions(body, mentions) if mentions else body,
|
|
379
|
+
"turnExecutionId": parsed["turn_execution_id"],
|
|
380
|
+
}
|
|
381
|
+
reply_to_id = parsed["reply_to_id"]
|
|
382
|
+
if isinstance(reply_to_id, str) and trim_argument(reply_to_id):
|
|
383
|
+
request_body["replyToId"] = trim_argument(reply_to_id)
|
|
384
|
+
return request_body
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def build_task_create_body(parsed: dict[str, object]) -> dict[str, object]:
|
|
388
|
+
request_body: dict[str, object] = {"title": parsed["title"]}
|
|
389
|
+
if parsed["description"] is not None:
|
|
390
|
+
request_body["description"] = parsed["description"]
|
|
391
|
+
if parsed["assignee_id"] is not None:
|
|
392
|
+
request_body["assigneeId"] = parsed["assignee_id"]
|
|
393
|
+
return request_body
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def build_task_update_body(parsed: dict[str, object]) -> dict[str, object]:
|
|
397
|
+
request_body: dict[str, object] = {}
|
|
398
|
+
if parsed["status"] is not None:
|
|
399
|
+
request_body["status"] = parsed["status"]
|
|
400
|
+
if parsed["title"] is not None:
|
|
401
|
+
request_body["title"] = parsed["title"]
|
|
402
|
+
if parsed["description"] is not None:
|
|
403
|
+
request_body["description"] = parsed["description"]
|
|
404
|
+
if parsed["assignee_id"] is not None:
|
|
405
|
+
request_body["assigneeId"] = parsed["assignee_id"]
|
|
406
|
+
return request_body
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Which task a bare `task` verb addresses is the gateway's decision, taken from the token binding:
|
|
410
|
+
# in a task thread `current-task` resolves that thread's task, and in a parent channel it answers
|
|
411
|
+
# not_found. The CLI therefore never inspects the turn's task assignment state.
|
|
412
|
+
def resolve_task_request(
|
|
413
|
+
parsed: dict[str, object],
|
|
414
|
+
base_url: str,
|
|
415
|
+
channel: str,
|
|
416
|
+
) -> dict[str, object]:
|
|
417
|
+
task_command = parsed["task_command"]
|
|
418
|
+
if task_command == "list":
|
|
419
|
+
return {"url": f"{base_url}/v1/channels/{channel}/tasks", "method": "GET", "body": None}
|
|
420
|
+
if task_command == "create":
|
|
421
|
+
return {
|
|
422
|
+
"url": f"{base_url}/v1/channels/{channel}/tasks",
|
|
423
|
+
"method": "POST",
|
|
424
|
+
"body": build_task_create_body(parsed),
|
|
425
|
+
}
|
|
426
|
+
if task_command == "history":
|
|
427
|
+
task_id = encode_component(str(parsed["task_id"]))
|
|
428
|
+
return {
|
|
429
|
+
"url": f"{base_url}/v1/tasks/{task_id}/history{history_query(parsed)}",
|
|
430
|
+
"method": "GET",
|
|
431
|
+
"body": None,
|
|
432
|
+
}
|
|
433
|
+
if parsed["task_id"] is None:
|
|
434
|
+
task_url = f"{base_url}/v1/channels/{channel}/current-task"
|
|
435
|
+
else:
|
|
436
|
+
task_url = f"{base_url}/v1/tasks/{encode_component(str(parsed['task_id']))}"
|
|
437
|
+
if task_command == "get":
|
|
438
|
+
return {"url": task_url, "method": "GET", "body": None}
|
|
439
|
+
if task_command == "update":
|
|
440
|
+
return {"url": task_url, "method": "PATCH", "body": build_task_update_body(parsed)}
|
|
441
|
+
property_url = f"{task_url}/properties/{encode_component(str(parsed['property_key']))}"
|
|
442
|
+
if task_command == "set-property":
|
|
443
|
+
return {"url": property_url, "method": "PUT", "body": {"value": parsed["property_value"]}}
|
|
444
|
+
return {"url": property_url, "method": "DELETE", "body": None}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def resolve_request(credential: dict[str, str], parsed: dict[str, object]) -> dict[str, object]:
|
|
448
|
+
base_url = credential["base_url"]
|
|
449
|
+
channel = encode_component(credential["channel_id"])
|
|
450
|
+
command = parsed["command"]
|
|
451
|
+
if command == "health":
|
|
452
|
+
return {"url": f"{base_url}/health", "method": "GET", "body": None}
|
|
453
|
+
if command == "bootstrap":
|
|
454
|
+
return {
|
|
455
|
+
"url": f"{base_url}/v1/channels/{channel}/bootstrap",
|
|
456
|
+
"method": "GET",
|
|
457
|
+
"body": None,
|
|
458
|
+
}
|
|
459
|
+
if command == "whoami":
|
|
460
|
+
return {"url": f"{base_url}/v1/channels/{channel}/me", "method": "GET", "body": None}
|
|
461
|
+
if command == "history":
|
|
462
|
+
return {
|
|
463
|
+
"url": f"{base_url}/v1/channels/{channel}/history{history_query(parsed)}",
|
|
464
|
+
"method": "GET",
|
|
465
|
+
"body": None,
|
|
466
|
+
}
|
|
467
|
+
if command == "users":
|
|
468
|
+
return {"url": f"{base_url}/v1/channels/{channel}/users", "method": "GET", "body": None}
|
|
469
|
+
if command == "draft":
|
|
470
|
+
query = build_query([("turnExecutionId", parsed["turn_execution_id"])])
|
|
471
|
+
return {
|
|
472
|
+
"url": f"{base_url}/v1/channels/{channel}/draft{query}",
|
|
473
|
+
"method": "GET",
|
|
474
|
+
"body": None,
|
|
475
|
+
}
|
|
476
|
+
if command in ("send", "mention"):
|
|
477
|
+
return {
|
|
478
|
+
"url": f"{base_url}/v1/channels/{channel}/messages",
|
|
479
|
+
"method": "POST",
|
|
480
|
+
"body": build_message_body(parsed),
|
|
481
|
+
}
|
|
482
|
+
return resolve_task_request(parsed, base_url, channel)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class RefuseRedirects(HTTPRedirectHandler):
|
|
486
|
+
"""The gateway has no redirect routes, so a 3xx means something other than the gateway
|
|
487
|
+
answered. Following it would carry the channel token off the loopback address — the default
|
|
488
|
+
handler copies the Authorization header onto the new host — so the redirect is reported as the
|
|
489
|
+
failure it is, matching the Node CLI."""
|
|
490
|
+
|
|
491
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
GATEWAY_OPENER = build_opener(RefuseRedirects)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def call_gateway(credential: dict[str, str], request: dict[str, object]) -> object:
|
|
499
|
+
headers = {"Authorization": f"Bearer {credential['token']}"}
|
|
500
|
+
data = None
|
|
501
|
+
if request["body"] is not None:
|
|
502
|
+
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
503
|
+
data = json.dumps(request["body"], ensure_ascii=False, separators=(",", ":")).encode(
|
|
504
|
+
"utf-8"
|
|
505
|
+
)
|
|
506
|
+
http_request = Request(
|
|
507
|
+
str(request["url"]), headers=headers, method=str(request["method"]), data=data
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
with GATEWAY_OPENER.open(http_request) as response:
|
|
511
|
+
body_text = response.read().decode("utf-8")
|
|
512
|
+
except HTTPError as exc:
|
|
513
|
+
error_text = exc.read().decode("utf-8")
|
|
514
|
+
error_payload = json.loads(error_text) if error_text else None
|
|
515
|
+
reported = json.dumps(error_payload, ensure_ascii=False, separators=(",", ":"))
|
|
516
|
+
raise RuntimeError(f"Gateway request failed with {exc.code}: {reported}") from exc
|
|
517
|
+
except URLError as exc:
|
|
518
|
+
raise RuntimeError(
|
|
519
|
+
f"Cannot reach the localhost gateway at {credential['base_url']}: {exc.reason}"
|
|
520
|
+
) from exc
|
|
521
|
+
return json.loads(body_text) if body_text else None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main() -> int:
|
|
525
|
+
parsed = parse_args(sys.argv[1:])
|
|
526
|
+
if parsed.get("help") is True:
|
|
527
|
+
sys.stdout.write(f"{MANUAL}\n")
|
|
528
|
+
return 0
|
|
529
|
+
if parsed["command"] is None:
|
|
530
|
+
sys.stderr.write(f"{MANUAL}\n")
|
|
531
|
+
return 2
|
|
532
|
+
validate_command(parsed)
|
|
533
|
+
credential = read_gateway_credential(str(parsed["gateway_credential_path"]))
|
|
534
|
+
result = call_gateway(credential, resolve_request(credential, parsed))
|
|
535
|
+
sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
if __name__ == "__main__":
|
|
540
|
+
try:
|
|
541
|
+
sys.exit(main())
|
|
542
|
+
except UsageError as error:
|
|
543
|
+
sys.stderr.write(f"error: {error}\n")
|
|
544
|
+
sys.exit(2)
|
|
545
|
+
except Exception as error:
|
|
546
|
+
sys.stderr.write(f"error: {error}\n")
|
|
547
|
+
sys.exit(1)
|