nora-sdk 0.4.0__tar.gz
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.
- nora_sdk-0.4.0/LICENSE +5 -0
- nora_sdk-0.4.0/PKG-INFO +53 -0
- nora_sdk-0.4.0/README.md +34 -0
- nora_sdk-0.4.0/nora_agent/__init__.py +3 -0
- nora_sdk-0.4.0/nora_agent/dev_cli.py +276 -0
- nora_sdk-0.4.0/nora_agent/sdk.py +285 -0
- nora_sdk-0.4.0/nora_sdk.egg-info/PKG-INFO +53 -0
- nora_sdk-0.4.0/nora_sdk.egg-info/SOURCES.txt +12 -0
- nora_sdk-0.4.0/nora_sdk.egg-info/dependency_links.txt +1 -0
- nora_sdk-0.4.0/nora_sdk.egg-info/entry_points.txt +2 -0
- nora_sdk-0.4.0/nora_sdk.egg-info/requires.txt +1 -0
- nora_sdk-0.4.0/nora_sdk.egg-info/top_level.txt +1 -0
- nora_sdk-0.4.0/pyproject.toml +40 -0
- nora_sdk-0.4.0/setup.cfg +4 -0
nora_sdk-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (c) Valisoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software (the "NORA SDK") is provided to NORA customers to build and run
|
|
4
|
+
automations ("robots") on the NORA platform. Redistribution or modification
|
|
5
|
+
outside that purpose is not permitted without written permission from Valisoft.
|
nora_sdk-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nora-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Client SDK + dev CLI to build and debug robots on NORA (Robots Center).
|
|
5
|
+
Author: Valisoft
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://nora.valisoftconsulting.com
|
|
8
|
+
Keywords: nora,rpa,automation,sdk,robots
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: License :: Other/Proprietary License
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# nora-sdk
|
|
21
|
+
|
|
22
|
+
Client SDK + `nora` developer CLI to build and debug **robots** on NORA
|
|
23
|
+
(Robots Center). Install it to develop locally; in production the NORA agent
|
|
24
|
+
provides the same SDK automatically, so your robot code is identical.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nora-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Uso en el robot
|
|
31
|
+
```python
|
|
32
|
+
from nora_agent import sdk
|
|
33
|
+
|
|
34
|
+
item = sdk.get_queue_item("RPA-Challenge") # consume una cola
|
|
35
|
+
cred = sdk.get_asset("API_KEY") # lee un asset (secreto)
|
|
36
|
+
sdk.log("info", "hola") # log al dashboard
|
|
37
|
+
sdk.update_progress(50, "a mitad")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Desarrollo local con breakpoints
|
|
41
|
+
```bash
|
|
42
|
+
nora login
|
|
43
|
+
nora dev env --write .env # NORA_API_URL + token de dev (dev/staging, no prod)
|
|
44
|
+
# apunta tu IDE a .env y debuggea, o:
|
|
45
|
+
nora dev run main.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Seguridad
|
|
49
|
+
- El token solo viaja por **HTTPS** (rechaza http salvo localhost).
|
|
50
|
+
- Los nombres en la URL van **percent-encoded** (sin inyección de path/query).
|
|
51
|
+
- Sin `shell`, sin `eval`/`exec`. Dependencia única: `httpx`.
|
|
52
|
+
- El token de dev está limitado a `dev`/`staging`: nunca lee secretos de producción.
|
|
53
|
+
- La sesión se guarda en `~/.nora/credentials.json` con permisos solo-dueño.
|
nora_sdk-0.4.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# nora-sdk
|
|
2
|
+
|
|
3
|
+
Client SDK + `nora` developer CLI to build and debug **robots** on NORA
|
|
4
|
+
(Robots Center). Install it to develop locally; in production the NORA agent
|
|
5
|
+
provides the same SDK automatically, so your robot code is identical.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nora-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Uso en el robot
|
|
12
|
+
```python
|
|
13
|
+
from nora_agent import sdk
|
|
14
|
+
|
|
15
|
+
item = sdk.get_queue_item("RPA-Challenge") # consume una cola
|
|
16
|
+
cred = sdk.get_asset("API_KEY") # lee un asset (secreto)
|
|
17
|
+
sdk.log("info", "hola") # log al dashboard
|
|
18
|
+
sdk.update_progress(50, "a mitad")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Desarrollo local con breakpoints
|
|
22
|
+
```bash
|
|
23
|
+
nora login
|
|
24
|
+
nora dev env --write .env # NORA_API_URL + token de dev (dev/staging, no prod)
|
|
25
|
+
# apunta tu IDE a .env y debuggea, o:
|
|
26
|
+
nora dev run main.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Seguridad
|
|
30
|
+
- El token solo viaja por **HTTPS** (rechaza http salvo localhost).
|
|
31
|
+
- Los nombres en la URL van **percent-encoded** (sin inyección de path/query).
|
|
32
|
+
- Sin `shell`, sin `eval`/`exec`. Dependencia única: `httpx`.
|
|
33
|
+
- El token de dev está limitado a `dev`/`staging`: nunca lee secretos de producción.
|
|
34
|
+
- La sesión se guarda en `~/.nora/credentials.json` con permisos solo-dueño.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""``nora`` developer CLI — run robots locally without embedding credentials.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
nora login Authenticate and store a session (refresh token).
|
|
5
|
+
nora dev run <entry.py> Mint a short-lived dev token and run the robot with
|
|
6
|
+
it injected, so the code is identical to production
|
|
7
|
+
and never contains a credential.
|
|
8
|
+
nora dev env Print/write env vars to run+debug from an IDE.
|
|
9
|
+
nora logout Forget the stored session.
|
|
10
|
+
|
|
11
|
+
The dev token is restricted to non-production environments: a developer can
|
|
12
|
+
never read production secrets from their machine.
|
|
13
|
+
|
|
14
|
+
No third-party deps beyond httpx (kept minimal on purpose). No shell, no eval.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import getpass
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from urllib.parse import urlparse
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
DEFAULT_API_URL = os.environ.get("NORA_API_URL", "https://nora-api.valisoftconsulting.com/api/v1")
|
|
31
|
+
_CONFIG_DIR = Path.home() / ".nora"
|
|
32
|
+
_CONFIG_FILE = _CONFIG_DIR / "credentials.json"
|
|
33
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _echo(msg: str) -> None:
|
|
37
|
+
print(msg)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _err(msg: str) -> None:
|
|
41
|
+
print(msg, file=sys.stderr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_https(api_url: str) -> None:
|
|
45
|
+
"""Refuse to send credentials/tokens to a non-https endpoint (except localhost)."""
|
|
46
|
+
parsed = urlparse(api_url)
|
|
47
|
+
host = (parsed.hostname or "").lower()
|
|
48
|
+
if parsed.scheme != "https" and host not in _LOCAL_HOSTS:
|
|
49
|
+
raise SystemExit(
|
|
50
|
+
f"Refusing to use a non-https API URL ({api_url}). "
|
|
51
|
+
"Credentials must travel over TLS."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_session(api_url: str, refresh_token: str) -> None:
|
|
56
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
# Write with owner-only perms from the start (avoid a brief world-readable window).
|
|
58
|
+
fd = os.open(str(_CONFIG_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
59
|
+
with os.fdopen(fd, "w") as f:
|
|
60
|
+
json.dump({"api_url": api_url, "refresh_token": refresh_token}, f)
|
|
61
|
+
try:
|
|
62
|
+
os.chmod(_CONFIG_FILE, 0o600)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_session() -> dict | None:
|
|
68
|
+
if not _CONFIG_FILE.exists():
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(_CONFIG_FILE.read_text())
|
|
72
|
+
except (OSError, ValueError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _access_token_from_refresh(api_url: str, refresh_token: str) -> str:
|
|
77
|
+
"""Exchange the stored refresh token for a fresh access token."""
|
|
78
|
+
_require_https(api_url)
|
|
79
|
+
resp = httpx.post(
|
|
80
|
+
f"{api_url}/auth/refresh", json={"refresh_token": refresh_token}, timeout=30
|
|
81
|
+
)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return resp.json()["data"]["access_token"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
87
|
+
api_url = args.api_url or DEFAULT_API_URL
|
|
88
|
+
_require_https(api_url)
|
|
89
|
+
email = args.email or input("Email: ")
|
|
90
|
+
password = getpass.getpass("Password: ")
|
|
91
|
+
|
|
92
|
+
with httpx.Client(timeout=30) as client:
|
|
93
|
+
resp = client.post(f"{api_url}/auth/login", json={"email": email, "password": password})
|
|
94
|
+
if resp.status_code != 200:
|
|
95
|
+
_err(f"Login failed: {resp.text}")
|
|
96
|
+
return 1
|
|
97
|
+
data = resp.json()["data"]
|
|
98
|
+
|
|
99
|
+
# MFA-pending: the API returns {"requires_mfa": true, "mfa_token": ...}.
|
|
100
|
+
if isinstance(data, dict) and data.get("requires_mfa"):
|
|
101
|
+
code = input("MFA code: ").strip()
|
|
102
|
+
resp = client.post(
|
|
103
|
+
f"{api_url}/auth/mfa/login",
|
|
104
|
+
json={"mfa_token": data["mfa_token"], "code": code},
|
|
105
|
+
)
|
|
106
|
+
if resp.status_code != 200:
|
|
107
|
+
_err(f"MFA failed: {resp.text}")
|
|
108
|
+
return 1
|
|
109
|
+
data = resp.json()["data"]
|
|
110
|
+
|
|
111
|
+
if isinstance(data, dict) and data.get("org_token"):
|
|
112
|
+
_err(
|
|
113
|
+
"Your account belongs to multiple organizations. "
|
|
114
|
+
"Select one in the web app, then log in here from that org."
|
|
115
|
+
)
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
# The refresh token is delivered as an HttpOnly cookie.
|
|
119
|
+
refresh = client.cookies.get("nora_refresh_token") or (
|
|
120
|
+
data.get("refresh_token") if isinstance(data, dict) else None
|
|
121
|
+
)
|
|
122
|
+
if not refresh:
|
|
123
|
+
_err("Could not obtain a session token.")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
_save_session(api_url, refresh)
|
|
127
|
+
_echo(f"Logged in. Session saved to {_CONFIG_FILE}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_logout(_args: argparse.Namespace) -> int:
|
|
132
|
+
if _CONFIG_FILE.exists():
|
|
133
|
+
_CONFIG_FILE.unlink()
|
|
134
|
+
_echo("Logged out.")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _mint_dev_token(args: argparse.Namespace) -> tuple[str, str] | None:
|
|
139
|
+
"""Return (api_url, dev_token) for the current session, or None on error."""
|
|
140
|
+
session = _load_session()
|
|
141
|
+
if not session:
|
|
142
|
+
_err("Not logged in. Run 'nora login' first.")
|
|
143
|
+
return None
|
|
144
|
+
api_url = session["api_url"]
|
|
145
|
+
_require_https(api_url)
|
|
146
|
+
try:
|
|
147
|
+
access_token = _access_token_from_refresh(api_url, session["refresh_token"])
|
|
148
|
+
except httpx.HTTPError as e:
|
|
149
|
+
_err(f"Session expired ({e}). Run 'nora login' again.")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
body: dict = {"environment": args.environment, "ttl_seconds": args.ttl}
|
|
153
|
+
if getattr(args, "assets", None):
|
|
154
|
+
body["assets"] = [a.strip() for a in args.assets.split(",") if a.strip()]
|
|
155
|
+
|
|
156
|
+
resp = httpx.post(
|
|
157
|
+
f"{api_url}/dev/token",
|
|
158
|
+
json=body,
|
|
159
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
160
|
+
timeout=30,
|
|
161
|
+
)
|
|
162
|
+
if resp.status_code != 200:
|
|
163
|
+
_err(f"Could not mint dev token: {resp.text}")
|
|
164
|
+
return None
|
|
165
|
+
return api_url, resp.json()["data"]["token"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_dev_run(args: argparse.Namespace) -> int:
|
|
169
|
+
entry = Path(args.entry)
|
|
170
|
+
if not entry.exists():
|
|
171
|
+
_err(f"Entry file not found: {entry}")
|
|
172
|
+
return 1
|
|
173
|
+
minted = _mint_dev_token(args)
|
|
174
|
+
if not minted:
|
|
175
|
+
return 1
|
|
176
|
+
api_url, dev_token = minted
|
|
177
|
+
|
|
178
|
+
# Run the robot with the dev token injected exactly like the agent does in
|
|
179
|
+
# production. The robot's code is identical and holds no credential.
|
|
180
|
+
# No shell — args are passed as a list, so nothing in `entry`/`script_args`
|
|
181
|
+
# can be interpreted by a shell.
|
|
182
|
+
env = dict(os.environ)
|
|
183
|
+
env["NORA_API_URL"] = api_url
|
|
184
|
+
env["NORA_EXEC_TOKEN"] = dev_token
|
|
185
|
+
env["NORA_MACHINE_TOKEN"] = dev_token # SDK backward-compat
|
|
186
|
+
env.pop("NORA_ASSETS", None)
|
|
187
|
+
|
|
188
|
+
_echo(f"Running {entry} against {args.environment} (dev token, {args.ttl}s)")
|
|
189
|
+
proc = subprocess.run([sys.executable, str(entry), *args.script_args], env=env)
|
|
190
|
+
return proc.returncode
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_dev_env(args: argparse.Namespace) -> int:
|
|
194
|
+
"""Print (or write) the env vars to run/debug a robot from an IDE.
|
|
195
|
+
|
|
196
|
+
Put these in your IDE's run/debug configuration (VS Code envFile, PyCharm
|
|
197
|
+
env vars, …) and set breakpoints — the SDK will pull live data from the
|
|
198
|
+
Robots Center using a short-lived dev token (dev/staging only).
|
|
199
|
+
"""
|
|
200
|
+
minted = _mint_dev_token(args)
|
|
201
|
+
if not minted:
|
|
202
|
+
return 1
|
|
203
|
+
api_url, dev_token = minted
|
|
204
|
+
|
|
205
|
+
pairs = [
|
|
206
|
+
("NORA_API_URL", api_url),
|
|
207
|
+
("NORA_EXEC_TOKEN", dev_token),
|
|
208
|
+
("NORA_MACHINE_TOKEN", dev_token), # SDK backward-compat
|
|
209
|
+
]
|
|
210
|
+
if args.format == "powershell":
|
|
211
|
+
lines = [f'$env:{k} = "{v}"' for k, v in pairs]
|
|
212
|
+
elif args.format == "bash":
|
|
213
|
+
lines = [f'export {k}="{v}"' for k, v in pairs]
|
|
214
|
+
else: # dotenv
|
|
215
|
+
lines = [f"{k}={v}" for k, v in pairs]
|
|
216
|
+
body = "\n".join(lines) + "\n"
|
|
217
|
+
|
|
218
|
+
if args.write:
|
|
219
|
+
out = Path(args.write)
|
|
220
|
+
fd = os.open(str(out), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
221
|
+
with os.fdopen(fd, "w") as f:
|
|
222
|
+
f.write(body)
|
|
223
|
+
_echo(
|
|
224
|
+
f"Wrote {out} (env={args.environment}, ttl={args.ttl}s). "
|
|
225
|
+
"Point your IDE's run config at it and set breakpoints."
|
|
226
|
+
)
|
|
227
|
+
_echo("Add this file to .gitignore — it holds a token.")
|
|
228
|
+
else:
|
|
229
|
+
# Plain stdout (no markup) so it can be piped/eval'd.
|
|
230
|
+
print(body, end="")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
235
|
+
parser = argparse.ArgumentParser(prog="nora", description="NORA developer CLI")
|
|
236
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
237
|
+
|
|
238
|
+
p_login = sub.add_parser("login", help="Authenticate and store a session")
|
|
239
|
+
p_login.add_argument("--email")
|
|
240
|
+
p_login.add_argument("--api-url", dest="api_url")
|
|
241
|
+
p_login.set_defaults(func=cmd_login)
|
|
242
|
+
|
|
243
|
+
sub.add_parser("logout", help="Forget the stored session").set_defaults(func=cmd_logout)
|
|
244
|
+
|
|
245
|
+
p_dev = sub.add_parser("dev", help="Local development commands")
|
|
246
|
+
dev_sub = p_dev.add_subparsers(dest="dev_command", required=True)
|
|
247
|
+
p_run = dev_sub.add_parser("run", help="Run a robot locally with a dev token")
|
|
248
|
+
p_run.add_argument("entry", help="Path to the robot entry file (e.g. main.py)")
|
|
249
|
+
p_run.add_argument("--environment", "-e", default="dev", choices=["dev", "staging"])
|
|
250
|
+
p_run.add_argument("--assets", help="Comma-separated asset names to scope the token to")
|
|
251
|
+
p_run.add_argument("--ttl", type=int, default=1800, help="Token lifetime in seconds")
|
|
252
|
+
p_run.add_argument("script_args", nargs="*", help="Arguments passed to the robot")
|
|
253
|
+
p_run.set_defaults(func=cmd_dev_run)
|
|
254
|
+
|
|
255
|
+
p_env = dev_sub.add_parser(
|
|
256
|
+
"env", help="Print/write env vars to run+debug a robot from your IDE (breakpoints)"
|
|
257
|
+
)
|
|
258
|
+
p_env.add_argument("--environment", "-e", default="dev", choices=["dev", "staging"])
|
|
259
|
+
p_env.add_argument("--assets", help="Comma-separated asset names to scope the token to")
|
|
260
|
+
# Default 8h so a debug session with breakpoints doesn't expire mid-pause.
|
|
261
|
+
p_env.add_argument("--ttl", type=int, default=28800, help="Token lifetime in seconds")
|
|
262
|
+
p_env.add_argument("--format", choices=["dotenv", "bash", "powershell"], default="dotenv")
|
|
263
|
+
p_env.add_argument("--write", metavar="PATH", help="Write to a file (e.g. .env) instead of stdout")
|
|
264
|
+
p_env.set_defaults(func=cmd_dev_env)
|
|
265
|
+
|
|
266
|
+
return parser
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main() -> None:
|
|
270
|
+
parser = build_parser()
|
|
271
|
+
args = parser.parse_args()
|
|
272
|
+
sys.exit(args.func(args))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
main()
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""NORA Agent SDK — helpers for bot scripts to interact with NORA API.
|
|
2
|
+
|
|
3
|
+
Security notes:
|
|
4
|
+
- The access token is only ever sent over HTTPS (plain http is refused except
|
|
5
|
+
for localhost), so a misconfigured/poisoned NORA_API_URL cannot exfiltrate it.
|
|
6
|
+
- Every value placed in a URL path (asset/queue/item names, job id) is
|
|
7
|
+
percent-encoded, so a crafted name cannot inject extra path or query segments.
|
|
8
|
+
- No eval/exec and no shell are used anywhere in this module.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import quote, urlparse
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _enforce_https(api_url: str) -> str:
|
|
23
|
+
"""Refuse to send the bearer token over an unencrypted connection.
|
|
24
|
+
|
|
25
|
+
https is required for every host except localhost (local development).
|
|
26
|
+
"""
|
|
27
|
+
parsed = urlparse(api_url)
|
|
28
|
+
host = (parsed.hostname or "").lower()
|
|
29
|
+
if parsed.scheme != "https" and host not in _LOCAL_HOSTS:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
f"NORA_API_URL must use https (got scheme '{parsed.scheme}' for host "
|
|
32
|
+
f"'{host}'). Refusing to send the access token over plain http."
|
|
33
|
+
)
|
|
34
|
+
return api_url
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _seg(value: Any) -> str:
|
|
38
|
+
"""Percent-encode a single URL path segment (no '/' passes through).
|
|
39
|
+
|
|
40
|
+
Prevents a crafted name (e.g. '../', 'a/b', '?x=1') from injecting extra
|
|
41
|
+
path or query components into the request URL.
|
|
42
|
+
"""
|
|
43
|
+
return quote(str(value), safe="")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _client() -> tuple[httpx.Client, str]:
|
|
47
|
+
api_url = _enforce_https(os.environ.get("NORA_API_URL", "http://localhost:8000/api/v1"))
|
|
48
|
+
# Prefer the per-job exec token; fall back to NORA_MACHINE_TOKEN for agents
|
|
49
|
+
# that predate exec tokens (the agent sets both to the same value).
|
|
50
|
+
token = os.environ.get("NORA_EXEC_TOKEN") or os.environ.get("NORA_MACHINE_TOKEN", "")
|
|
51
|
+
# verify=True (httpx default) — TLS certificate validation stays on.
|
|
52
|
+
client = httpx.Client(
|
|
53
|
+
timeout=15.0,
|
|
54
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
55
|
+
)
|
|
56
|
+
return client, api_url
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_job_id() -> str | None:
|
|
60
|
+
"""Return the ID of the job that launched this bot, or None when running
|
|
61
|
+
outside the agent (e.g. local dev). Injected by the agent as NORA_JOB_ID."""
|
|
62
|
+
return os.environ.get("NORA_JOB_ID") or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_asset(name: str, environment: str = "production") -> dict[str, Any]:
|
|
66
|
+
"""Fetch a decrypted asset by name. Returns {name, type, environment, value, username?}.
|
|
67
|
+
|
|
68
|
+
When the process declares an asset manifest, the agent pre-fetches those
|
|
69
|
+
assets and injects them as NORA_ASSETS — we serve them from there without a
|
|
70
|
+
network call. Anything else falls back to the scoped runtime API.
|
|
71
|
+
"""
|
|
72
|
+
injected = os.environ.get("NORA_ASSETS")
|
|
73
|
+
if injected:
|
|
74
|
+
import json
|
|
75
|
+
try:
|
|
76
|
+
cached = json.loads(injected)
|
|
77
|
+
except ValueError:
|
|
78
|
+
cached = {}
|
|
79
|
+
if name in cached:
|
|
80
|
+
return cached[name]
|
|
81
|
+
|
|
82
|
+
client, api_url = _client()
|
|
83
|
+
try:
|
|
84
|
+
resp = client.get(
|
|
85
|
+
f"{api_url}/agent/assets/{_seg(name)}",
|
|
86
|
+
params={"environment": environment},
|
|
87
|
+
)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return resp.json()["data"]
|
|
90
|
+
finally:
|
|
91
|
+
client.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_queue_item(queue_name: str) -> dict[str, Any] | None:
|
|
95
|
+
"""Claim the next available item from a queue. Returns item data or None."""
|
|
96
|
+
client, api_url = _client()
|
|
97
|
+
try:
|
|
98
|
+
resp = client.post(f"{api_url}/agent/queues/{_seg(queue_name)}/next")
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
return resp.json()["data"]
|
|
101
|
+
finally:
|
|
102
|
+
client.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def complete_queue_item(queue_name: str, item_id: str, result: dict[str, Any]) -> None:
|
|
106
|
+
"""Mark a queue item as completed with result data."""
|
|
107
|
+
client, api_url = _client()
|
|
108
|
+
try:
|
|
109
|
+
resp = client.put(
|
|
110
|
+
f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}/complete",
|
|
111
|
+
json={"result": result},
|
|
112
|
+
)
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
finally:
|
|
115
|
+
client.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fail_queue_item(queue_name: str, item_id: str, error_message: str) -> None:
|
|
119
|
+
"""Mark a queue item as failed (may retry based on queue max_retries)."""
|
|
120
|
+
client, api_url = _client()
|
|
121
|
+
try:
|
|
122
|
+
resp = client.put(
|
|
123
|
+
f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}/fail",
|
|
124
|
+
json={"error_message": error_message},
|
|
125
|
+
)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
finally:
|
|
128
|
+
client.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def check_for_update(current_version: str) -> dict[str, Any]:
|
|
132
|
+
"""Check if a newer agent version is available.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
dict with keys: update_available, latest_version, is_mandatory,
|
|
136
|
+
changelog, download_url_macos, download_url_windows.
|
|
137
|
+
"""
|
|
138
|
+
client, api_url = _client()
|
|
139
|
+
try:
|
|
140
|
+
resp = client.get(
|
|
141
|
+
f"{api_url}/agent/version/latest",
|
|
142
|
+
params={"current": current_version},
|
|
143
|
+
)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
return resp.json()["data"]
|
|
146
|
+
finally:
|
|
147
|
+
client.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def log(level: str, message: str, data: dict[str, Any] | None = None) -> None:
|
|
151
|
+
"""Append a structured log line to the current job's log stream.
|
|
152
|
+
|
|
153
|
+
Requires NORA_JOB_ID (injected automatically by the agent).
|
|
154
|
+
"""
|
|
155
|
+
import json
|
|
156
|
+
import time
|
|
157
|
+
|
|
158
|
+
job_id = os.environ.get("NORA_JOB_ID")
|
|
159
|
+
if not job_id:
|
|
160
|
+
return # No-op outside of a job context
|
|
161
|
+
|
|
162
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
163
|
+
line = f"[{ts}] [{level.upper()}] {message}"
|
|
164
|
+
if data:
|
|
165
|
+
line += f" | {json.dumps(data, ensure_ascii=False)}"
|
|
166
|
+
|
|
167
|
+
client, api_url = _client()
|
|
168
|
+
try:
|
|
169
|
+
resp = client.patch(
|
|
170
|
+
f"{api_url}/agent/jobs/{_seg(job_id)}/logs",
|
|
171
|
+
json={"logs": line + "\n"},
|
|
172
|
+
)
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
finally:
|
|
175
|
+
client.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def update_progress(percent: int, message: str | None = None) -> None:
|
|
179
|
+
"""Report execution progress to the NORA dashboard.
|
|
180
|
+
|
|
181
|
+
Requires NORA_JOB_ID (injected automatically by the agent).
|
|
182
|
+
"""
|
|
183
|
+
job_id = os.environ.get("NORA_JOB_ID")
|
|
184
|
+
if not job_id:
|
|
185
|
+
return # No-op outside of a job context
|
|
186
|
+
|
|
187
|
+
client, api_url = _client()
|
|
188
|
+
try:
|
|
189
|
+
resp = client.patch(
|
|
190
|
+
f"{api_url}/agent/jobs/{_seg(job_id)}/progress",
|
|
191
|
+
json={"percent": max(0, min(100, percent)), "message": message},
|
|
192
|
+
)
|
|
193
|
+
resp.raise_for_status()
|
|
194
|
+
finally:
|
|
195
|
+
client.close()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def add_queue_items(
|
|
199
|
+
queue_name: str,
|
|
200
|
+
items: list[dict[str, Any]],
|
|
201
|
+
priority: int = 3,
|
|
202
|
+
) -> int:
|
|
203
|
+
"""Add items to a queue from within a bot script.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
queue_name: The name of the target queue.
|
|
207
|
+
items: List of dicts — each dict is the payload for one item.
|
|
208
|
+
priority: 1=low, 3=normal, 5=urgent (applied to all items).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Number of items successfully added.
|
|
212
|
+
"""
|
|
213
|
+
client, api_url = _client()
|
|
214
|
+
try:
|
|
215
|
+
resp = client.post(
|
|
216
|
+
f"{api_url}/agent/queues/{_seg(queue_name)}/items/bulk",
|
|
217
|
+
json={"items": items, "priority": priority},
|
|
218
|
+
)
|
|
219
|
+
resp.raise_for_status()
|
|
220
|
+
return resp.json()["data"]["added"]
|
|
221
|
+
finally:
|
|
222
|
+
client.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def send_queue_item_for_review(queue_name: str, item_id: str) -> dict[str, Any]:
|
|
226
|
+
"""Mark a queue item as pending human review.
|
|
227
|
+
|
|
228
|
+
After calling this, use wait_for_queue_review() to block until the
|
|
229
|
+
operator approves or rejects the item.
|
|
230
|
+
"""
|
|
231
|
+
client, api_url = _client()
|
|
232
|
+
try:
|
|
233
|
+
resp = client.put(
|
|
234
|
+
f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}/review"
|
|
235
|
+
)
|
|
236
|
+
resp.raise_for_status()
|
|
237
|
+
return resp.json()["data"]
|
|
238
|
+
finally:
|
|
239
|
+
client.close()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def wait_for_queue_review(
|
|
243
|
+
queue_name: str,
|
|
244
|
+
item_id: str,
|
|
245
|
+
poll_interval: float = 5.0,
|
|
246
|
+
timeout: float = 3600.0,
|
|
247
|
+
) -> str:
|
|
248
|
+
"""Block execution until an operator approves or rejects the item.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
"approved" — item status changed to "new" (continue processing).
|
|
252
|
+
"rejected" — item status changed to "failed" (skip this item).
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
TimeoutError: if timeout seconds elapse without a decision.
|
|
256
|
+
RuntimeError: if the item is in an unexpected state.
|
|
257
|
+
"""
|
|
258
|
+
import time
|
|
259
|
+
|
|
260
|
+
deadline = time.monotonic() + timeout
|
|
261
|
+
while time.monotonic() < deadline:
|
|
262
|
+
client, api_url = _client()
|
|
263
|
+
try:
|
|
264
|
+
resp = client.get(
|
|
265
|
+
f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}"
|
|
266
|
+
)
|
|
267
|
+
resp.raise_for_status()
|
|
268
|
+
item = resp.json()["data"]
|
|
269
|
+
finally:
|
|
270
|
+
client.close()
|
|
271
|
+
|
|
272
|
+
status = item.get("status")
|
|
273
|
+
if status == "new":
|
|
274
|
+
return "approved"
|
|
275
|
+
if status == "failed":
|
|
276
|
+
return "rejected"
|
|
277
|
+
if status != "pending_review":
|
|
278
|
+
raise RuntimeError(
|
|
279
|
+
f"Unexpected item status '{status}' for item {item_id}"
|
|
280
|
+
)
|
|
281
|
+
time.sleep(poll_interval)
|
|
282
|
+
|
|
283
|
+
raise TimeoutError(
|
|
284
|
+
f"Item {item_id} was not reviewed within {timeout} seconds"
|
|
285
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nora-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Client SDK + dev CLI to build and debug robots on NORA (Robots Center).
|
|
5
|
+
Author: Valisoft
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://nora.valisoftconsulting.com
|
|
8
|
+
Keywords: nora,rpa,automation,sdk,robots
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: License :: Other/Proprietary License
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# nora-sdk
|
|
21
|
+
|
|
22
|
+
Client SDK + `nora` developer CLI to build and debug **robots** on NORA
|
|
23
|
+
(Robots Center). Install it to develop locally; in production the NORA agent
|
|
24
|
+
provides the same SDK automatically, so your robot code is identical.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nora-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Uso en el robot
|
|
31
|
+
```python
|
|
32
|
+
from nora_agent import sdk
|
|
33
|
+
|
|
34
|
+
item = sdk.get_queue_item("RPA-Challenge") # consume una cola
|
|
35
|
+
cred = sdk.get_asset("API_KEY") # lee un asset (secreto)
|
|
36
|
+
sdk.log("info", "hola") # log al dashboard
|
|
37
|
+
sdk.update_progress(50, "a mitad")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Desarrollo local con breakpoints
|
|
41
|
+
```bash
|
|
42
|
+
nora login
|
|
43
|
+
nora dev env --write .env # NORA_API_URL + token de dev (dev/staging, no prod)
|
|
44
|
+
# apunta tu IDE a .env y debuggea, o:
|
|
45
|
+
nora dev run main.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Seguridad
|
|
49
|
+
- El token solo viaja por **HTTPS** (rechaza http salvo localhost).
|
|
50
|
+
- Los nombres en la URL van **percent-encoded** (sin inyección de path/query).
|
|
51
|
+
- Sin `shell`, sin `eval`/`exec`. Dependencia única: `httpx`.
|
|
52
|
+
- El token de dev está limitado a `dev`/`staging`: nunca lee secretos de producción.
|
|
53
|
+
- La sesión se guarda en `~/.nora/credentials.json` con permisos solo-dueño.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
nora_agent/__init__.py
|
|
5
|
+
nora_agent/dev_cli.py
|
|
6
|
+
nora_agent/sdk.py
|
|
7
|
+
nora_sdk.egg-info/PKG-INFO
|
|
8
|
+
nora_sdk.egg-info/SOURCES.txt
|
|
9
|
+
nora_sdk.egg-info/dependency_links.txt
|
|
10
|
+
nora_sdk.egg-info/entry_points.txt
|
|
11
|
+
nora_sdk.egg-info/requires.txt
|
|
12
|
+
nora_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<1.0,>=0.27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nora_agent
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nora-sdk"
|
|
7
|
+
description = "Client SDK + dev CLI to build and debug robots on NORA (Robots Center)."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { text = "Proprietary" }
|
|
11
|
+
authors = [{ name = "Valisoft" }]
|
|
12
|
+
keywords = ["nora", "rpa", "automation", "sdk", "robots"]
|
|
13
|
+
dynamic = ["version"]
|
|
14
|
+
|
|
15
|
+
# Minimal, pinned dependency surface on purpose (smaller supply-chain attack
|
|
16
|
+
# surface). The SDK and the `nora` CLI need only httpx.
|
|
17
|
+
dependencies = ["httpx>=0.27,<1.0"]
|
|
18
|
+
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Operating System :: Microsoft :: Windows",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"License :: Other/Proprietary License",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://nora.valisoftconsulting.com"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
nora = "nora_agent.dev_cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
# The package contents are assembled from agent/nora_agent by sdk/assemble.py
|
|
35
|
+
# (single source of truth) — only the client-facing modules, never the agent
|
|
36
|
+
# runtime/installer/service code.
|
|
37
|
+
packages = ["nora_agent"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.dynamic]
|
|
40
|
+
version = { attr = "nora_agent.__version__" }
|
nora_sdk-0.4.0/setup.cfg
ADDED