Merge branch 'feat/tool-definition-manifest' into dev
Conflicts resolved: - models/__init__.py: kept both TerminalSessionModel (from dev) and ToolDefinitionManifest (from feature branch) - alembic migration: kept full migration (already applied to DB) - openspec/config.yaml: kept full config with SDD settings
This commit is contained in:
+568
@@ -0,0 +1,568 @@
|
||||
{
|
||||
"version": "v2",
|
||||
"timestamp": 1779889907001,
|
||||
"ruleHash": "fd9b2b15f2ac8993",
|
||||
"queries": [
|
||||
{
|
||||
"id": "bare-except",
|
||||
"name": "Bare Except Clause",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
|
||||
"query": " (except_clause\n \"except\") @CLAUSE",
|
||||
"metavars": [
|
||||
"CLAUSE"
|
||||
],
|
||||
"post_filter": "bare_except_only",
|
||||
"defect_class": "silent-error",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
|
||||
},
|
||||
{
|
||||
"id": "eval-exec",
|
||||
"name": "Eval/Exec Usage",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
|
||||
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
|
||||
},
|
||||
{
|
||||
"id": "exit-signature-check",
|
||||
"name": "__exit__ Missing Parameters",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "__exit__ should accept type, value, and traceback arguments",
|
||||
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"SELF",
|
||||
"PARAM1",
|
||||
"PARAM2",
|
||||
"PARAM3"
|
||||
],
|
||||
"post_filter": "exit_params_insufficient",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
|
||||
},
|
||||
{
|
||||
"id": "in-operator-unsupported",
|
||||
"name": "In and Not In Operators Should Be Used on Valid Objects",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "'in' operator used on object that may not support containment",
|
||||
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"TARGET"
|
||||
],
|
||||
"post_filter": "check_in_operator_types",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
|
||||
},
|
||||
{
|
||||
"id": "is-vs-equals",
|
||||
"name": "Is vs Equals for Literals",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Using 'is' with literal — use '==' for value comparison",
|
||||
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
|
||||
"metavars": [
|
||||
"LITERAL"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
|
||||
},
|
||||
{
|
||||
"id": "iter-return-iterator",
|
||||
"name": "__iter__ Should Return Iterator",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "__iter__ should return an iterator (object with __next__ method)",
|
||||
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"RETURN"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
|
||||
},
|
||||
{
|
||||
"id": "mutable-default-arg",
|
||||
"name": "Mutable Default Argument",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Mutable default argument — list/dict/set as default value",
|
||||
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
|
||||
"metavars": [
|
||||
"PARAM",
|
||||
"MUTABLE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-super-torchscript",
|
||||
"name": "super Should Not Be Used in TorchScript Methods",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "super() calls should not be used in TorchScript methods",
|
||||
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
|
||||
"metavars": [
|
||||
"DEC",
|
||||
"FUNC",
|
||||
"CALL"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
|
||||
},
|
||||
{
|
||||
"id": "notimplemented-boolean-context",
|
||||
"name": "NotImplemented in Boolean Context",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "NotImplemented should not be used in boolean contexts",
|
||||
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
|
||||
"metavars": [
|
||||
"COND"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-assert-production",
|
||||
"name": "Assert in Production Code",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
|
||||
"query": " (assert_statement) @ASSERT",
|
||||
"metavars": [
|
||||
"ASSERT"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-command-injection",
|
||||
"name": "Command Injection Sink",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Potential command injection sink — avoid shell execution with dynamic input",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS",
|
||||
"KW"
|
||||
],
|
||||
"post_filter": "py_command_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-cross-language-method",
|
||||
"name": "Cross-Language Method Leakage",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
|
||||
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-debugger",
|
||||
"name": "Debugger Statement",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Debugger call '{{FUNC}}' — remove before committing",
|
||||
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"MOD"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-empty-except",
|
||||
"name": "Empty Except Block",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Except block only contains 'pass' — handle or re-raise the exception",
|
||||
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "python_empty_except",
|
||||
"defect_class": "silent-error",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-hallucinated-import",
|
||||
"name": "Hallucinated Import",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
|
||||
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
|
||||
"metavars": [
|
||||
"MODULE",
|
||||
"NAME"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
|
||||
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-hardcoded-secrets",
|
||||
"name": "Hardcoded Secret",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
|
||||
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
|
||||
"metavars": [
|
||||
"VARNAME",
|
||||
"VALUE"
|
||||
],
|
||||
"post_filter": "check_secret_pattern",
|
||||
"defect_class": "secrets",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-insecure-deserialization",
|
||||
"name": "Insecure Deserialization",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"DATA"
|
||||
],
|
||||
"post_filter": "py_insecure_deserialization_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-insecure-random",
|
||||
"name": "Insecure Randomness",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-mutable-class-attr",
|
||||
"name": "Mutable Class Attribute",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
|
||||
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
|
||||
"metavars": [
|
||||
"VARNAME",
|
||||
"VALUE"
|
||||
],
|
||||
"post_filter": "not_in_function",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-path-traversal",
|
||||
"name": "Path Traversal Risk",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Potential path traversal sink — sanitize and constrain file paths",
|
||||
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"PATH"
|
||||
],
|
||||
"post_filter": "py_path_traversal_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-print-statement",
|
||||
"name": "Print Statement in Production",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "print() — remove debug output before committing",
|
||||
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "not_in_test_block",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-raise-string",
|
||||
"name": "Raise String Instead of Exception",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "raise with string literal — Python 3 requires exception instances",
|
||||
"query": " (raise_statement\n (string) @VALUE)",
|
||||
"metavars": [
|
||||
"VALUE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-sleep-in-test",
|
||||
"name": "time.sleep in Test",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"CALL"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-sql-injection",
|
||||
"name": "SQL Injection Risk",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Potential SQL injection sink — use parameterized queries",
|
||||
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"SQL"
|
||||
],
|
||||
"post_filter": "py_sql_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-ssrf",
|
||||
"name": "SSRF Risk",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"URL"
|
||||
],
|
||||
"post_filter": "py_ssrf_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-subprocess-shell",
|
||||
"name": "subprocess with shell=True",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"KW"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-thread-global-write",
|
||||
"name": "Threaded Shared State Risk",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Thread creation detected — ensure shared state mutations are synchronized",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-unsafe-regex",
|
||||
"name": "Unsafe Dynamic Regex",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FUNC",
|
||||
"PATTERN"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-weak-hash",
|
||||
"name": "Weak Hash Primitive",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
|
||||
},
|
||||
{
|
||||
"id": "return-in-generator",
|
||||
"name": "Return with Value in Generator",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "'return' with a value should not be used in a generator function",
|
||||
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
|
||||
"metavars": [
|
||||
"FUNCTION",
|
||||
"RETURN",
|
||||
"RETURN_VAL"
|
||||
],
|
||||
"post_filter": "is_generator_with_valued_return",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
|
||||
},
|
||||
{
|
||||
"id": "return-in-init",
|
||||
"name": "Return Value in __init__",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "__init__ should not return a value — it must always return None",
|
||||
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"RETURN",
|
||||
"RETURN_VAL"
|
||||
],
|
||||
"post_filter": "has_return_value",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
|
||||
},
|
||||
{
|
||||
"id": "send-file-mimetype",
|
||||
"name": "send_file Should Specify Mimetype or Download Name",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
|
||||
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"FIRST_ARG",
|
||||
"KW"
|
||||
],
|
||||
"post_filter": "missing_mimetype_and_download_name",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
|
||||
},
|
||||
{
|
||||
"id": "unreachable-except",
|
||||
"name": "Unreachable Except Clause",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Unreachable except clause — earlier except catches all",
|
||||
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
|
||||
"metavars": [
|
||||
"GENERAL",
|
||||
"SPECIFIC"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
|
||||
},
|
||||
{
|
||||
"id": "wildcard-import",
|
||||
"name": "Wildcard Import",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Wildcard import — pollutes namespace, hard to track origin",
|
||||
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
|
||||
"metavars": [
|
||||
"MODULE",
|
||||
"WILDCARD"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "yield-return-outside-function",
|
||||
"name": "Yield/Return Outside Function",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "{{STATEMENT}} used outside function — syntax error",
|
||||
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
|
||||
"metavars": [
|
||||
"STATEMENT"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
||||
"""add terminal_sessions table
|
||||
|
||||
Revision ID: 2026_05_28_add_terminal_sessions
|
||||
Revises: 20260527_160017_add_pi_agent
|
||||
Create Date: 2026-05-28
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_28_add_terminal_sessions"
|
||||
down_revision: str | None = "2026_05_28_add_tool_definition_manifests"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"terminal_sessions",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("instance_id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
onupdate=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"], ["tool_instances.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_terminal_sessions_instance_id"),
|
||||
"terminal_sessions",
|
||||
["instance_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_terminal_sessions_instance_id"),
|
||||
table_name="terminal_sessions",
|
||||
)
|
||||
op.drop_table("terminal_sessions")
|
||||
+470
-72
@@ -1,16 +1,20 @@
|
||||
"""WebSocket terminal endpoint for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_db_session
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.terminal_manager import terminal_manager
|
||||
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,32 +23,58 @@ logger = logging.getLogger(__name__)
|
||||
class SessionRef:
|
||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||
|
||||
def __init__(self, session):
|
||||
def __init__(self, session, slot_session_id: str | None = None):
|
||||
self.session = session
|
||||
self.slot_session_id = slot_session_id or session.session_id
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal",
|
||||
)
|
||||
async def terminal_websocket(
|
||||
async def terminal_websocket_default(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for terminal access to a tool instance.
|
||||
"""WebSocket endpoint for terminal access (default session alias).
|
||||
|
||||
Provides an interactive terminal session inside a running tool instance container.
|
||||
Sessions persist across WebSocket disconnections.
|
||||
Backward-compatible route that maps to the default session.
|
||||
"""
|
||||
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
|
||||
)
|
||||
async def terminal_websocket_specific(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for a specific terminal session."""
|
||||
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||
|
||||
|
||||
async def _handle_terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
target_session_id: str | None,
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Shared WebSocket handler for terminal sessions.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
instance_id: UUID string of the tool instance.
|
||||
target_session_id: Specific session ID (slot key). None means default session.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
None. Communicates via WebSocket messages.
|
||||
"""
|
||||
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||
logger.debug(
|
||||
"Terminal WebSocket connection attempt for instance %s (session=%s)",
|
||||
instance_id,
|
||||
target_session_id or "default",
|
||||
)
|
||||
await websocket.accept()
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
@@ -59,7 +89,9 @@ async def terminal_websocket(
|
||||
# Authenticate user from session cookie
|
||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||
if user_id is None:
|
||||
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
|
||||
logger.warning(
|
||||
"Unauthorized terminal access attempt for instance %s", instance_id
|
||||
)
|
||||
await websocket.close(code=4003, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -71,12 +103,21 @@ async def terminal_websocket(
|
||||
return
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
|
||||
logger.warning(
|
||||
"Forbidden terminal access for instance %s by user %s",
|
||||
instance_id,
|
||||
user_id,
|
||||
)
|
||||
await websocket.close(code=4003, reason="Forbidden")
|
||||
return
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
|
||||
logger.warning(
|
||||
"Instance %s not running (status=%s, container_id=%s)",
|
||||
instance_id,
|
||||
instance.status,
|
||||
instance.container_id,
|
||||
)
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
@@ -86,16 +127,50 @@ async def terminal_websocket(
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
if startup_command:
|
||||
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||
logger.debug(
|
||||
"Using startup command for instance %s: %s",
|
||||
instance_id,
|
||||
startup_command,
|
||||
)
|
||||
|
||||
session = None
|
||||
|
||||
# Get or create terminal session
|
||||
try:
|
||||
session = await terminal_manager.get_or_create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
if target_session_id is None:
|
||||
# Default session alias
|
||||
session = await terminal_manager.get_or_create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
slot_session_id = "default"
|
||||
else:
|
||||
# Specific session
|
||||
session = terminal_manager.get_session(
|
||||
instance_id,
|
||||
target_session_id,
|
||||
)
|
||||
if session is None:
|
||||
logger.warning(
|
||||
"Session %s not found for instance %s",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4004, reason="Session not found")
|
||||
return
|
||||
# Determine slot key for reset scoping
|
||||
key = terminal_manager._find_key_by_internal_id(
|
||||
instance_id, session.session_id
|
||||
)
|
||||
slot_session_id = key[1] if key else target_session_id
|
||||
|
||||
logger.debug(
|
||||
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
|
||||
instance_id,
|
||||
session.session_id,
|
||||
slot_session_id,
|
||||
)
|
||||
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||
|
||||
# Attach WebSocket to session
|
||||
await terminal_manager.attach_websocket(session, websocket)
|
||||
@@ -106,11 +181,13 @@ async def terminal_websocket(
|
||||
logger.debug("Sent connected status for instance %s", instance_id)
|
||||
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session)
|
||||
session_ref = SessionRef(session, slot_session_id)
|
||||
|
||||
# Start I/O loops and heartbeat
|
||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||
write_task = asyncio.create_task(
|
||||
_write_loop(session_ref, websocket, instance_id)
|
||||
)
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||
|
||||
@@ -119,24 +196,33 @@ async def terminal_websocket(
|
||||
[read_task, write_task, heartbeat_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||
|
||||
|
||||
logger.debug(
|
||||
"Terminal loop completed for instance %s, done=%s",
|
||||
instance_id,
|
||||
len(done),
|
||||
)
|
||||
|
||||
# Cancel remaining tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||
logger.error(
|
||||
"Terminal session error for instance %s: %s",
|
||||
instance_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||
finally:
|
||||
# Detach WebSocket, don't kill session
|
||||
try:
|
||||
if 'session' in locals():
|
||||
with suppress(Exception):
|
||||
if session is not None:
|
||||
await terminal_manager.detach_websocket(session, websocket)
|
||||
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug(
|
||||
"WebSocket detached from session for instance %s", instance_id
|
||||
)
|
||||
|
||||
|
||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||
@@ -175,38 +261,54 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
import json
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
msg_type = ctrl.get("type")
|
||||
|
||||
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||
logger.debug(
|
||||
"Received resize message for instance %s: %sx%s",
|
||||
instance_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session
|
||||
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||
|
||||
# Reset the session
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
"Resetting terminal session for instance %s (slot=%s)",
|
||||
session.instance_id,
|
||||
session_ref.slot_session_id,
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "resetting"}
|
||||
)
|
||||
|
||||
# Reset the session scoped to its slot
|
||||
new_session = await terminal_manager.reset_session(
|
||||
session.instance_id,
|
||||
session.container_id,
|
||||
startup_command=session.startup_command,
|
||||
session_id=session_ref.slot_session_id,
|
||||
name=session.name,
|
||||
)
|
||||
|
||||
# Update the mutable session reference so read_loop uses the new session
|
||||
|
||||
# Update the mutable session reference
|
||||
session_ref.session = new_session
|
||||
|
||||
|
||||
# Attach to new session
|
||||
await terminal_manager.attach_websocket(new_session, websocket)
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
|
||||
await terminal_manager.attach_websocket(
|
||||
new_session, websocket
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "connected"}
|
||||
)
|
||||
|
||||
# Continue the loop with the new session
|
||||
continue
|
||||
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not a valid JSON control message, treat as regular input
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
@@ -232,56 +334,347 @@ async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
|
||||
summary="Reset terminal session",
|
||||
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
|
||||
)
|
||||
async def reset_terminal_session(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
async def _get_terminal_instance(
|
||||
instance_id: uuid.UUID,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset the terminal session for an instance.
|
||||
user_id: uuid.UUID,
|
||||
db_session: AsyncSession,
|
||||
) -> ToolInstance:
|
||||
"""Fetch instance and validate auth, ownership, and running status.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
repo_id: UUID of the repository.
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with status message.
|
||||
The validated ToolInstance.
|
||||
|
||||
Raises:
|
||||
HTTPException: If instance not found, not owned, or not running.
|
||||
"""
|
||||
# Get instance and verify it exists and is running
|
||||
instance = await db_session.get(ToolInstance, instance_id)
|
||||
if instance is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Instance not found"
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
|
||||
)
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this instance",
|
||||
)
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Instance is not running"
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@router.get(
|
||||
"/instances/{instance_id}/terminal/sessions",
|
||||
summary="List terminal sessions",
|
||||
description="List terminal sessions for a tool instance with live WebSocket state.",
|
||||
)
|
||||
async def list_terminal_sessions(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List terminal sessions for an instance.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with sessions list.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
# Query active DB rows for this instance
|
||||
result = await db_session.execute(
|
||||
select(TerminalSessionModel)
|
||||
.where(TerminalSessionModel.instance_id == instance_id)
|
||||
.where(TerminalSessionModel.status != "closed")
|
||||
.order_by(TerminalSessionModel.created_at.asc())
|
||||
)
|
||||
db_rows = result.scalars().all()
|
||||
|
||||
# Build response with live has_websockets flag
|
||||
sessions = []
|
||||
for row in db_rows:
|
||||
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
|
||||
sessions.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"name": row.name,
|
||||
"status": row.status,
|
||||
"has_websockets": live_session.has_websockets()
|
||||
if live_session
|
||||
else False,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"last_activity_at": row.last_activity_at.isoformat()
|
||||
if row.last_activity_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions",
|
||||
summary="Create terminal session",
|
||||
description="Create a new terminal session for a running tool instance.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a new terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
data: Request body with optional name.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with new session details.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 if max sessions reached.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
name = data.get("name")
|
||||
|
||||
try:
|
||||
session = await terminal_manager.create_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
name=name,
|
||||
)
|
||||
except MaxSessionsExceededError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Maximum of 5 terminal sessions reached for this instance",
|
||||
) from None
|
||||
|
||||
return {
|
||||
"id": session.session_id,
|
||||
"name": session.name,
|
||||
"status": session.status,
|
||||
"created_at": session.last_activity,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
||||
summary="Close terminal session",
|
||||
description="Close a specific terminal session.",
|
||||
)
|
||||
async def close_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Close a terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to close.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with closure status.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
# Find the session by internal ID to determine its slot key
|
||||
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||
if (
|
||||
key is None
|
||||
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||
):
|
||||
key = (str(instance_id), session_id)
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
await terminal_manager.close_session(key[0], key[1])
|
||||
|
||||
return {"status": "closed", "session_id": session_id}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
|
||||
summary="Reset terminal session",
|
||||
description="Reset a specific terminal session, killing the current shell and starting fresh.",
|
||||
)
|
||||
async def reset_specific_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset a specific terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to reset.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with reset session details.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Determine slot key for reset
|
||||
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||
if (
|
||||
key is None
|
||||
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||
):
|
||||
key = (str(instance_id), session_id)
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
# Preserve name if possible
|
||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||
name = live_session.name if live_session else None
|
||||
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
session_id=key[1],
|
||||
name=name,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_session.session_id,
|
||||
"name": new_session.name,
|
||||
"status": new_session.status,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
||||
summary="Rename terminal session",
|
||||
description="Rename a specific terminal session.",
|
||||
)
|
||||
async def rename_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Rename a terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to rename.
|
||||
data: Request body with new name.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with updated session details.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
new_name = data.get("name")
|
||||
if not new_name or not isinstance(new_name, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
||||
)
|
||||
|
||||
# Update in-memory session name if live
|
||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||
if live_session:
|
||||
live_session.name = new_name
|
||||
|
||||
# Update DB row
|
||||
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
|
||||
if db_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
db_row.name = new_name
|
||||
await db_session.commit()
|
||||
|
||||
return {"id": str(db_row.id), "name": new_name}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/reset",
|
||||
summary="Reset terminal session (legacy alias)",
|
||||
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
|
||||
)
|
||||
async def reset_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset the default terminal session for an instance (legacy alias).
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with status message.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
try:
|
||||
# Reset the session
|
||||
# Reset the default session
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
|
||||
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Terminal session reset for instance %s (new session_id=%s)",
|
||||
instance_id,
|
||||
new_session.session_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Terminal session reset successfully",
|
||||
@@ -289,11 +682,16 @@ async def reset_terminal_session(
|
||||
"session_id": new_session.session_id,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||
logger.error(
|
||||
"Failed to reset terminal session for instance %s: %s",
|
||||
instance_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset terminal session: {exc}"
|
||||
)
|
||||
detail=f"Failed to reset terminal session: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
async def _get_user_from_websocket(
|
||||
|
||||
@@ -25,6 +25,7 @@ from src.api.tool_types import router as tool_types_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.config import Settings
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
from src.database import init_database
|
||||
from src.logging_config import (
|
||||
ExceptionLoggingMiddleware,
|
||||
|
||||
@@ -4,6 +4,7 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -18,6 +19,7 @@ __all__ = [
|
||||
"GitRepository",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"TerminalSessionModel",
|
||||
"ToolDefinitionManifest",
|
||||
"ToolInstance",
|
||||
"ToolType",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Terminal session database model."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Database model for terminal session metadata."""
|
||||
|
||||
__tablename__ = "terminal_sessions"
|
||||
|
||||
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="active",
|
||||
)
|
||||
last_activity_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
@@ -3,20 +3,37 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxSessionsExceededError(Exception):
|
||||
"""Raised when the maximum number of terminal sessions per instance is reached."""
|
||||
|
||||
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
|
||||
self.instance_id = instance_id
|
||||
self.max_sessions = max_sessions
|
||||
super().__init__(
|
||||
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
|
||||
)
|
||||
|
||||
|
||||
class TerminalManager:
|
||||
"""Manages active terminal sessions with persistence support."""
|
||||
|
||||
# Maximum sessions per tool instance
|
||||
MAX_SESSIONS_PER_INSTANCE = 5
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Track sessions by instance_id for persistence
|
||||
self._sessions: dict[str, TerminalSession] = {}
|
||||
# Track sessions by (instance_id, session_id) for multi-session support
|
||||
self._sessions: dict[tuple[str, str], TerminalSession] = {}
|
||||
self._idle_check_task: asyncio.Task | None = None
|
||||
self._start_idle_check()
|
||||
|
||||
@@ -42,16 +59,131 @@ class TerminalManager:
|
||||
|
||||
async def _cleanup_idle_sessions(self) -> None:
|
||||
"""Clean up sessions that have been idle for too long."""
|
||||
idle_sessions = []
|
||||
for instance_id, session in list(self._sessions.items()):
|
||||
idle_keys = []
|
||||
for (instance_id, session_id), session in list(self._sessions.items()):
|
||||
if session.is_idle():
|
||||
idle_sessions.append(instance_id)
|
||||
|
||||
for instance_id in idle_sessions:
|
||||
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
|
||||
session = self._sessions.pop(instance_id, None)
|
||||
idle_keys.append((instance_id, session_id))
|
||||
|
||||
for key in idle_keys:
|
||||
instance_id, session_id = key
|
||||
logger.info(
|
||||
"Cleaning up idle terminal session %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Update DB status fire-and-forget
|
||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||
|
||||
async def _insert_db_session_row(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Insert a TerminalSessionModel row into the database."""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
db_row = TerminalSessionModel(
|
||||
id=uuid.UUID(session_id),
|
||||
instance_id=instance_id,
|
||||
name=name,
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(db_row)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Inserted terminal session row %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to insert terminal session row: %s", exc)
|
||||
|
||||
async def _mark_closed_in_db(self, session_id: str) -> None:
|
||||
"""Mark a terminal session as closed in the database."""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
db_row = await db_session.get(
|
||||
TerminalSessionModel, uuid.UUID(session_id)
|
||||
)
|
||||
if db_row:
|
||||
db_row.status = "closed"
|
||||
db_row.closed_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Marked terminal session %s as closed in DB", session_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
|
||||
|
||||
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
|
||||
"""Count active in-memory sessions for a given instance."""
|
||||
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Create a new terminal session for an instance.
|
||||
|
||||
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
|
||||
Inserts a DB row fire-and-forget.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
container_id: Docker container ID.
|
||||
startup_command: Optional startup command to run.
|
||||
name: Optional session name (auto-generated if omitted).
|
||||
|
||||
Returns:
|
||||
The newly created TerminalSession.
|
||||
|
||||
Raises:
|
||||
MaxSessionsExceededError: If the instance already has max sessions.
|
||||
"""
|
||||
instance_id_str = str(instance_id)
|
||||
|
||||
if (
|
||||
self._count_sessions_for_instance(instance_id_str)
|
||||
>= self.MAX_SESSIONS_PER_INSTANCE
|
||||
):
|
||||
raise MaxSessionsExceededError(
|
||||
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(
|
||||
session_id=session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name=name,
|
||||
)
|
||||
await session.start(startup_command=startup_command)
|
||||
|
||||
key = (instance_id_str, session_id)
|
||||
self._sessions[key] = session
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created terminal session %s for instance %s (name=%s)",
|
||||
session_id,
|
||||
instance_id,
|
||||
session.name,
|
||||
)
|
||||
return session
|
||||
|
||||
async def get_or_create_session(
|
||||
self,
|
||||
@@ -59,61 +191,146 @@ class TerminalManager:
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Get existing session or create a new one."""
|
||||
"""Get existing session or create a new one.
|
||||
|
||||
Backward-compatible alias that uses 'default' as the session_id.
|
||||
"""
|
||||
# Ensure idle check is running (lazy start)
|
||||
self._start_idle_check()
|
||||
|
||||
|
||||
instance_id_str = str(instance_id)
|
||||
|
||||
# Check for existing session
|
||||
if instance_id_str in self._sessions:
|
||||
session = self._sessions[instance_id_str]
|
||||
|
||||
key = (instance_id_str, "default")
|
||||
|
||||
# Check for existing default session
|
||||
if key in self._sessions:
|
||||
session = self._sessions[key]
|
||||
|
||||
# Check if session is still alive
|
||||
if session.is_alive():
|
||||
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||
logger.debug(
|
||||
"Reattaching to existing terminal session for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
return session
|
||||
else:
|
||||
# Session died, clean it up
|
||||
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||
logger.debug(
|
||||
"Existing session for instance %s is dead, cleaning up",
|
||||
instance_id,
|
||||
)
|
||||
await session.close()
|
||||
del self._sessions[instance_id_str]
|
||||
|
||||
# Create new session
|
||||
logger.info("Creating new terminal session for instance %s", instance_id)
|
||||
del self._sessions[key]
|
||||
|
||||
# Create new default session
|
||||
logger.info(
|
||||
"Creating new default terminal session for instance %s", instance_id
|
||||
)
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||
session = TerminalSession(
|
||||
session_id=session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name="Session 1",
|
||||
)
|
||||
await session.start(startup_command=startup_command)
|
||||
self._sessions[instance_id_str] = session
|
||||
|
||||
self._sessions[key] = session
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
def get_session(
|
||||
self,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
) -> TerminalSession | None:
|
||||
"""Lookup a session by composite key, or by internal session_id."""
|
||||
session = self._sessions.get((instance_id, session_id))
|
||||
if session is not None:
|
||||
return session
|
||||
# Fallback: search by internal TerminalSession.session_id
|
||||
for (iid, _sid), sess in self._sessions.items():
|
||||
if iid == instance_id and sess.session_id == session_id:
|
||||
return sess
|
||||
return None
|
||||
|
||||
def _find_key_by_internal_id(
|
||||
self,
|
||||
instance_id: str,
|
||||
internal_session_id: str,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Find the manager dict key for a session by its internal session_id."""
|
||||
for (iid, sid), session in self._sessions.items():
|
||||
if iid == instance_id and session.session_id == internal_session_id:
|
||||
return (iid, sid)
|
||||
return None
|
||||
|
||||
def get_sessions_for_instance(
|
||||
self,
|
||||
instance_id: str,
|
||||
) -> list[TerminalSession]:
|
||||
"""Return all in-memory sessions for a given instance."""
|
||||
return [
|
||||
session
|
||||
for (iid, _sid), session in self._sessions.items()
|
||||
if iid == instance_id
|
||||
]
|
||||
|
||||
async def close_session(
|
||||
self,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""Close a specific session and update its DB status."""
|
||||
key = (instance_id, session_id)
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Fire-and-forget DB update
|
||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||
logger.info(
|
||||
"Closed terminal session %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
|
||||
async def attach_websocket(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Attach a WebSocket to an existing session."""
|
||||
# Handle concurrent connections - close existing ones
|
||||
"""Attach a WebSocket to an existing session.
|
||||
|
||||
Closes existing WebSocket connections only for this specific session.
|
||||
"""
|
||||
# Handle concurrent connections - close existing ones within the same session
|
||||
if session.has_websockets():
|
||||
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||
logger.debug(
|
||||
"Closing existing WebSocket connections for session %s (instance %s)",
|
||||
session.session_id,
|
||||
session.instance_id,
|
||||
)
|
||||
for ws in list(session._websockets):
|
||||
try:
|
||||
await ws.close(code=4000, reason="New connection established")
|
||||
except Exception:
|
||||
pass
|
||||
pass # noqa: S110
|
||||
session._websockets.clear()
|
||||
|
||||
|
||||
# Attach new WebSocket
|
||||
session.attach_websocket(websocket)
|
||||
|
||||
|
||||
# Replay buffer
|
||||
buffer = session.get_buffer()
|
||||
if buffer:
|
||||
try:
|
||||
await websocket.send_bytes(buffer)
|
||||
except Exception:
|
||||
pass
|
||||
pass # noqa: S110
|
||||
|
||||
async def detach_websocket(
|
||||
self,
|
||||
@@ -128,23 +345,60 @@ class TerminalManager:
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
session_id: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Reset a session by killing it and creating a new one."""
|
||||
"""Reset a session by killing it and creating a new one.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
container_id: Docker container ID.
|
||||
startup_command: Optional startup command.
|
||||
session_id: Specific session to reset. If None, resets the default session.
|
||||
name: Optional name to preserve for the new session.
|
||||
|
||||
Returns:
|
||||
The newly created TerminalSession.
|
||||
"""
|
||||
instance_id_str = str(instance_id)
|
||||
|
||||
target_session_id = session_id or "default"
|
||||
key = (instance_id_str, target_session_id)
|
||||
|
||||
# Preserve old name if not provided
|
||||
old_name = name
|
||||
if old_name is None and key in self._sessions:
|
||||
old_name = self._sessions[key].name
|
||||
|
||||
# Close existing session if any
|
||||
if instance_id_str in self._sessions:
|
||||
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||
old_session = self._sessions.pop(instance_id_str)
|
||||
if key in self._sessions:
|
||||
logger.debug(
|
||||
"Resetting terminal session %s for instance %s",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
old_session = self._sessions.pop(key)
|
||||
await old_session.close()
|
||||
|
||||
# Create new session
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||
await session.start(startup_command=startup_command)
|
||||
self._sessions[instance_id_str] = session
|
||||
|
||||
return session
|
||||
# Fire-and-forget DB update for old session
|
||||
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
|
||||
|
||||
# Create new session preserving the same session_id slot
|
||||
new_session_id = str(uuid.uuid4())
|
||||
new_session = TerminalSession(
|
||||
session_id=new_session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name=old_name or ("Session 1" if target_session_id == "default" else None),
|
||||
)
|
||||
await new_session.start(startup_command=startup_command)
|
||||
self._sessions[key] = new_session
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
|
||||
)
|
||||
|
||||
return new_session
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Close all active sessions."""
|
||||
@@ -152,7 +406,7 @@ class TerminalManager:
|
||||
self._sessions.clear()
|
||||
for session in sessions:
|
||||
await session.close()
|
||||
|
||||
|
||||
if self._idle_check_task and not self._idle_check_task.done():
|
||||
self._idle_check_task.cancel()
|
||||
|
||||
|
||||
@@ -18,18 +18,28 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session connected to a docker container.
|
||||
|
||||
|
||||
Supports persistent sessions that survive WebSocket disconnections.
|
||||
Multiple WebSocket connections can attach/detach from the same session.
|
||||
"""
|
||||
|
||||
# Circular buffer size (10KB)
|
||||
BUFFER_SIZE = 10 * 1024
|
||||
|
||||
|
||||
# Idle timeout in seconds (30 minutes)
|
||||
IDLE_TIMEOUT = 30 * 60
|
||||
|
||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
|
||||
# Session number counter per instance_id for auto-naming
|
||||
_instance_counters: dict[str, int] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
@@ -38,37 +48,52 @@ class TerminalSession:
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = None
|
||||
|
||||
|
||||
# Circular buffer for output replay
|
||||
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||
self._buffer_size = 0
|
||||
|
||||
|
||||
# WebSocket connections
|
||||
self._websockets: set[Any] = set()
|
||||
|
||||
|
||||
# Activity tracking
|
||||
self.last_activity = time.time()
|
||||
|
||||
|
||||
# Terminal size
|
||||
self._cols = 80
|
||||
self._rows = 24
|
||||
|
||||
# Session metadata
|
||||
self.name = name or self._generate_name(str(instance_id))
|
||||
self.status: str = "active"
|
||||
|
||||
@classmethod
|
||||
def _generate_name(cls, instance_id: str) -> str:
|
||||
"""Generate an auto-incremented session name for the instance."""
|
||||
count = cls._instance_counters.get(instance_id, 0) + 1
|
||||
cls._instance_counters[instance_id] = count
|
||||
return f"Session {count}"
|
||||
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(self._cols, self._rows)
|
||||
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
||||
|
||||
logger.debug(
|
||||
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
|
||||
)
|
||||
|
||||
# Build the shell command
|
||||
if startup_command:
|
||||
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||
logger.debug(
|
||||
f"Using startup command for session {self.session_id}: {startup_command}"
|
||||
)
|
||||
else:
|
||||
shell_cmd = "bash -il"
|
||||
|
||||
|
||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||
# Using -it because the slave fd IS a TTY
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
@@ -85,11 +110,11 @@ class TerminalSession:
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
)
|
||||
|
||||
|
||||
# Close slave fd in parent process
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
|
||||
|
||||
self.last_activity = time.time()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
@@ -99,7 +124,7 @@ class TerminalSession:
|
||||
return
|
||||
# TIOCSWINSZ = 0x5414 on Linux
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||
@@ -127,7 +152,7 @@ class TerminalSession:
|
||||
"""Add data to circular buffer, maintaining size limit."""
|
||||
self._output_buffer.append(data)
|
||||
self._buffer_size += len(data)
|
||||
|
||||
|
||||
# Trim if exceeds max size
|
||||
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||
removed = self._output_buffer.popleft()
|
||||
@@ -152,16 +177,16 @@ class TerminalSession:
|
||||
if self._closed:
|
||||
logger.warning("Cannot resize: session is closed")
|
||||
return
|
||||
|
||||
|
||||
# Only resize if dimensions actually changed
|
||||
if cols == self._cols and rows == self._rows:
|
||||
return
|
||||
|
||||
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
|
||||
# Docker exec -it creates its own PTY inside the container,
|
||||
# so host PTY resize doesn't propagate to the container shell.
|
||||
# Send SIGWINCH to the docker exec process on the host.
|
||||
@@ -170,14 +195,19 @@ class TerminalSession:
|
||||
if self.process and self.process.pid:
|
||||
try:
|
||||
os.kill(self.process.pid, signal.SIGWINCH)
|
||||
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
|
||||
logger.debug(
|
||||
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
|
||||
)
|
||||
except ProcessLookupError:
|
||||
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
|
||||
logger.warning(
|
||||
f"docker exec process {self.process.pid} not found for session {self.session_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send SIGWINCH: {e}")
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the session by killing the process and clearing state."""
|
||||
self.status = "resetting"
|
||||
await self.close()
|
||||
self._closed = False
|
||||
self._output_buffer.clear()
|
||||
@@ -186,18 +216,20 @@ class TerminalSession:
|
||||
self.process = None
|
||||
self._master_fd = None
|
||||
self._slave_fd = None
|
||||
self.status = "active"
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session and cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self.status = "closed"
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
pass # noqa: S110
|
||||
self._master_fd = None
|
||||
|
||||
if self.process is not None:
|
||||
@@ -240,7 +272,7 @@ class TerminalSession:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
|
||||
|
||||
# Clean up dead sockets
|
||||
for ws in dead_sockets:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestTerminalWebSocketMultiSession:
|
||||
"""Tests for multi-session WebSocket routing."""
|
||||
|
||||
def test_specific_session_websocket_route_exists(self, client):
|
||||
"""The specific session WebSocket route should be registered."""
|
||||
# We can't easily test WebSocket without auth, but we can verify
|
||||
# the route exists by checking for a 403 (no auth cookie)
|
||||
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
||||
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
||||
assert response.status_code in (403, 404)
|
||||
|
||||
def test_default_session_alias_route_exists(self, client):
|
||||
"""The default session alias route should still exist."""
|
||||
response = client.get("/ws/tool-instances/test-instance/terminal")
|
||||
assert response.status_code in (403, 404)
|
||||
|
||||
|
||||
class TestTerminalRestApi:
|
||||
"""Tests for REST API endpoints."""
|
||||
|
||||
def test_list_sessions_requires_auth(self, client):
|
||||
"""List sessions endpoint requires authentication."""
|
||||
response = client.get("/instances/test/terminal/sessions")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_create_session_requires_auth(self, client):
|
||||
"""Create session endpoint requires authentication."""
|
||||
response = client.post(
|
||||
"/instances/test/terminal/sessions",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_close_session_requires_auth(self, client):
|
||||
"""Close session endpoint requires authentication."""
|
||||
response = client.delete("/instances/test/terminal/sessions/test-session")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_reset_session_requires_auth(self, client):
|
||||
"""Reset session endpoint requires authentication."""
|
||||
response = client.post("/instances/test/terminal/sessions/test-session/reset")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_rename_session_requires_auth(self, client):
|
||||
"""Rename session endpoint requires authentication."""
|
||||
response = client.post(
|
||||
"/instances/test/terminal/sessions/test-session/rename",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_legacy_reset_alias_requires_auth(self, client):
|
||||
"""Legacy reset endpoint still requires auth."""
|
||||
response = client.post("/instances/test/terminal/reset")
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Unit tests for TerminalManager multi-session support."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.terminal_manager import MaxSessionsExceededError, TerminalManager
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager() -> TerminalManager:
|
||||
"""Provide a fresh TerminalManager instance for each test."""
|
||||
tm = TerminalManager()
|
||||
# Cancel the background idle check to avoid side effects
|
||||
if tm._idle_check_task and not tm._idle_check_task.done():
|
||||
tm._idle_check_task.cancel()
|
||||
return tm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_terminal_session(monkeypatch) -> None:
|
||||
"""Monkeypatch TerminalSession.start and is_alive for unit tests."""
|
||||
|
||||
async def fake_start(self, startup_command=None):
|
||||
self.last_activity = __import__("time").time()
|
||||
|
||||
monkeypatch.setattr(TerminalSession, "start", fake_start)
|
||||
monkeypatch.setattr(TerminalSession, "is_alive", lambda self: True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
"""Minimal fake WebSocket for testing attach/detach behavior."""
|
||||
|
||||
def __init__(self, name: str = "ws") -> None:
|
||||
self.name = name
|
||||
self.closed = False
|
||||
self.close_code: int | None = None
|
||||
self.close_reason: str | None = None
|
||||
self._sent: list[bytes] = []
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
self.closed = True
|
||||
self.close_code = code
|
||||
self.close_reason = reason
|
||||
|
||||
async def send_bytes(self, data: bytes) -> None:
|
||||
self._sent.append(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_increases_count(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
instance_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Creating sessions increments the per-instance count."""
|
||||
assert len(manager.get_sessions_for_instance(str(instance_id))) == 0
|
||||
|
||||
session1 = await manager.create_session(instance_id, "container-1")
|
||||
assert len(manager.get_sessions_for_instance(str(instance_id))) == 1
|
||||
assert session1.session_id in [
|
||||
s.session_id for s in manager.get_sessions_for_instance(str(instance_id))
|
||||
]
|
||||
|
||||
session2 = await manager.create_session(instance_id, "container-1")
|
||||
assert len(manager.get_sessions_for_instance(str(instance_id))) == 2
|
||||
|
||||
# Verify sessions are distinct
|
||||
assert session1.session_id != session2.session_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_enforces_max_5(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
instance_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""The 6th session creation raises MaxSessionsExceededError."""
|
||||
for i in range(5):
|
||||
await manager.create_session(instance_id, f"container-{i}")
|
||||
|
||||
assert len(manager.get_sessions_for_instance(str(instance_id))) == 5
|
||||
|
||||
with pytest.raises(MaxSessionsExceededError):
|
||||
await manager.create_session(instance_id, "container-overflow")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_for_instance_filters_by_instance(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
) -> None:
|
||||
"""get_sessions_for_instance returns only sessions for the requested instance."""
|
||||
instance_a = uuid.uuid4()
|
||||
instance_b = uuid.uuid4()
|
||||
|
||||
await manager.create_session(instance_a, "container-a")
|
||||
await manager.create_session(instance_a, "container-a2")
|
||||
await manager.create_session(instance_b, "container-b")
|
||||
|
||||
assert len(manager.get_sessions_for_instance(str(instance_a))) == 2
|
||||
assert len(manager.get_sessions_for_instance(str(instance_b))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_session_removes_from_dict(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
instance_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""close_session removes the key from _sessions and marks DB closed."""
|
||||
session = await manager.create_session(instance_id, "container-1")
|
||||
session_id = session.session_id
|
||||
|
||||
assert manager.get_session(str(instance_id), session_id) is not None
|
||||
|
||||
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
|
||||
await manager.close_session(str(instance_id), session_id)
|
||||
# Give the fire-and-forget task a chance to be scheduled
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert manager.get_session(str(instance_id), session_id) is None
|
||||
mock_mark.assert_called_once_with(session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_websocket_only_closes_same_session(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
instance_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Attaching to session A must not close WebSockets on session B."""
|
||||
session_a = await manager.create_session(instance_id, "container-1")
|
||||
session_b = await manager.create_session(instance_id, "container-1")
|
||||
|
||||
ws_a1 = FakeWebSocket("ws-a1")
|
||||
ws_b1 = FakeWebSocket("ws-b1")
|
||||
|
||||
# Manually attach websockets (simulate prior connections)
|
||||
session_a.attach_websocket(ws_a1)
|
||||
session_b.attach_websocket(ws_b1)
|
||||
|
||||
# Now attach a new websocket to session_a
|
||||
ws_a2 = FakeWebSocket("ws-a2")
|
||||
await manager.attach_websocket(session_a, ws_a2)
|
||||
|
||||
# ws_a1 should have been closed because it's on the same session
|
||||
assert ws_a1.closed is True
|
||||
|
||||
# ws_b1 should NOT have been closed because it's on a different session
|
||||
assert ws_b1.closed is False
|
||||
|
||||
# ws_a2 should be attached and receive buffer
|
||||
assert ws_a2 in session_a._websockets
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_session_keyed_separately(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
instance_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Default session uses 'default' session_id and does not collide with named sessions."""
|
||||
default_session = await manager.get_or_create_session(instance_id, "container-1")
|
||||
explicit_session = await manager.create_session(instance_id, "container-1")
|
||||
|
||||
# Both should exist
|
||||
assert manager.get_session(str(instance_id), "default") is default_session
|
||||
assert (
|
||||
manager.get_session(str(instance_id), explicit_session.session_id)
|
||||
is explicit_session
|
||||
)
|
||||
|
||||
# They should be different objects
|
||||
assert default_session.session_id != explicit_session.session_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_cleanup_updates_db_status(
|
||||
manager: TerminalManager,
|
||||
mock_terminal_session,
|
||||
instance_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Idle cleanup removes sessions from dict and calls DB update."""
|
||||
session = await manager.create_session(instance_id, "container-1")
|
||||
session_id = session.session_id
|
||||
|
||||
# Make session appear idle (no websockets, old last_activity)
|
||||
session.last_activity = 0
|
||||
|
||||
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
|
||||
await manager._cleanup_idle_sessions()
|
||||
|
||||
assert manager.get_session(str(instance_id), session_id) is None
|
||||
mock_mark.assert_called_once_with(session_id)
|
||||
-1
File diff suppressed because one or more lines are too long
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": "v2",
|
||||
"timestamp": 1779892231625,
|
||||
"ruleHash": "0a2423849fae7580",
|
||||
"queries": [
|
||||
{
|
||||
"id": "dangerously-set-inner-html",
|
||||
"name": "Dangerously Set Inner HTML",
|
||||
"severity": "error",
|
||||
"language": "tsx",
|
||||
"message": "dangerouslySetInnerHTML — XSS risk, sanitize user input",
|
||||
"query": " (jsx_attribute\n (property_identifier) @ATTR\n (#match? @ATTR \"dangerouslySetInnerHTML\"))",
|
||||
"metavars": [
|
||||
"ATTR"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/dangerously-set-inner-html.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-nested-links",
|
||||
"name": "Nested anchor tags",
|
||||
"severity": "error",
|
||||
"language": "tsx",
|
||||
"message": "Nested <a> tags are invalid HTML and cause unexpected behavior",
|
||||
"query": " (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @OUTER\n (#eq? @OUTER \"a\"))\n (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @INNER\n (#eq? @INNER \"a\"))))",
|
||||
"metavars": [
|
||||
"OUTER",
|
||||
"INNER"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
{
|
||||
"version": "v2",
|
||||
"timestamp": 1779889832502,
|
||||
"ruleHash": "45ab8be323739a4e",
|
||||
"queries": [
|
||||
{
|
||||
"id": "console-statement",
|
||||
"name": "Console Statement",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "{{METHOD}} — remove debug statements before committing",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml"
|
||||
},
|
||||
{
|
||||
"id": "debugger-statement",
|
||||
"name": "Debugger Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Debugger statement — remove before committing",
|
||||
"query": " (debugger_statement) @DEBUGGER",
|
||||
"metavars": [
|
||||
"DEBUGGER"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/debugger.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-nesting",
|
||||
"name": "Deep Nesting",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
|
||||
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
|
||||
"metavars": [
|
||||
"IF_NESTED",
|
||||
"FOR_NESTED",
|
||||
"WHILE_NESTED",
|
||||
"TRY_NESTED",
|
||||
"IF_IN_FOR",
|
||||
"IF_IN_WHILE"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-nesting.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-promise-chain",
|
||||
"name": "Deep Promise Chain (4+ levels)",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
|
||||
"metavars": [
|
||||
"M1",
|
||||
"M2",
|
||||
"M3",
|
||||
"M4"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-promise-chain.yml"
|
||||
},
|
||||
{
|
||||
"id": "default-not-last",
|
||||
"name": "Default Clauses Should Be Last",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "default clause should be the last case",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
|
||||
"metavars": [
|
||||
"DEFAULT",
|
||||
"AFTER_CASE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/default-not-last.yml"
|
||||
},
|
||||
{
|
||||
"id": "duplicate-function-arg",
|
||||
"name": "Function Argument Names Should Be Unique",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Duplicate parameter name '{{NAME}}'",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
|
||||
"metavars": [
|
||||
"PARAM1",
|
||||
"PARAM2"
|
||||
],
|
||||
"post_filter": "same_param_name",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml"
|
||||
},
|
||||
{
|
||||
"id": "empty-switch-case",
|
||||
"name": "Switch Cases Should Not Be Empty",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should not be empty",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
|
||||
"metavars": [
|
||||
"BLOCK"
|
||||
],
|
||||
"post_filter": "is_empty_block",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/empty-switch-case.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-eval",
|
||||
"name": "Eval Usage",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "eval() detected — security risk, never use eval",
|
||||
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/eval.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-incomplete-assertion",
|
||||
"name": "Incomplete Test Assertion",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Incomplete assertion — expect() chain is not called",
|
||||
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
|
||||
"metavars": [
|
||||
"EXPECT",
|
||||
"EXPR"
|
||||
],
|
||||
"post_filter": "incomplete_assertion",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/incomplete-assertion.yml"
|
||||
},
|
||||
{
|
||||
"id": "infinite-loop",
|
||||
"name": "Loops Should Not Be Infinite",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Loop appears to be infinite with no termination condition",
|
||||
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "no_break_or_return_in_body",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/infinite-loop.yml"
|
||||
},
|
||||
{
|
||||
"id": "mixed-async-styles",
|
||||
"name": "Mixed Async/Await and Promise Chains",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Mixed async/await + promise chains — use consistent async style",
|
||||
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "has_mixed_async",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/mixed-async-styles.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-console-in-tests",
|
||||
"name": "Console Statement in Test",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "in_test_block",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/no-console-in-tests.yml"
|
||||
},
|
||||
{
|
||||
"id": "self-assignment",
|
||||
"name": "Variables Should Not Be Self-Assigned",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{{VAR}}' is assigned to itself",
|
||||
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
|
||||
"metavars": [
|
||||
"VAR",
|
||||
"SAME"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/self-assignment.yml"
|
||||
},
|
||||
{
|
||||
"id": "sql-injection",
|
||||
"name": "SQL Injection Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
|
||||
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
|
||||
"metavars": [
|
||||
"SQL_FUNC",
|
||||
"INTERPOLATION"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-case-termination",
|
||||
"name": "Switch Cases Should End With Terminating Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should end with break, return, throw, or continue",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
|
||||
"metavars": [
|
||||
"LAST",
|
||||
"NEXT"
|
||||
],
|
||||
"post_filter": "no_terminating_statement",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-case-termination.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-non-case-labels-ts",
|
||||
"name": "Switch Should Not Contain Non-Case Labels",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "switch statements should not contain non-case labels",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
|
||||
"metavars": [
|
||||
"LABEL",
|
||||
"LABELED"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-command-injection",
|
||||
"name": "Command Injection Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"NS",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_command_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-command-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-detached-async-call",
|
||||
"name": "Detached Async Call",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
|
||||
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_detached_async_call",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-dynamic-require",
|
||||
"name": "Dynamic Require Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
|
||||
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-hallucinated-react-import",
|
||||
"name": "Hallucinated React Import",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
|
||||
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"SRC"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"SRC": "^['\\\"]react['\\\"]$",
|
||||
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-insecure-random",
|
||||
"name": "Insecure Randomness",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
|
||||
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARGS",
|
||||
"VAR"
|
||||
],
|
||||
"post_filter": "ts_insecure_random_source",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-insecure-random.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-nosql-injection",
|
||||
"name": "NoSQL Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
|
||||
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
|
||||
"metavars": [
|
||||
"KEY"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-open-redirect",
|
||||
"name": "Open Redirect",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL",
|
||||
"WIN",
|
||||
"LOC",
|
||||
"PROP",
|
||||
"VALUE"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-open-redirect.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-react-antipatterns",
|
||||
"name": "React Anti-Pattern",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
|
||||
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"defect_class": "logic-error",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-ssrf",
|
||||
"name": "SSRF Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
|
||||
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL"
|
||||
],
|
||||
"post_filter": "ts_ssrf_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-ssrf.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-weak-hash",
|
||||
"name": "Weak Hash Primitive",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
|
||||
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ALG"
|
||||
],
|
||||
"post_filter": "ts_weak_hash_algorithm",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-weak-hash.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-xss-dom-sink",
|
||||
"name": "XSS DOM Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
|
||||
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
|
||||
"metavars": [
|
||||
"PROP",
|
||||
"VALUE",
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml"
|
||||
},
|
||||
{
|
||||
"id": "unsafe-regex",
|
||||
"name": "Dynamic Regex Construction",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
|
||||
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
|
||||
"metavars": [
|
||||
"CTOR",
|
||||
"INTERPOLATION",
|
||||
"PATTERN"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/unsafe-regex.yml"
|
||||
},
|
||||
{
|
||||
"id": "variable-shadowing",
|
||||
"name": "Variable Shadowing",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
|
||||
"metavars": [
|
||||
"PARAM",
|
||||
"NAME"
|
||||
],
|
||||
"post_filter": "name_matches_param",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/variable-shadowing.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
has_websockets: boolean;
|
||||
created_at: string;
|
||||
last_activity_at: string | null;
|
||||
}
|
||||
|
||||
export interface TerminalSessionListResponse {
|
||||
sessions: TerminalSession[];
|
||||
}
|
||||
|
||||
export interface TerminalSessionCreateRequest {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSessionCreateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listTerminalSessions(
|
||||
instanceId: string,
|
||||
): Promise<TerminalSession[]> {
|
||||
const response = await apiClient.get(
|
||||
`/instances/${instanceId}/terminal/sessions`,
|
||||
);
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export async function createTerminalSession(
|
||||
instanceId: string,
|
||||
name?: string,
|
||||
): Promise<TerminalSessionCreateResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/instances/${instanceId}/terminal/sessions`,
|
||||
{ name },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function closeTerminalSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; session_id: string }> {
|
||||
const response = await apiClient.delete(
|
||||
`/instances/${instanceId}/terminal/sessions/${sessionId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function resetTerminalSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ id: string; name: string; status: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/instances/${instanceId}/terminal/sessions/${sessionId}/reset`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function renameTerminalSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
|
||||
{ name },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "./terminal-session-tabs";
|
||||
|
||||
const mockSessions: TerminalSessionInfo[] = [
|
||||
{ id: "s1", name: "Session 1", status: "connected" },
|
||||
{ id: "s2", name: "Session 2", status: "connecting" },
|
||||
{ id: "s3", name: "Session 3", status: "disconnected" },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TerminalSessionTabs", () => {
|
||||
it("renders all tabs", () => {
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Session 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a tab calls onSelect", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByText("Session 2")[0]);
|
||||
expect(onSelect).toHaveBeenCalledWith("s2");
|
||||
});
|
||||
|
||||
it("close button calls onClose after confirmation", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close session Session 1");
|
||||
// First click shows confirm
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.getByText("Close?")).toBeInTheDocument();
|
||||
|
||||
// Click confirm text
|
||||
fireEvent.click(screen.getByText("Close?"));
|
||||
expect(onClose).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("double-click enables rename and Enter commits", () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
|
||||
const input = screen.getByLabelText("Rename session");
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
|
||||
});
|
||||
|
||||
it("double-click enables rename and Escape cancels", () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
|
||||
const input = screen.getByLabelText("Rename session");
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Session 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plus button is disabled at 5 sessions", () => {
|
||||
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `s${i + 1}`,
|
||||
name: `Session ${i + 1}`,
|
||||
status: "connected",
|
||||
}));
|
||||
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={fiveSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const newButton = screen.getByRole("button", { name: /new session/i });
|
||||
expect(newButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("status dot reflects connection state", () => {
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(3);
|
||||
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
|
||||
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
|
||||
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
}
|
||||
|
||||
export interface TerminalSessionTabsProps {
|
||||
sessions: TerminalSessionInfo[];
|
||||
activeSessionId: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onClose: (sessionId: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (sessionId: string, newName: string) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onCreate,
|
||||
onRename,
|
||||
isMobile = false,
|
||||
}) => {
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
|
||||
setRenamingId(session.id);
|
||||
setRenameValue(session.name);
|
||||
requestAnimationFrame(() => {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
onRename(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, [renamingId, renameValue, onRename]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
commitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
},
|
||||
[commitRename, cancelRename],
|
||||
);
|
||||
|
||||
const handleCloseClick = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirmCloseId === sessionId) {
|
||||
setConfirmCloseId(null);
|
||||
onClose(sessionId);
|
||||
} else {
|
||||
setConfirmCloseId(sessionId);
|
||||
// Auto-dismiss confirm after 3s
|
||||
setTimeout(() => {
|
||||
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
[confirmCloseId, onClose],
|
||||
);
|
||||
|
||||
const isMaxSessions = sessions.length >= 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
|
||||
{sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const isConfirmingClose = confirmCloseId === session.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-session-tab ${isActive ? "active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onDoubleClick={() => handleDoubleClick(session)}
|
||||
title={isRenaming ? "" : `${session.name} (${session.status})`}
|
||||
>
|
||||
<span
|
||||
className={`terminal-session-tab-status ${session.status}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="terminal-session-tab-input"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={commitRename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Rename session"
|
||||
/>
|
||||
) : (
|
||||
<span className="terminal-session-tab-name">
|
||||
{session.name}
|
||||
</span>
|
||||
)}
|
||||
{isConfirmingClose ? (
|
||||
<button
|
||||
className="terminal-session-tab-confirm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmCloseId(null);
|
||||
onClose(session.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Close?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="terminal-session-tab-close"
|
||||
onClick={(e) => handleCloseClick(e, session.id)}
|
||||
type="button"
|
||||
aria-label={`Close session ${session.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="terminal-session-tab new-session"
|
||||
onClick={onCreate}
|
||||
disabled={isMaxSessions}
|
||||
type="button"
|
||||
aria-label="New session"
|
||||
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listTerminalSessions,
|
||||
createTerminalSession,
|
||||
closeTerminalSession,
|
||||
resetTerminalSession,
|
||||
renameTerminalSession,
|
||||
type TerminalSession,
|
||||
} from "../api/terminal";
|
||||
|
||||
export interface UseTerminalSessionsResult {
|
||||
sessions: TerminalSession[];
|
||||
activeSessionId: string | null;
|
||||
setActiveSessionId: (id: string) => void;
|
||||
createSession: (name?: string) => Promise<TerminalSession | null>;
|
||||
closeSession: (sessionId: string) => Promise<void>;
|
||||
renameSession: (sessionId: string, name: string) => Promise<void>;
|
||||
resetSession: (sessionId: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useTerminalSessions(
|
||||
instanceId: string,
|
||||
): UseTerminalSessionsResult {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sess = await listTerminalSessions(instanceId);
|
||||
setSessions(sess);
|
||||
if (sess.length > 0 && !activeSessionId) {
|
||||
setActiveSessionId(sess[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load sessions");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, activeSessionId]);
|
||||
|
||||
const createSession = useCallback(
|
||||
async (name?: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const newSession = await createTerminalSession(instanceId, name);
|
||||
const session: TerminalSession = {
|
||||
id: newSession.id,
|
||||
name: newSession.name,
|
||||
status: newSession.status,
|
||||
has_websockets: false,
|
||||
created_at: newSession.created_at,
|
||||
last_activity_at: null,
|
||||
};
|
||||
setSessions((prev) => [...prev, session]);
|
||||
setActiveSessionId(session.id);
|
||||
return session;
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Failed to create session";
|
||||
setError(msg);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
const closeSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await closeTerminalSession(instanceId, sessionId);
|
||||
setSessions((prev) => {
|
||||
const filtered = prev.filter((s) => s.id !== sessionId);
|
||||
if (activeSessionId === sessionId && filtered.length > 0) {
|
||||
setActiveSessionId(filtered[0].id);
|
||||
} else if (filtered.length === 0) {
|
||||
setActiveSessionId(null);
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to close session",
|
||||
);
|
||||
}
|
||||
},
|
||||
[instanceId, activeSessionId],
|
||||
);
|
||||
|
||||
const renameSession = useCallback(
|
||||
async (sessionId: string, name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await renameTerminalSession(instanceId, sessionId, name);
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === sessionId ? { ...s, name } : s)),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to rename session",
|
||||
);
|
||||
}
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
const resetSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await resetTerminalSession(instanceId, sessionId);
|
||||
// Refetch to get updated session info
|
||||
await loadSessions();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to reset session",
|
||||
);
|
||||
}
|
||||
},
|
||||
[instanceId, loadSessions],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
+309
-42
@@ -1,50 +1,317 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { TerminalComponent } from "../components/terminal";
|
||||
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
|
||||
import { TerminalComponent, type TerminalRef } from "../components/terminal";
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "../components/terminal-session-tabs";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
||||
import type { TerminalSession } from "../api/terminal";
|
||||
|
||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as TerminalSessionInfo["status"],
|
||||
}));
|
||||
|
||||
export const TerminalPage: React.FC = () => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const { instanceId } = useParams<{
|
||||
instanceId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileTerminalWrapper
|
||||
instanceId={instanceId}
|
||||
onBack={() => navigate(-1)}
|
||||
onClose={() => navigate(-1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Auto-create default session if none exist
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
|
||||
return (
|
||||
<section className="terminal-page">
|
||||
<div className="terminal-page-header">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={() => navigate(-1)}
|
||||
isMobile={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
// Ensure refs map is kept in sync with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
// Clean up refs for closed sessions
|
||||
const currentIds = new Set(sessions.map((s) => s.id));
|
||||
for (const id of Object.keys(terminalRefs.current)) {
|
||||
if (!currentIds.has(id)) {
|
||||
delete terminalRefs.current[id];
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Fit active terminal when switching tabs
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
// Small delay to allow display:block to apply
|
||||
const timer = setTimeout(() => {
|
||||
ref.current?.fit();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
if (sessions.length < 5) {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}
|
||||
break;
|
||||
case "w":
|
||||
e.preventDefault();
|
||||
if (
|
||||
activeSessionId &&
|
||||
window.confirm("Close this terminal session?")
|
||||
) {
|
||||
void closeSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) {
|
||||
setActiveSessionId(sessions[idx - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1) {
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
void resetSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
resetSession,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// Exit fullscreen on Escape
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
},
|
||||
[setActiveSessionId],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (sessionId: string) => {
|
||||
await closeSession(sessionId);
|
||||
},
|
||||
[closeSession],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}, [createSession, sessions.length]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newName: string) => {
|
||||
void renameSession(sessionId, newName);
|
||||
},
|
||||
[renameSession],
|
||||
);
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<section
|
||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={() => headerAutoHide.show()}
|
||||
>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
type="button"
|
||||
>
|
||||
{isFullscreen ? "Exit" : "Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={() => headerAutoHide.show()}
|
||||
>
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
|
||||
style={{
|
||||
display: session.id === activeSessionId ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
|
||||
{!isFullscreen && (
|
||||
<div className="terminal-page-header">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
type="button"
|
||||
title="Toggle fullscreen (Alt+Shift+F)"
|
||||
>
|
||||
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={false}
|
||||
/>
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
|
||||
style={{
|
||||
display: session.id === activeSessionId ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
+2627
-2331
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user