diff --git a/internal/recipe/context.go b/internal/recipe/context.go index 7592595..0f24adb 100644 --- a/internal/recipe/context.go +++ b/internal/recipe/context.go @@ -1,6 +1,7 @@ package recipe import ( + "regexp" "strings" ) @@ -11,6 +12,12 @@ type ExecContext struct { EnvVars map[string]string } +// This regex matches: +// 1. $$ +// 2. ${ANY_WORD} +// 3. $ANY_WORD +var envRegex = regexp.MustCompile(`\$\$(\$)?|\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)`) + // WrappedCommand returns the full shell command for a RUN step with context // applied. The result is passed as the argument to /bin/sh -c. // @@ -64,66 +71,24 @@ func (c *ExecContext) shellPrefix() string { // If a variable is not found in the vars map, it is replaced with an empty // string. func expandEnv(s string, vars map[string]string) string { - var sb strings.Builder - sb.Grow(len(s) * 2) - - for { - idx := strings.IndexByte(s, '$') - if idx < 0 { - sb.WriteString(s) - break - } - - sb.WriteString(s[:idx]) - s = s[idx:] - - if len(s) == 1 { - sb.WriteByte('$') - break - } - - if s[1] == '$' { - sb.WriteByte('$') - s = s[2:] - continue + return envRegex.ReplaceAllStringFunc(s, func(match string) string { + if match == "$$" { + return "$" } var name string - var advance int - - if s[1] == '{' { - end := strings.IndexByte(s[2:], '}') - if end < 0 { - sb.WriteByte('$') - s = s[1:] - continue - } - name = s[2 : 2+end] - advance = 2 + end + 1 + if match[1] == '{' { + name = match[2 : len(match)-1] } else { - j := 1 - for j < len(s) && isNameChar(s[j]) { - j++ - } - name = s[1:j] - advance = j + name = match[1:] } if v, ok := vars[name]; ok { - sb.WriteString(v) + return v } - s = s[advance:] - } - - return sb.String() -} - -// isNameChar reports whether the byte c is a valid character for an -// environment variable name (alphanumeric or underscore) -func isNameChar(c byte) bool { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '_' + return "" + }) } // shellescape wraps s in single quotes, escaping any embedded single quotes. diff --git a/recipes/python-interpreter-v0-beta.recipefile b/recipes/python-interpreter-v0-beta.recipefile index 50221b4..e83f5da 100644 --- a/recipes/python-interpreter-v0-beta.recipefile +++ b/recipes/python-interpreter-v0-beta.recipefile @@ -2,6 +2,6 @@ RUN apt-get install -y --no-install-recommends python3 python3-pip python3-venv RUN python3 -m venv /opt/venv ENV PATH=/opt/venv/bin:$PATH -RUN --timeout=5m pip install --no-cache-dir notebook +RUN --timeout=5m pip install --no-cache-dir jupyter-server ipykernel -START jupyter notebook --no-browser --ip=0.0.0.0 --port=8888 --ServerApp.token='' --ServerApp.allow_origin='*' --allow-root +START jupyter server --ServerApp.ip=0.0.0.0 --ServerApp.port=8888 --ServerApp.token='' --ServerApp.allow_origin='*' --ServerApp.disable_check_xsrf=True --no-browser --allow-root diff --git a/recipes/test-jupyter-kernel.py b/recipes/test-jupyter-kernel.py new file mode 100755 index 0000000..7c5c162 --- /dev/null +++ b/recipes/test-jupyter-kernel.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +import uuid + +try: + import websocket +except ImportError: + print("websocket-client is required: pip install websocket-client") + sys.exit(1) + + +def create_kernel(base_url: str, token: str) -> str: + import urllib.request + + url = f"{base_url}/api/kernels" + headers = {} + if token: + headers["X-API-Key"] = token + + req = urllib.request.Request(url, method="POST", data=b"", headers=headers) + resp = urllib.request.urlopen(req) + data = json.loads(resp.read()) + kernel_id = data["id"] + print(f"Created kernel: {kernel_id}") + return kernel_id + + +def execute_code(ws: websocket.WebSocket, code: str) -> dict: + msg_id = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + msg = { + "header": { + "msg_type": "execute_request", + "msg_id": msg_id, + "username": "", + "session": session_id, + "version": "5.3", + "date": "", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + }, + "buffers": [], + "channel": "shell", + } + ws.send(json.dumps(msg)) + + result = {"stdout": "", "stderr": "", "output": None, "error": None} + + while True: + resp = json.loads(ws.recv()) + + # CRITICAL FIX: Ignore messages left over from previous executions + parent_id = resp.get("parent_header", {}).get("msg_id") + if parent_id != msg_id: + continue + + msg_type = resp.get("msg_type", "") + + if msg_type == "stream": + result["stdout"] += resp["content"]["text"] + elif msg_type == "error": + result["error"] = "\n".join(resp["content"].get("traceback", [])) + elif msg_type == "execute_result": + result["output"] = resp["content"]["data"] + elif msg_type == "status": + if resp["content"]["execution_state"] == "idle": + break + + return result + + +def main(): + parser = argparse.ArgumentParser( + description="Test Jupyter kernel state management in a sandbox" + ) + parser.add_argument( + "sandbox_id", + help="Sandbox ID (e.g. cl-8nxizn9ygtczplsnn9jve38be)", + ) + parser.add_argument( + "--domain", + default="localhost:8080", + help="Proxy domain (default: localhost:8080)", + ) + parser.add_argument( + "--port", + default="8888", + help="Jupyter port inside the sandbox (default: 8888)", + ) + parser.add_argument( + "--key", + default="", + help="Wrenn API Token", + ) + args = parser.parse_args() + + base_url = f"http://{args.port}-{args.sandbox_id}.{args.domain}" + ws_base = base_url.replace("http", "ws", 1) + + print(f"Testing Jupyter kernel at {base_url}") + print() + + kernel_id = create_kernel(base_url, args.key) + + ws_url = f"{ws_base}/api/kernels/{kernel_id}/channels" + + # Pass auth headers to the WebSocket if a token was provided + ws_headers = {} + if args.key: + ws_headers["X-API-Key"] = args.key + + ws = websocket.create_connection(ws_url, header=ws_headers) + print("Connected to kernel WebSocket") + print() + + tests = [ + ("variable assignment", "x = 42", None), + ("read variable", "x * 2", "84"), + ("import", "import math", None), + ("use import", "math.sqrt(144)", "12.0"), + ("function definition", "def greet(name): return f'hello {name}'", None), + # Fixed: Jupyter 'execute_result' strings include the literal single quotes + ("call function", "greet('sandbox')", "'hello sandbox'"), + ("list mutation", "items = [1, 2, 3]; items.append(4); items", "[1, 2, 3, 4]"), + ] + + passed = 0 + failed = 0 + + for name, code, expected in tests: + print(f" {name}: {code}") + result = execute_code(ws, code) + + if result["error"]: + print(f" ERROR: {result['error']}") + failed += 1 + continue + + output = result["stdout"].strip() + if not output and result["output"]: + if "text/plain" in result["output"]: + output = result["output"]["text/plain"].strip() + + if expected is not None: + if output == expected: + print(f" PASS (got: {output})") + passed += 1 + else: + print(f" FAIL (expected: {expected}, got: {output})") + failed += 1 + else: + print(" OK") + passed += 1 + + ws.close() + print() + print(f"Results: {passed} passed, {failed} failed") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main()