@ethernity-cloud/sdk-js 1.6.1 → 1.7.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.
Files changed (52) hide show
  1. package/README.md +44 -6
  2. package/cas/config.js +61 -3
  3. package/info.js +333 -324
  4. package/init.js +20 -4
  5. package/nodenithy/build/securelock/Dockerfile.tmpl +34 -32
  6. package/nodenithy/build/securelock/scripts/binary-fs-build.sh +7 -6
  7. package/nodenithy/build/securelock/src/securelock.js.tmpl +5 -2
  8. package/nodenithy/build.mjs +83 -71
  9. package/nodenithy/run/docker-compose-final.yml.tmpl +36 -31
  10. package/nodenithy/run/docker-compose.yml.tmpl +34 -45
  11. package/nodenithy/run/image_registry.js +396 -380
  12. package/nodenithy/run.js +42 -28
  13. package/package.json +5 -3
  14. package/publish.js +4 -2
  15. package/pynithy/build/securelock/Dockerfile.base.tpl +13 -0
  16. package/pynithy/build/securelock/Dockerfile.tpl +131 -0
  17. package/pynithy/build/securelock/scripts/binary-fs-build.sh +25 -34
  18. package/pynithy/build/securelock/scripts/pyfreeze.sh +32 -0
  19. package/pynithy/build/securelock/src/{pox.abi → abi/etnyImplementationV2.abi} +1489 -1489
  20. package/pynithy/build/securelock/src/abi/image_registry.abi +1 -0
  21. package/pynithy/build/securelock/src/ecld_state.py +906 -0
  22. package/pynithy/build/securelock/src/esr.abi +1 -0
  23. package/pynithy/build/securelock/src/esr_local.py +267 -0
  24. package/pynithy/build/securelock/src/esr_wallet.py +150 -0
  25. package/pynithy/build/securelock/src/etny_crypto.py +208 -167
  26. package/pynithy/build/securelock/src/etny_exec.py +385 -79
  27. package/pynithy/build/securelock/src/etny_exec_flask.py +535 -0
  28. package/pynithy/build/securelock/src/etny_exec_serv.py +353 -0
  29. package/pynithy/build/securelock/src/etny_session_exec.py +245 -0
  30. package/pynithy/build/securelock/src/get_sgx_report.so +0 -0
  31. package/pynithy/build/securelock/src/image_registry.abi +1 -1
  32. package/pynithy/build/securelock/src/key_generation.py +17 -17
  33. package/pynithy/build/securelock/src/local_api.py +189 -0
  34. package/pynithy/build/securelock/src/models.py +200 -176
  35. package/pynithy/build/securelock/src/securelock.py.tmpl +1037 -727
  36. package/pynithy/build/securelock/src/swift_stream_service.py +206 -186
  37. package/pynithy/build.mjs +142 -92
  38. package/pynithy/run/docker-compose-final.yml.tmpl +58 -51
  39. package/pynithy/run/docker-compose.yml.tmpl +62 -48
  40. package/pynithy/run/etny-securelock-test.yaml.tpl +4 -1
  41. package/pynithy/run/image_registry.js +396 -380
  42. package/pynithy/run.js +44 -28
  43. package/pynithy/src/serverless/Dockerfile.serverless +8 -0
  44. package/pynithy/src/serverless/requirements.txt +6 -0
  45. package/run.js +69 -28
  46. package/nodenithy/build/las/Dockerfile +0 -5
  47. package/pynithy/build/docker-compose.yml +0 -70
  48. package/pynithy/build/las/Dockerfile +0 -11
  49. package/pynithy/build/las/src/etny_entrypoint.sh +0 -6
  50. package/pynithy/build/las/src/las_entrypoint.sh +0 -26
  51. package/pynithy/build/scripts/binary-fs-build.sh +0 -28
  52. package/pynithy/build/securelock/Dockerfile.tmpl +0 -95
@@ -0,0 +1,353 @@
1
+ # etny_exec.py
2
+
3
+ import os
4
+ import sys
5
+ import ast
6
+ import json
7
+ import threading
8
+ import time
9
+ import secrets
10
+ import socket
11
+ import logging
12
+ from flask import Flask, request, jsonify
13
+ from functools import wraps
14
+ from flask_limiter import Limiter
15
+ from flask_limiter.util import get_remote_address
16
+ from pyngrok import ngrok
17
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError
18
+ import importlib
19
+
20
+ # ===========================
21
+ # Backend Functions Loading
22
+ # ===========================
23
+
24
+ try:
25
+ import serverless.backend as backend
26
+ except ImportError:
27
+ backend = None
28
+ pass
29
+
30
+ sdkFunctions = {}
31
+ if backend is not None:
32
+ for func in backend.__dict__.keys():
33
+ if func not in backend.__builtins__.keys() and func not in [
34
+ "__file__",
35
+ "__cached__",
36
+ "__builtins__",
37
+ ]:
38
+ sdkFunctions.update({func: backend.__dict__[func]})
39
+
40
+
41
+ # ===========================
42
+ # Task Status Definitions
43
+ # ===========================
44
+
45
+ class TaskStatus:
46
+ SUCCESS = 0
47
+ SYSTEM_ERROR = 1
48
+ KEY_ERROR = 2
49
+ SYNTAX_WARNING = 3
50
+ BASE_EXCEPTION = 4
51
+ PAYLOAD_NOT_DEFINED = 5
52
+ PAYLOAD_CHECKSUM_ERROR = 6
53
+ INPUT_CHECKSUM_ERROR = 7
54
+
55
+
56
+ # ===========================
57
+ # Flask and ngrok Setup
58
+ # ===========================
59
+
60
+ class EthernityEnclaveServer:
61
+ def __init__(self, local_port=5000):
62
+ self.api_key = self.generate_api_key()
63
+ self.local_port = local_port
64
+ self.ngrok_url = None
65
+ self.app = Flask(__name__)
66
+ self.setup_logging()
67
+ self.setup_security()
68
+ self.setup_execution_environment()
69
+ self.setup_routes()
70
+ self.server_thread = threading.Thread(target=self.run_flask, daemon=True)
71
+ self.ngrok_thread = threading.Thread(target=self.start_ngrok, daemon=True)
72
+
73
+ def generate_api_key(self):
74
+ """Generates a secure random API key."""
75
+ return secrets.token_hex(16) # Generates a 32-character hexadecimal string
76
+
77
+ def setup_logging(self):
78
+ """Configures logging with rotation."""
79
+ from logging.handlers import RotatingFileHandler
80
+ handler = RotatingFileHandler('app.log', maxBytes=1000000, backupCount=5)
81
+ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
82
+ self.logger = logging.getLogger(__name__)
83
+ self.logger.setLevel(logging.INFO)
84
+ self.logger.addHandler(handler)
85
+ self.logger.info("Logging is set up.")
86
+
87
+ def setup_security(self):
88
+ """Sets up authentication and rate limiting."""
89
+ # Authentication Decorator
90
+ def require_api_key(f):
91
+ @wraps(f)
92
+ def decorated(*args, **kwargs):
93
+ key = request.headers.get('x-api-key')
94
+ if not key or key != self.api_key:
95
+ self.logger.warning("Unauthorized access attempt detected.")
96
+ return jsonify({"status": "error", "message": "Unauthorized: Invalid or missing API key."}), 401
97
+ return f(*args, **kwargs)
98
+ return decorated
99
+
100
+ # Rate Limiting using Flask-Limiter
101
+ self.limiter = Limiter(
102
+ self.app,
103
+ key_func=get_remote_address,
104
+ default_limits=["200 per day", "50 per hour"]
105
+ )
106
+ self.logger.info("Rate limiting enabled: 200 requests/day and 50 requests/hour per IP.")
107
+ self.require_api_key = require_api_key # Attach to instance for use in routes
108
+
109
+ def setup_execution_environment(self):
110
+ """Initializes the secure execution environment."""
111
+ self.globals_context = {
112
+ "___etny_result___": self.___etny_result___,
113
+ **sdkFunctions
114
+ }
115
+
116
+ def setup_routes(self):
117
+ """Defines Flask routes."""
118
+ @self.app.route('/execute', methods=['POST'])
119
+ @self.require_api_key
120
+ @self.limiter.limit("10 per minute") # Additional rate limit for this endpoint
121
+ def execute():
122
+ """
123
+ Executes user-provided Python code and returns the outputs.
124
+ """
125
+ data = request.get_json()
126
+ if not data:
127
+ self.logger.warning("No JSON payload received.")
128
+ return jsonify({"status": "error", "message": "Invalid JSON payload."}), 400
129
+
130
+ payload = data.get('payload')
131
+ input_data = data.get('input_data', None)
132
+
133
+ if not payload:
134
+ self.logger.warning("No 'payload' provided in the request.")
135
+ return jsonify({"status": "error", "message": "No payload provided."}), 400
136
+
137
+ # Execute the payload
138
+ result = execute_task(payload, input_data)
139
+
140
+ return jsonify(result)
141
+
142
+ @self.app.route('/reload_backend', methods=['POST'])
143
+ @self.require_api_key
144
+ def reload_backend():
145
+ """
146
+ Reloads backend functions dynamically.
147
+ """
148
+ self.logger.info("Reloading backend functions...")
149
+ global backend, sdkFunctions
150
+ try:
151
+ importlib.reload(backend) # Reload backend module
152
+ sdkFunctions = {}
153
+ if backend is not None:
154
+ for func in backend.__dict__.keys():
155
+ if func not in backend.__builtins__.keys() and func not in [
156
+ "__file__",
157
+ "__cached__",
158
+ "__builtins__",
159
+ ]:
160
+ sdkFunctions.update({func: backend.__dict__[func]})
161
+ # Update globals_context with new sdkFunctions
162
+ self.globals_context.update(sdkFunctions)
163
+ self.logger.info("Backend functions reloaded successfully.")
164
+ return jsonify({"status": "success", "message": "Backend functions reloaded successfully."}), 200
165
+ except Exception as e:
166
+ self.logger.error(f"Failed to reload backend functions: {e}", exc_info=True)
167
+ return jsonify({"status": "error", "message": "Failed to reload backend functions."}), 500
168
+
169
+ @self.app.route('/health', methods=['GET'])
170
+ def health():
171
+ """Health check endpoint."""
172
+ return jsonify({"status": "healthy"}), 200
173
+
174
+ @self.app.route('/list_functions', methods=['GET'])
175
+ @self.require_api_key
176
+ def list_functions():
177
+ """
178
+ Lists all loaded backend functions.
179
+ """
180
+ functions = {name: func.__doc__ for name, func in sdkFunctions.items() if callable(func)}
181
+ return jsonify({"status": "success", "functions": functions}), 200
182
+
183
+ def run_flask(self):
184
+ """Runs the Flask server."""
185
+ self.app.run(port=self.local_port, debug=False, use_reloader=False)
186
+
187
+ def start_ngrok(self):
188
+ """Starts ngrok tunnel to forward the specified port."""
189
+ try:
190
+ self.logger.info("Starting ngrok tunnel...")
191
+ # Authenticate with ngrok
192
+ ngrok.set_auth_token(os.getenv("NGROK_AUTHTOKEN"))
193
+
194
+ # Forward the port with TLS
195
+ public_url = ngrok.connect(self.local_port, bind_tls=True)
196
+ self.ngrok_url = public_url.public_url
197
+ self.logger.info(f"ngrok tunnel established at {self.ngrok_url}")
198
+ print(f"ngrok tunnel established at {self.ngrok_url}")
199
+ except Exception as e:
200
+ self.logger.error(f"Failed to start ngrok tunnel: {e}", exc_info=True)
201
+
202
+ def start(self):
203
+ """Starts the Flask server and ngrok tunnel."""
204
+ self.server_thread.start()
205
+ self.ngrok_thread.start()
206
+ self.logger.info("Flask server and ngrok tunnel threads started.")
207
+
208
+ # ===========================
209
+ # Execution Functions
210
+ # ===========================
211
+
212
+ def ___etny_result___(self, data):
213
+ """Handles the result of execution."""
214
+ # Here, you can implement any post-execution handling if needed
215
+ # For now, we'll simply return the data
216
+ return data
217
+
218
+ def ___etny_result___(data):
219
+ quit([0, data])
220
+
221
+
222
+ def execute_task(payload_data, input_data):
223
+ return Exec(
224
+ payload_data,
225
+ input_data,
226
+ {"___etny_result___": ___etny_result___, **sdkFunctions},
227
+ )
228
+
229
+
230
+ def Exec(payload_data, input_data, globals=None, locals=None):
231
+ try:
232
+ if payload_data is not None:
233
+ if input_data is not None:
234
+ globals["___etny_data_set___"] = input_data
235
+ module = ast.parse(payload_data)
236
+ outputs = []
237
+ for node in module.body:
238
+ if isinstance(node, ast.Expr):
239
+ expr_code = compile(
240
+ ast.Expression(node.value), filename="<ast>", mode="eval"
241
+ )
242
+ result = eval(expr_code, globals, locals)
243
+ outputs.append(result)
244
+ else:
245
+ # Handle statements if needed
246
+ exec(
247
+ compile(ast.Module([node], type_ignores=[]), filename="<ast>", mode="exec"),
248
+ globals,
249
+ locals,
250
+ )
251
+
252
+ # Function results are arbitrary Python values -- the shipped
253
+ # esr-counter example returns dicts -- so serialize anything that
254
+ # is not already a str instead of letting join() raise. None (an
255
+ # expression statement with no value, e.g. a bare print()) is
256
+ # dropped, matching REPL semantics.
257
+ rendered = [
258
+ out if isinstance(out, str) else json.dumps(out, default=str)
259
+ for out in outputs
260
+ if out is not None
261
+ ]
262
+ return ___etny_result___("\n".join(rendered))
263
+ else:
264
+ return (
265
+ TaskStatus.PAYLOAD_NOT_DEFINED,
266
+ "Could not find the source file to execute",
267
+ )
268
+
269
+ return TaskStatus.SUCCESS, "TASK EXECUTED SUCCESSFULLY"
270
+ except SystemExit as e:
271
+ # A task returns its result ONLY through ___etny_result___(data) ->
272
+ # quit([code, data]) -> SystemExit carrying [code, data]. Honor that
273
+ # embedded code. Handled BEFORE SystemError: the read path's web3/crypto
274
+ # C-extensions intermittently raise a spurious SystemError during the
275
+ # SystemExit unwind, which previously stamped SYSTEM_ERROR onto a task
276
+ # that had already produced the correct value (~50% false read failure).
277
+ import traceback
278
+ try:
279
+ code, data = e.args[0][0], e.args[0][1]
280
+ except Exception:
281
+ tb = traceback.format_exc()
282
+ logging.error("SystemExit without [code, data]: %r -- %s", e, tb)
283
+ return TaskStatus.SYSTEM_ERROR, f"SYSTEM_ERROR: {e!r}\n{tb}"
284
+ if code == 0:
285
+ return TaskStatus.SUCCESS, data
286
+ return int(code), data
287
+ except SystemError as e:
288
+ # A spurious SystemError may replace the SystemExit mid-unwind; recover
289
+ # the real result from the chained exception before failing.
290
+ import traceback
291
+ for chained in (getattr(e, "__context__", None), getattr(e, "__cause__", None)):
292
+ if isinstance(chained, SystemExit):
293
+ try:
294
+ code, data = chained.args[0][0], chained.args[0][1]
295
+ except Exception:
296
+ continue
297
+ logging.warning(
298
+ "SystemError during result unwind; recovered result from "
299
+ "chained SystemExit (code=%r). Native error: %r", code, e)
300
+ if code == 0:
301
+ return TaskStatus.SUCCESS, data
302
+ return int(code), data
303
+ tb = traceback.format_exc()
304
+ logging.error("SystemError in payload execution: %r -- %s", e, tb)
305
+ return TaskStatus.SYSTEM_ERROR, f"SYSTEM_ERROR: {e!r}\n{tb}"
306
+ except KeyError as e:
307
+ return TaskStatus.KEY_ERROR, e.args[0]
308
+ except SyntaxWarning as e:
309
+ return TaskStatus.SYNTAX_WARNING, e.args[0]
310
+ except BaseException as e:
311
+ import traceback
312
+ try:
313
+ if e.args[0][0] == 0:
314
+ return TaskStatus.SUCCESS, e.args[0][1]
315
+ except Exception:
316
+ pass
317
+ tb = traceback.format_exc()
318
+ logging.error("BaseException in payload execution: %r -- %s", e, tb)
319
+ return TaskStatus.BASE_EXCEPTION, f"BASE_EXCEPTION: {e!r}\n{tb}"
320
+
321
+
322
+ # ===========================
323
+ # Main Execution
324
+ # ===========================
325
+
326
+ def main():
327
+ # Initialize and start the EthernityEnclaveServer
328
+ server = EthernityEnclaveServer(local_port=5000)
329
+ server.start()
330
+
331
+ # Wait briefly to ensure ngrok tunnel is established
332
+ time.sleep(2)
333
+
334
+ if not server.ngrok_url:
335
+ print("Failed to establish ngrok tunnel.")
336
+ sys.exit(1)
337
+
338
+ print("EthernityEnclaveServer is running!")
339
+ print(f"ngrok URL: {server.ngrok_url}")
340
+ print(f"API Key: {server.api_key}")
341
+
342
+ # Keep the main thread alive to continue listening for replies
343
+ try:
344
+ while True:
345
+ time.sleep(1)
346
+ except KeyboardInterrupt:
347
+ print("Shutting down the EthernityEnclaveServer.")
348
+ # Disconnect all ngrok tunnels
349
+ ngrok.kill()
350
+ sys.exit()
351
+
352
+ if __name__ == "__main__":
353
+ main()
@@ -0,0 +1,245 @@
1
+ """Interactive session executor for the securelock enclave.
2
+
3
+ Runs when the trustedzone staged 'session.config.securelock'. The payload
4
+ context is created once and kept alive; every streamed input is delivered
5
+ to the handler the payload registered:
6
+
7
+ def ___etny_on_input___(data):
8
+ ...
9
+ return "reply"
10
+
11
+ A payload that defines no handler is not session-aware: each input is
12
+ answered with an explicit error output (no silent re-execution fallback),
13
+ so the dApp developer sees the problem on the first message.
14
+
15
+ Objects (all trustedzone<->securelock traffic uses the existing
16
+ encrypted+signed envelope in both directions):
17
+ session.config.securelock {order_id, close_after_seconds,
18
+ input_cutoff_seconds, max_messages}
19
+ session.input.<seq>.securelock one streamed input (+ .sig)
20
+ session.output.<n> {"ack": seq, "code": c, "data": out}
21
+ session.control.securelock 'close'
22
+
23
+ The TIMEOUT GUARD is the point of this module: the running period ending is
24
+ a COMPLETION, not a failure. The guard arms from the attested
25
+ close_after_seconds delta (monotonic clock -- the enclave never trusts the
26
+ node's wall clock), stops pulling inputs at the cutoff, gives an in-flight
27
+ handler a short grace, and finalizes through the ordinary result path with
28
+ task code SUCCESS and a session summary, e.g.:
29
+
30
+ {"session": "v1", "reason": "RUNNING_PERIOD_COMPLETE",
31
+ "seen": 12, "processed": 12, "emitted": 12, "unprocessed": []}
32
+
33
+ so a full-duration session reads as 'securelock running period complete'.
34
+ The hard-kill path only remains for a guard that never returns -- a genuine
35
+ fault, refunded via the operator-fault flow.
36
+ """
37
+
38
+ import json
39
+ import logging
40
+ import threading
41
+ import time
42
+
43
+ import etny_exec
44
+ from etny_exec import TaskStatus
45
+
46
+ HANDLER_NAME = "___etny_on_input___"
47
+ HANDLER_GRACE_SECONDS = 30
48
+ TICK_SECONDS = 2
49
+
50
+
51
+ def _render(value):
52
+ if value is None:
53
+ return ""
54
+ return value if isinstance(value, str) else json.dumps(value, default=str)
55
+
56
+
57
+ def _resolve_handler(base_globals):
58
+ """The session input handler, from EITHER the namespaced `ecld.on_input`
59
+ or the legacy bare `___etny_on_input___`. `ecld.on_input` wins if both are
60
+ set; returns None if neither is a callable (handler-less payload)."""
61
+ ecld = base_globals.get("ecld")
62
+ if ecld is not None:
63
+ h = getattr(ecld, "on_input", None)
64
+ if callable(h):
65
+ return h
66
+ legacy = base_globals.get(HANDLER_NAME)
67
+ return legacy if callable(legacy) else None
68
+
69
+
70
+ class SecureLockSession:
71
+ def __init__(self, app):
72
+ self.app = app
73
+ cfg = json.loads(app.get_and_verify_trustedzone_object("session.config.securelock"))
74
+ self.order_id = int(cfg.get("order_id", -1))
75
+ self.max_messages = int(cfg.get("max_messages", 256))
76
+ started = time.monotonic()
77
+ self.close_at = started + float(cfg.get("close_after_seconds", 0))
78
+ self.cutoff_at = started + float(cfg.get("input_cutoff_seconds", 0))
79
+ self.seen = 0
80
+ self.processed = 0
81
+ self.emitted = 0
82
+ self.unprocessed = []
83
+ self.reason = "RUNNING_PERIOD_COMPLETE"
84
+ self.globals = etny_exec.session_base_globals()
85
+
86
+ # ------------------------------------------------------------------ pieces
87
+
88
+ def _initial_run(self):
89
+ """Create the payload context. Its own result is not a session output
90
+ -- setup code runs, a handler may get defined, state gets created."""
91
+ code, result = etny_exec.Exec(
92
+ self.app.payload_data, self.app.input_data, globals=self.globals)
93
+ logging.info(f"session: payload context created (code {code})")
94
+ if int(code) != int(TaskStatus.SUCCESS):
95
+ # A payload that cannot even set up cannot serve a session.
96
+ self.reason = "PAYLOAD_SETUP_FAILED"
97
+ return code, result
98
+ return None
99
+
100
+ def _handle(self, seq, data):
101
+ """Deliver one input; returns (code, output). Runs the handler on a
102
+ worker thread so a hung payload cannot outlive the timeout guard."""
103
+ handler = _resolve_handler(self.globals)
104
+ if not callable(handler):
105
+ # No silent fallback: a payload without a handler is not
106
+ # session-aware, so every input gets an explicit, acked error.
107
+ return (int(TaskStatus.PAYLOAD_NOT_DEFINED),
108
+ "SESSION_HANDLER_NOT_DEFINED: this payload defines no "
109
+ "session input handler, so streamed inputs cannot be "
110
+ "processed. Set ecld.on_input = def handler(data): ... "
111
+ "(or the legacy ___etny_on_input___) in the payload and "
112
+ "republish.")
113
+ box = {}
114
+
115
+ def work():
116
+ try:
117
+ box["out"] = (int(TaskStatus.SUCCESS), _render(handler(data)))
118
+ except Exception as e:
119
+ box["out"] = (int(TaskStatus.SYSTEM_ERROR), f"SESSION HANDLER ERROR: {e}")
120
+
121
+ worker = threading.Thread(target=work, daemon=True)
122
+ worker.start()
123
+ budget = max(HANDLER_GRACE_SECONDS, self.close_at - time.monotonic())
124
+ worker.join(budget)
125
+ if worker.is_alive():
126
+ logging.error(f"session: handler hung on input {seq}; abandoning")
127
+ self.unprocessed.append(seq)
128
+ return None
129
+ return box.get("out", (int(TaskStatus.SYSTEM_ERROR), "SESSION HANDLER LOST"))
130
+
131
+ def _session_binding(self, base_name):
132
+ """The prefix each session object's signature covers ahead of the
133
+ data: "<challenge>:<order_id>:<object>:", the task binding the
134
+ trustedzone forwarded in task.securelock extended with the object's
135
+ own name, so the trustedzone can refuse an object captured from
136
+ another order, another run, or another sequence position. Empty
137
+ without a binding (older trustedzone), which then verifies the bare
138
+ data."""
139
+ binding = getattr(self.app, "task_binding", None)
140
+ if not binding:
141
+ return ""
142
+ return f"{binding['challenge']}:{binding['order_id']}:{base_name}:"
143
+
144
+ def _emit(self, ack_seq, code, data):
145
+ envelope = json.dumps({"ack": ack_seq, "code": int(code), "data": data})
146
+ name = f"session.output.{self.emitted}"
147
+ self.app.encrypt_file_and_push_to_swifstream(
148
+ envelope, name, signed_prefix=self._session_binding(name))
149
+ self.emitted += 1
150
+
151
+ def _close_requested(self):
152
+ status, _ = self.app.swift_stream_service.is_object_in_bucket(
153
+ self.app.etny_bucket, "session.control.securelock")
154
+ if not status:
155
+ return False
156
+ # An unauthenticated close signal is ignored: the node could place an
157
+ # object in the bucket, but only the trustedzone can SIGN one.
158
+ try:
159
+ control = self.app.get_and_verify_trustedzone_object("session.control.securelock")
160
+ return str(control).strip() == "close"
161
+ except Exception:
162
+ return False
163
+
164
+ # --------------------------------------------------------------------- run
165
+
166
+ def run(self):
167
+ setup_failure = self._initial_run()
168
+ if setup_failure is not None:
169
+ return setup_failure
170
+ # READINESS HANDSHAKE: tell the trustedzone this securelock speaks the
171
+ # session protocol AND whether the payload defined an input handler.
172
+ # A pre-session securelock never writes this (the trustedzone answers
173
+ # every input with an explicit error), and a handler-less payload is
174
+ # announced by the trustedzone as a signed code-5 error row on the
175
+ # DP-request metadata side before any input is even sent.
176
+ try:
177
+ ready = json.dumps({
178
+ "ready": True,
179
+ "handler": callable(_resolve_handler(self.globals)),
180
+ })
181
+ self.app.encrypt_file_and_push_to_swifstream(
182
+ ready, "session.ready",
183
+ signed_prefix=self._session_binding("session.ready"))
184
+ except Exception as e:
185
+ logging.error(f"session: could not publish readiness: {e}")
186
+ next_seq = 0
187
+ while True:
188
+ now = time.monotonic()
189
+ if now >= self.close_at:
190
+ self.reason = "RUNNING_PERIOD_COMPLETE"
191
+ break
192
+ if self._close_requested():
193
+ self.reason = "CLOSED"
194
+ break
195
+ if next_seq >= self.max_messages:
196
+ self.reason = "MESSAGE_CAP"
197
+ break
198
+ obj = f"session.input.{next_seq}.securelock"
199
+ status, _ = self.app.swift_stream_service.is_object_in_bucket(
200
+ self.app.etny_bucket, obj)
201
+ if not status:
202
+ time.sleep(TICK_SECONDS)
203
+ continue
204
+ if now >= self.cutoff_at:
205
+ # Delivered past the cutoff: the trustedzone answers these
206
+ # with signed late notices; we only stop consuming.
207
+ self.reason = "RUNNING_PERIOD_COMPLETE"
208
+ break
209
+ try:
210
+ data = self.app.get_and_verify_trustedzone_object(obj)
211
+ except Exception as e:
212
+ logging.error(f"session: input {next_seq} failed verification: {e}")
213
+ time.sleep(TICK_SECONDS)
214
+ continue
215
+ self.seen += 1
216
+ outcome = self._handle(next_seq, data)
217
+ if outcome is not None:
218
+ code, out = outcome
219
+ # Only successful handling counts as processed; error outputs
220
+ # (handler missing / handler raised) are still emitted+acked
221
+ # so the dApp sees them, but the summary stays truthful.
222
+ if int(code) == int(TaskStatus.SUCCESS):
223
+ self.processed += 1
224
+ self._emit(next_seq, code, out)
225
+ next_seq += 1
226
+ summary = {
227
+ "session": "v1",
228
+ "reason": self.reason,
229
+ "seen": self.seen,
230
+ "processed": self.processed,
231
+ "emitted": self.emitted,
232
+ "unprocessed": self.unprocessed,
233
+ }
234
+ logging.info(f"session: finalizing -- {summary}")
235
+ return (TaskStatus.SUCCESS, json.dumps(summary))
236
+
237
+
238
+ def run_session(app):
239
+ """Entry point used by securelock.execute(). Any unexpected failure
240
+ degrades to an ordinary task error, never a hang."""
241
+ try:
242
+ return SecureLockSession(app).run()
243
+ except Exception as e:
244
+ logging.error(f"session executor failed: {e}")
245
+ return (TaskStatus.SYSTEM_ERROR, f"SESSION EXECUTOR ERROR: {e}")