@inline-chat/hermes-agent-adapter 0.0.6 → 0.0.8-alpha.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.
- package/README.md +24 -7
- package/dist/install.js +142 -56
- package/package.json +8 -4
- package/plugin/inline/adapter.py +147 -1
- package/plugin/inline/cli.py +295 -24
- package/plugin/inline/plugin.yaml +1 -1
- package/plugin/inline/sidecar/index.mjs +24614 -10512
- package/plugin/inline/tools.py +47 -10
package/plugin/inline/cli.py
CHANGED
|
@@ -20,6 +20,8 @@ _SIDECAR_ENTRY = Path(__file__).parent / "sidecar" / "index.mjs"
|
|
|
20
20
|
_MIN_NODE_MAJOR = 20
|
|
21
21
|
_BOT_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]+bot$", re.IGNORECASE)
|
|
22
22
|
_CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
|
|
23
|
+
_MAX_TOKEN_BYTES = 16 * 1024
|
|
24
|
+
_MACHINE_SETUP_PROTOCOL_VERSION = 1
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def gateway_setup() -> None:
|
|
@@ -205,6 +207,11 @@ def _install_inline_cli(hermes_setup) -> str | None:
|
|
|
205
207
|
|
|
206
208
|
|
|
207
209
|
def _find_inline_cli() -> str | None:
|
|
210
|
+
configured = os.getenv("INLINE_CLI_BIN", "").strip()
|
|
211
|
+
if configured:
|
|
212
|
+
candidate = Path(configured).expanduser()
|
|
213
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
214
|
+
return str(candidate)
|
|
208
215
|
discovered = shutil.which("inline")
|
|
209
216
|
if discovered:
|
|
210
217
|
return discovered
|
|
@@ -242,25 +249,56 @@ def _configure_access(hermes_gateway, hermes_setup, owner_user_id: str | None) -
|
|
|
242
249
|
allowed.append(value)
|
|
243
250
|
|
|
244
251
|
if allowed:
|
|
245
|
-
|
|
246
|
-
hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "false")
|
|
247
|
-
hermes_gateway.save_env_value("INLINE_ALLOWED_USERS", value)
|
|
248
|
-
hermes_gateway.save_env_value("INLINE_GROUP_ALLOW_FROM", value)
|
|
249
|
-
hermes_gateway.save_env_value("INLINE_DM_POLICY", "allowlist")
|
|
250
|
-
hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "allowlist")
|
|
252
|
+
_apply_access(hermes_gateway, "allowlist", owner_user_id, allowed)
|
|
251
253
|
hermes_setup.print_success("Only the listed Inline users can invoke Hermes.")
|
|
252
254
|
return
|
|
253
255
|
|
|
254
256
|
if hermes_setup.prompt_yes_no("Allow any Inline user who can reach the bot?", False):
|
|
257
|
+
_apply_access(hermes_gateway, "open", owner_user_id, [])
|
|
258
|
+
hermes_setup.print_warning("Open access enabled. Any reachable Inline user can invoke Hermes.")
|
|
259
|
+
else:
|
|
260
|
+
_apply_access(hermes_gateway, "disabled", owner_user_id, [])
|
|
261
|
+
hermes_setup.print_warning("Messaging is disabled until you add allowed user IDs and re-run setup.")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _apply_access(
|
|
265
|
+
hermes_gateway,
|
|
266
|
+
access: str,
|
|
267
|
+
owner_user_id: str | None,
|
|
268
|
+
allowed_user_ids: list[str],
|
|
269
|
+
) -> list[str]:
|
|
270
|
+
normalized: list[str] = []
|
|
271
|
+
if access in ("owner", "allowlist"):
|
|
272
|
+
for value in [owner_user_id, *allowed_user_ids]:
|
|
273
|
+
candidate = str(value or "").strip()
|
|
274
|
+
if not candidate or not candidate.isdigit() or int(candidate) <= 0:
|
|
275
|
+
continue
|
|
276
|
+
if candidate not in normalized:
|
|
277
|
+
normalized.append(candidate)
|
|
278
|
+
if not normalized:
|
|
279
|
+
raise ValueError("owner or allowlist access requires a positive owner user ID")
|
|
280
|
+
joined = ",".join(normalized)
|
|
281
|
+
hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "false")
|
|
282
|
+
hermes_gateway.save_env_value("INLINE_ALLOWED_USERS", joined)
|
|
283
|
+
hermes_gateway.save_env_value("INLINE_GROUP_ALLOW_FROM", joined)
|
|
284
|
+
hermes_gateway.save_env_value("INLINE_DM_POLICY", "allowlist")
|
|
285
|
+
hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "allowlist")
|
|
286
|
+
return normalized
|
|
287
|
+
if access == "open":
|
|
255
288
|
hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "true")
|
|
289
|
+
hermes_gateway.save_env_value("INLINE_ALLOWED_USERS", "")
|
|
290
|
+
hermes_gateway.save_env_value("INLINE_GROUP_ALLOW_FROM", "")
|
|
256
291
|
hermes_gateway.save_env_value("INLINE_DM_POLICY", "open")
|
|
257
292
|
hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "open")
|
|
258
|
-
|
|
259
|
-
|
|
293
|
+
return normalized
|
|
294
|
+
if access == "disabled":
|
|
260
295
|
hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "false")
|
|
296
|
+
hermes_gateway.save_env_value("INLINE_ALLOWED_USERS", "")
|
|
297
|
+
hermes_gateway.save_env_value("INLINE_GROUP_ALLOW_FROM", "")
|
|
261
298
|
hermes_gateway.save_env_value("INLINE_DM_POLICY", "disabled")
|
|
262
299
|
hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "disabled")
|
|
263
|
-
|
|
300
|
+
return normalized
|
|
301
|
+
raise ValueError(f"unsupported Inline access mode: {access}")
|
|
264
302
|
|
|
265
303
|
|
|
266
304
|
def _inline_cli_user_id(inline_bin: str | None) -> str | None:
|
|
@@ -297,28 +335,178 @@ def _run_inline_json(inline_bin: str, args: list[str]) -> tuple[dict | None, str
|
|
|
297
335
|
|
|
298
336
|
def register_cli(parser: argparse.ArgumentParser) -> None:
|
|
299
337
|
subs = parser.add_subparsers(dest="inline_command", required=False)
|
|
300
|
-
subs.add_parser("setup", help="Configure Inline
|
|
301
|
-
|
|
338
|
+
setup = subs.add_parser("setup", help="Configure Inline", description="Configure the Inline platform and its access policy.")
|
|
339
|
+
setup.add_argument("--non-interactive", action="store_true", help="Run prompt-free machine setup; requires the token on stdin.")
|
|
340
|
+
setup.add_argument("--token-stdin", action="store_true", help="Read one bounded Inline token from stdin instead of argv.")
|
|
341
|
+
setup.add_argument("--owner-user-id", help="Positive Inline user ID that owns the configured bot.")
|
|
342
|
+
setup.add_argument("--access", choices=["owner", "allowlist", "open", "disabled"], default="owner", help="Who may invoke Hermes through Inline (default: owner).")
|
|
343
|
+
setup.add_argument("--allow-user", action="append", default=[], type=_positive_user_id, help="Additional positive Inline user ID to allow; repeatable.")
|
|
344
|
+
setup.add_argument("--json", action="store_true", help="Print compact machine-readable setup output.")
|
|
345
|
+
status = subs.add_parser("status", help="Show Inline adapter status", description="Check Inline configuration, sidecar, Node runtime, and optional credential identity.")
|
|
346
|
+
status.add_argument("--json", action="store_true", help="Print compact machine-readable status output.")
|
|
347
|
+
status.add_argument("--probe", action="store_true", help="Verify the configured Inline credential and bot identity.")
|
|
302
348
|
parser.set_defaults(func=dispatch)
|
|
303
349
|
|
|
304
350
|
|
|
351
|
+
def _positive_user_id(value: str) -> str:
|
|
352
|
+
value = str(value or "").strip()
|
|
353
|
+
if not value.isdigit() or int(value) <= 0:
|
|
354
|
+
raise argparse.ArgumentTypeError("Inline user IDs must be positive integers")
|
|
355
|
+
return value
|
|
356
|
+
|
|
357
|
+
|
|
305
358
|
def dispatch(args) -> int:
|
|
306
359
|
command = getattr(args, "inline_command", None)
|
|
307
360
|
if command is None:
|
|
308
361
|
command = "status"
|
|
309
362
|
if command == "setup":
|
|
363
|
+
if getattr(args, "non_interactive", False):
|
|
364
|
+
return _machine_setup(args)
|
|
310
365
|
gateway_setup()
|
|
311
366
|
return 0
|
|
312
367
|
if command == "status":
|
|
313
|
-
|
|
368
|
+
return _status(args)
|
|
369
|
+
raise SystemExit(f"unknown inline command: {command}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _machine_setup(args) -> int:
|
|
373
|
+
if not getattr(args, "token_stdin", False):
|
|
374
|
+
raise SystemExit("non-interactive Inline setup requires --token-stdin")
|
|
375
|
+
owner_user_id = str(getattr(args, "owner_user_id", "") or "").strip()
|
|
376
|
+
if not owner_user_id.isdigit() or int(owner_user_id) <= 0:
|
|
377
|
+
raise SystemExit("non-interactive Inline setup requires a positive --owner-user-id")
|
|
378
|
+
token = sys.stdin.read(_MAX_TOKEN_BYTES + 1)
|
|
379
|
+
if len(token.encode("utf-8")) > _MAX_TOKEN_BYTES:
|
|
380
|
+
raise SystemExit("Inline bot token exceeds the input limit")
|
|
381
|
+
token = token.strip()
|
|
382
|
+
if not token:
|
|
383
|
+
raise SystemExit("Inline bot token from stdin is empty")
|
|
384
|
+
from hermes_cli import gateway as hermes_gateway
|
|
385
|
+
|
|
386
|
+
hermes_gateway.save_env_value("INLINE_TOKEN", token)
|
|
387
|
+
allowed = _apply_access(
|
|
388
|
+
hermes_gateway,
|
|
389
|
+
getattr(args, "access", "owner"),
|
|
390
|
+
owner_user_id,
|
|
391
|
+
list(getattr(args, "allow_user", []) or []),
|
|
392
|
+
)
|
|
393
|
+
hermes_gateway.write_platform_config_field("inline", "enabled", True, raw=True)
|
|
394
|
+
result = {
|
|
395
|
+
"ok": True,
|
|
396
|
+
"action": "inline.setup",
|
|
397
|
+
"setupProtocolVersion": _MACHINE_SETUP_PROTOCOL_VERSION,
|
|
398
|
+
"pluginVersion": _plugin_version(),
|
|
399
|
+
"configured": True,
|
|
400
|
+
"access": getattr(args, "access", "owner"),
|
|
401
|
+
"ownerUserId": owner_user_id,
|
|
402
|
+
"allowedUserIds": allowed,
|
|
403
|
+
}
|
|
404
|
+
if getattr(args, "json", False):
|
|
405
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
406
|
+
else:
|
|
407
|
+
print("Inline configured: yes")
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _status(args) -> int:
|
|
412
|
+
from hermes_cli import gateway as hermes_gateway
|
|
413
|
+
|
|
414
|
+
token = (
|
|
415
|
+
hermes_gateway.get_env_value("INLINE_TOKEN")
|
|
416
|
+
or hermes_gateway.get_env_value("INLINE_BOT_TOKEN")
|
|
417
|
+
)
|
|
418
|
+
configured = bool(token)
|
|
419
|
+
probe_requested = bool(getattr(args, "probe", False))
|
|
420
|
+
probe = _probe_inline_token(token) if configured and probe_requested else None
|
|
421
|
+
node = _node_status()
|
|
422
|
+
sidecar = _sidecar_status(node)
|
|
423
|
+
sidecar_bundled = bool(
|
|
424
|
+
sidecar["exists"]
|
|
425
|
+
and sidecar["regularFile"]
|
|
426
|
+
and sidecar["readable"]
|
|
427
|
+
and sidecar["size"] > 0
|
|
428
|
+
)
|
|
429
|
+
runtime_usable = bool(sidecar["ok"] and node["ok"])
|
|
430
|
+
ready = runtime_usable and configured and (not probe_requested or bool(probe and probe.get("ok")))
|
|
431
|
+
result = {
|
|
432
|
+
"ok": ready,
|
|
433
|
+
"ready": ready,
|
|
434
|
+
"action": "inline.status",
|
|
435
|
+
"setupProtocolVersion": _MACHINE_SETUP_PROTOCOL_VERSION,
|
|
436
|
+
"pluginVersion": _plugin_version(),
|
|
437
|
+
"configured": configured,
|
|
438
|
+
"runtimeUsable": runtime_usable,
|
|
439
|
+
"sidecarBundled": sidecar_bundled,
|
|
440
|
+
"sidecar": sidecar,
|
|
441
|
+
"node": node,
|
|
442
|
+
"probeRequested": probe_requested,
|
|
443
|
+
**({"probe": probe} if probe is not None else {}),
|
|
444
|
+
}
|
|
445
|
+
if getattr(args, "json", False):
|
|
446
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
447
|
+
else:
|
|
314
448
|
print(f"Inline configured: {'yes' if configured else 'no'}")
|
|
315
|
-
print(f"Inline sidecar
|
|
316
|
-
print(f"Node available: {
|
|
449
|
+
print(f"Inline sidecar usable: {'yes' if sidecar['ok'] else 'no'}")
|
|
450
|
+
print(f"Node available: {_node_status_text(node)}")
|
|
451
|
+
print(f"Inline runtime ready: {'yes' if ready else 'no'}")
|
|
317
452
|
if not configured:
|
|
318
453
|
print("Next: run `hermes inline setup` for guided bot setup.")
|
|
454
|
+
elif probe_requested:
|
|
455
|
+
print(f"Inline credential probe: {'ready' if ready else 'failed'}")
|
|
319
456
|
print("Advanced diagnostics: inline-hermes doctor --json")
|
|
320
|
-
|
|
321
|
-
|
|
457
|
+
return 0 if runtime_usable and (not probe_requested or ready) else 1
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _plugin_version() -> str:
|
|
461
|
+
"""Read the separately installed plugin version without package-manager state."""
|
|
462
|
+
try:
|
|
463
|
+
for line in (Path(__file__).parent / "plugin.yaml").read_text(encoding="utf-8").splitlines():
|
|
464
|
+
key, separator, value = line.partition(":")
|
|
465
|
+
if separator and key.strip() == "version":
|
|
466
|
+
version = value.strip().strip("\"'")
|
|
467
|
+
if version:
|
|
468
|
+
return version
|
|
469
|
+
except OSError:
|
|
470
|
+
pass
|
|
471
|
+
return "unknown"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _probe_inline_token(token: str) -> dict:
|
|
475
|
+
inline_bin = _find_inline_cli()
|
|
476
|
+
if not inline_bin:
|
|
477
|
+
return {"ok": False, "error": "Inline CLI was not found for the credential probe."}
|
|
478
|
+
env = os.environ.copy()
|
|
479
|
+
for name in ("INLINE_TOKEN", "INLINE_BOT_TOKEN", "INLINE_OWNER_TOKEN", "INLINE_ACCESS_TOKEN"):
|
|
480
|
+
env.pop(name, None)
|
|
481
|
+
env["INLINE_TOKEN"] = token
|
|
482
|
+
try:
|
|
483
|
+
result = subprocess.run(
|
|
484
|
+
[inline_bin, "--json", "--compact", "auth", "me"],
|
|
485
|
+
stdout=subprocess.PIPE,
|
|
486
|
+
stderr=subprocess.PIPE,
|
|
487
|
+
text=True,
|
|
488
|
+
timeout=30,
|
|
489
|
+
check=False,
|
|
490
|
+
env=env,
|
|
491
|
+
)
|
|
492
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
493
|
+
return {"ok": False, "error": "Inline credential probe could not run."}
|
|
494
|
+
if result.returncode != 0:
|
|
495
|
+
return {"ok": False, "error": "Inline rejected the configured credential."}
|
|
496
|
+
try:
|
|
497
|
+
payload = json.loads(result.stdout)
|
|
498
|
+
except (json.JSONDecodeError, TypeError):
|
|
499
|
+
return {"ok": False, "error": "Inline credential probe returned unreadable output."}
|
|
500
|
+
raw_id = payload.get("id") if isinstance(payload, dict) else None
|
|
501
|
+
bot_user_id = str(raw_id).strip() if raw_id is not None else ""
|
|
502
|
+
if not bot_user_id.isdigit() or int(bot_user_id) <= 0:
|
|
503
|
+
return {"ok": False, "error": "Inline credential probe returned no bot identity."}
|
|
504
|
+
username = str(payload.get("username") or "").strip().lstrip("@")
|
|
505
|
+
return {
|
|
506
|
+
"ok": True,
|
|
507
|
+
"botUserId": bot_user_id,
|
|
508
|
+
**({"botUsername": username} if username else {}),
|
|
509
|
+
}
|
|
322
510
|
|
|
323
511
|
|
|
324
512
|
def _env_token_configured() -> bool:
|
|
@@ -339,10 +527,17 @@ def _find_node_bin() -> str | None:
|
|
|
339
527
|
return shutil.which("node")
|
|
340
528
|
|
|
341
529
|
|
|
342
|
-
def _node_status() ->
|
|
530
|
+
def _node_status() -> dict:
|
|
343
531
|
node_bin = _find_node_bin()
|
|
344
532
|
if not node_bin:
|
|
345
|
-
return
|
|
533
|
+
return {
|
|
534
|
+
"ok": False,
|
|
535
|
+
"path": None,
|
|
536
|
+
"version": None,
|
|
537
|
+
"major": None,
|
|
538
|
+
"minimumMajor": _MIN_NODE_MAJOR,
|
|
539
|
+
"error": "Node.js was not found.",
|
|
540
|
+
}
|
|
346
541
|
try:
|
|
347
542
|
result = subprocess.run(
|
|
348
543
|
[node_bin, "--version"],
|
|
@@ -353,12 +548,88 @@ def _node_status() -> str:
|
|
|
353
548
|
check=False,
|
|
354
549
|
)
|
|
355
550
|
except Exception as exc:
|
|
356
|
-
return
|
|
551
|
+
return {
|
|
552
|
+
"ok": False,
|
|
553
|
+
"path": node_bin,
|
|
554
|
+
"version": None,
|
|
555
|
+
"major": None,
|
|
556
|
+
"minimumMajor": _MIN_NODE_MAJOR,
|
|
557
|
+
"error": f"Node.js could not run: {exc}",
|
|
558
|
+
}
|
|
357
559
|
version = (result.stdout or result.stderr or "").strip()
|
|
358
560
|
if result.returncode != 0:
|
|
359
|
-
return
|
|
561
|
+
return {
|
|
562
|
+
"ok": False,
|
|
563
|
+
"path": node_bin,
|
|
564
|
+
"version": version or None,
|
|
565
|
+
"major": None,
|
|
566
|
+
"minimumMajor": _MIN_NODE_MAJOR,
|
|
567
|
+
"error": f"Node.js exited with status {result.returncode}.",
|
|
568
|
+
}
|
|
360
569
|
match = re.search(r"\bv?(\d+)(?:\.\d+){0,2}\b", version)
|
|
361
|
-
major = int(match.group(1)) if match else
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
570
|
+
major = int(match.group(1)) if match else None
|
|
571
|
+
ok = major is not None and major >= _MIN_NODE_MAJOR
|
|
572
|
+
return {
|
|
573
|
+
"ok": ok,
|
|
574
|
+
"path": node_bin,
|
|
575
|
+
"version": version or None,
|
|
576
|
+
"major": major,
|
|
577
|
+
"minimumMajor": _MIN_NODE_MAJOR,
|
|
578
|
+
"error": None if ok else f"Node.js {version or 'version'} is incompatible; requires >= {_MIN_NODE_MAJOR}.",
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _sidecar_status(node: dict) -> dict:
|
|
583
|
+
try:
|
|
584
|
+
info = _SIDECAR_ENTRY.stat()
|
|
585
|
+
exists = True
|
|
586
|
+
regular_file = _SIDECAR_ENTRY.is_file()
|
|
587
|
+
readable = os.access(_SIDECAR_ENTRY, os.R_OK)
|
|
588
|
+
size = info.st_size
|
|
589
|
+
except OSError:
|
|
590
|
+
exists = False
|
|
591
|
+
regular_file = False
|
|
592
|
+
readable = False
|
|
593
|
+
size = 0
|
|
594
|
+
|
|
595
|
+
syntax_checked = False
|
|
596
|
+
syntax_ok = False
|
|
597
|
+
error = None
|
|
598
|
+
if not exists:
|
|
599
|
+
error = "The packaged Inline sidecar is missing."
|
|
600
|
+
elif not regular_file or not readable or size <= 0:
|
|
601
|
+
error = "The packaged Inline sidecar is not a readable non-empty file."
|
|
602
|
+
elif node.get("ok"):
|
|
603
|
+
syntax_checked = True
|
|
604
|
+
try:
|
|
605
|
+
checked = subprocess.run(
|
|
606
|
+
[str(node["path"]), "--check", str(_SIDECAR_ENTRY)],
|
|
607
|
+
stdout=subprocess.DEVNULL,
|
|
608
|
+
stderr=subprocess.DEVNULL,
|
|
609
|
+
timeout=10,
|
|
610
|
+
check=False,
|
|
611
|
+
)
|
|
612
|
+
syntax_ok = checked.returncode == 0
|
|
613
|
+
if not syntax_ok:
|
|
614
|
+
error = "The packaged Inline sidecar failed Node.js syntax validation."
|
|
615
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
616
|
+
error = "The packaged Inline sidecar could not be validated by Node.js."
|
|
617
|
+
else:
|
|
618
|
+
error = "The packaged Inline sidecar cannot run without compatible Node.js."
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
"ok": bool(exists and regular_file and readable and size > 0 and syntax_checked and syntax_ok),
|
|
622
|
+
"exists": exists,
|
|
623
|
+
"regularFile": regular_file,
|
|
624
|
+
"readable": readable,
|
|
625
|
+
"size": size,
|
|
626
|
+
"syntaxChecked": syntax_checked,
|
|
627
|
+
"syntaxOk": syntax_ok,
|
|
628
|
+
"error": error,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _node_status_text(node: dict) -> str:
|
|
633
|
+
if node.get("ok"):
|
|
634
|
+
return f"yes ({node.get('version') or 'unknown version'})"
|
|
635
|
+
return f"no ({node.get('error') or 'unknown error'})"
|