1717from __future__ import annotations
1818
1919import asyncio
20+ import base64
21+ import json
22+ import re
2023import time
2124from typing import Any , Awaitable , Callable , Dict , List , Optional , Set , Tuple
25+ from urllib .parse import urlsplit
2226
2327import httpx
2428
3135 detect_connector_add_type ,
3236 is_full_commit_sha ,
3337)
38+ from openviking .crypto .encryptor import MAGIC as ENCRYPTED_ENVELOPE_MAGIC
3439from openviking .parse .mode import ParseMode
3540from openviking .resource .processing_mode import (
3641 DEFAULT_PROCESSING_MODE ,
4449
4550logger = get_logger (__name__ )
4651
52+ _TOS_BUCKET_PATTERN = re .compile (r"^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$" )
53+
54+
55+ def _validate_tos_uri (
56+ value : Any ,
57+ field : str ,
58+ * ,
59+ allow_bucket_without_slash : bool = False ,
60+ ) -> None :
61+ """Validate a Connector TOS URI without exposing it in client errors."""
62+ error = InvalidArgumentError (f"{ field } must be a valid TOS URI." )
63+ if (
64+ not isinstance (value , str )
65+ or value != value .strip ()
66+ or not value .startswith ("tos://" )
67+ or any (
68+ char in "?#%" or ord (char ) < 0x20 or ord (char ) == 0x7F
69+ for char in value
70+ )
71+ ):
72+ raise error
73+
74+ try :
75+ parsed = urlsplit (value )
76+ hostname = parsed .hostname
77+ port = parsed .port
78+ except ValueError :
79+ raise error from None
80+ if (
81+ parsed .scheme != "tos"
82+ or parsed .username is not None
83+ or parsed .password is not None
84+ or port is not None
85+ or not hostname
86+ or hostname != parsed .netloc
87+ or not _TOS_BUCKET_PATTERN .fullmatch (hostname )
88+ or (not parsed .path and not allow_bucket_without_slash )
89+ or (parsed .path and not parsed .path .startswith ("/" ))
90+ or parsed .path .startswith ("//" )
91+ or parsed .query
92+ or parsed .fragment
93+ ):
94+ raise error
95+
4796
4897class ConnectorDelegate :
4998 """Routes add_resource requests to the external Connector service.
@@ -71,6 +120,113 @@ def __init__(
71120 self ._background_tasks = background_tasks
72121 self ._link_reason_memory = link_reason_memory
73122
123+ _WATCH_AUTH_PROVIDER = "connector_encrypted"
124+ _WATCH_PLAINTEXT_AUTH_PROVIDER = "connector_plaintext"
125+
126+ def _watch_encryptor (self ) -> Any :
127+ encryptor = getattr (self ._viking_fs , "_encryptor" , None )
128+ if encryptor is None :
129+ raise InvalidArgumentError (
130+ "Connector watch requires encryption.enabled=true so credentials are "
131+ "encrypted at rest."
132+ )
133+ return encryptor
134+
135+ async def create_watch_auth_state (
136+ self ,
137+ * ,
138+ api_key : str ,
139+ account_id : str ,
140+ add_type : str ,
141+ path : str ,
142+ connector_args : Optional [Dict [str , Any ]],
143+ ) -> Dict [str , Any ]:
144+ """Build the private request state needed to replay a Connector watch."""
145+ if not api_key :
146+ raise InvalidArgumentError ("Connector watch requires an API key." )
147+ payload = {
148+ "api_key" : api_key ,
149+ "account_id" : account_id ,
150+ "add_type" : add_type ,
151+ "path" : path ,
152+ "connector_args" : dict (connector_args or {}),
153+ }
154+ encryptor = getattr (self ._viking_fs , "_encryptor" , None )
155+ if encryptor is None :
156+ return {
157+ "provider" : self ._WATCH_PLAINTEXT_AUTH_PROVIDER ,
158+ "request" : payload ,
159+ }
160+ try :
161+ plaintext = json .dumps (
162+ payload ,
163+ ensure_ascii = False ,
164+ separators = ("," , ":" ),
165+ ).encode ("utf-8" )
166+ ciphertext = await encryptor .encrypt (account_id , plaintext )
167+ except InvalidArgumentError :
168+ raise
169+ except Exception as exc :
170+ raise InvalidArgumentError ("Failed to encrypt Connector watch credentials." ) from exc
171+ return {
172+ "provider" : self ._WATCH_AUTH_PROVIDER ,
173+ "ciphertext" : base64 .b64encode (ciphertext ).decode ("ascii" ),
174+ }
175+
176+ @classmethod
177+ def is_watch_auth_state (cls , auth_state : Optional [Dict [str , Any ]]) -> bool :
178+ return (
179+ isinstance (auth_state , dict )
180+ and auth_state .get ("provider" )
181+ in {cls ._WATCH_AUTH_PROVIDER , cls ._WATCH_PLAINTEXT_AUTH_PROVIDER }
182+ )
183+
184+ async def restore_watch_request (
185+ self ,
186+ auth_state : Dict [str , Any ],
187+ * ,
188+ account_id : str ,
189+ path : str ,
190+ ) -> Tuple [str , str , Dict [str , Any ]]:
191+ """Restore and validate a source-bound Connector watch request."""
192+ try :
193+ provider = auth_state .get ("provider" )
194+ if provider == self ._WATCH_PLAINTEXT_AUTH_PROVIDER :
195+ payload = auth_state .get ("request" )
196+ elif provider == self ._WATCH_AUTH_PROVIDER :
197+ encoded = auth_state .get ("ciphertext" )
198+ if not isinstance (encoded , str ) or not encoded :
199+ raise ValueError ("missing ciphertext" )
200+ ciphertext = base64 .b64decode (encoded , validate = True )
201+ if not ciphertext .startswith (ENCRYPTED_ENVELOPE_MAGIC ):
202+ raise ValueError ("invalid encrypted envelope" )
203+ plaintext = await self ._watch_encryptor ().decrypt (account_id , ciphertext )
204+ payload = json .loads (plaintext .decode ("utf-8" ))
205+ else :
206+ raise ValueError ("unknown Connector watch provider" )
207+ if (
208+ not isinstance (payload , dict )
209+ or payload .get ("account_id" ) != account_id
210+ or payload .get ("path" ) != path
211+ ):
212+ raise ValueError ("watch binding mismatch" )
213+ api_key = payload .get ("api_key" )
214+ add_type = payload .get ("add_type" )
215+ connector_args = payload .get ("connector_args" )
216+ if (
217+ not isinstance (api_key , str )
218+ or not api_key
219+ or not isinstance (add_type , str )
220+ or not add_type
221+ or not isinstance (connector_args , dict )
222+ ):
223+ raise ValueError ("invalid watch request" )
224+ return api_key , add_type , dict (connector_args )
225+ except InvalidArgumentError :
226+ raise
227+ except Exception as exc :
228+ raise InvalidArgumentError ("Stored Connector watch credentials are invalid." ) from exc
229+
74230 @staticmethod
75231 def resolve_add_type (path : str , declared_add_type : Optional [str ]) -> Optional [Tuple [str , bool ]]:
76232 """Resolve the Connector ``(add_type, connector_only)`` for *path*.
@@ -100,6 +256,14 @@ def resolve_add_type(path: str, declared_add_type: Optional[str]) -> Optional[Tu
100256 )
101257 return (declared_add_type , True )
102258
259+ @classmethod
260+ def supported_args (cls , path : str , declared_add_type : Optional [str ]) -> Set [str ]:
261+ """Connector-owned ``args`` fields for the resolved source type."""
262+ resolved = cls .resolve_add_type (path , declared_add_type )
263+ if resolved is None :
264+ return set ()
265+ return set (CONNECTOR_SUPPORTED_ARGS .get (resolved [0 ], frozenset ()))
266+
103267 def should_delegate (
104268 self ,
105269 path : str ,
@@ -211,7 +375,7 @@ def should_delegate(
211375 f"standard import pipeline. Connector import does not support: { detail } "
212376 )
213377 logger .info (
214- f"[ConnectorDelegate] Connector does not support { detail } for path { path } ; "
378+ f"[ConnectorDelegate] Connector does not support { detail } ; "
215379 "falling back to the standard import pipeline"
216380 )
217381 return False
@@ -247,8 +411,6 @@ def _unsupported_params(
247411 unsupported .append ("missing exact 'to' target" )
248412 elif to != "viking://resources" and not to .startswith ("viking://resources/" ):
249413 unsupported .append ("to outside the public resources root (viking://resources/...)" )
250- if watch_interval > 0 :
251- unsupported .append ("watch_interval>0 (Connector imports cannot be watched yet)" )
252414 if instruction :
253415 unsupported .append ("instruction" )
254416 if not build_index :
@@ -260,6 +422,8 @@ def _unsupported_params(
260422 if kwargs .get ("strict" ):
261423 unsupported .append ("strict=true (Connector imports fail per file, not all-or-nothing)" )
262424 for field in ("ignore_dirs" , "include" , "exclude" ):
425+ if field == "exclude" and add_type == "tos" and field in connector_args :
426+ continue
263427 if kwargs .get (field ):
264428 unsupported .append (f"{ field } (Connector imports cannot filter the source tree)" )
265429 if kwargs .get ("preserve_structure" ) is False :
@@ -301,6 +465,8 @@ async def submit(
301465 connector_args : Optional [Dict [str , Any ]] = None ,
302466 tags : Optional [List [str ]] = None ,
303467 tag_mode : str = "replace" ,
468+ wait_for_completion : bool = False ,
469+ on_success : Optional [Callable [[], Awaitable [None ]]] = None ,
304470 ** kwargs : Any ,
305471 ) -> Dict [str , Any ]:
306472 """Route add_resource to the external Connector service."""
@@ -315,6 +481,8 @@ async def submit(
315481 if resolved is None :
316482 raise InvalidArgumentError (f"'{ path } ' does not match any Connector source type." )
317483 add_type , _ = resolved
484+ if add_type == "tos" :
485+ _validate_tos_uri (path , "path" , allow_bucket_without_slash = True )
318486
319487 task_resource_id = to or ""
320488 if not task_resource_id :
@@ -369,12 +537,34 @@ async def submit(
369537 tos_path : Optional [str ] = None
370538 param_config : Optional [Dict [str , Any ]] = None
371539 if add_type == "tos" :
372- source_path = path [len ("tos://" ) :].strip ()
373- if not source_path :
374- raise InvalidArgumentError (
375- "Connector TOS import requires path='tos://<bucket>/<path>'."
376- )
377- tos_path = source_path
540+ tos_args = connector_args or {}
541+ if "tos_prefix" not in tos_args :
542+ if "exclude" in tos_args :
543+ raise InvalidArgumentError ("args.exclude requires args.tos_prefix." )
544+ source_path = path [len ("tos://" ) :].strip ()
545+ if not source_path :
546+ raise InvalidArgumentError (
547+ "Connector TOS import requires path='tos://<bucket>/<path>'."
548+ )
549+ tos_path = source_path
550+ else :
551+ tos_prefix = tos_args ["tos_prefix" ]
552+ if not isinstance (tos_prefix , list ) or not tos_prefix :
553+ raise InvalidArgumentError (
554+ "args.tos_prefix must be a non-empty list of TOS URIs."
555+ )
556+ for index , source in enumerate (tos_prefix ):
557+ _validate_tos_uri (source , f"args.tos_prefix[{ index } ]" )
558+ if tos_prefix [0 ] != path :
559+ raise InvalidArgumentError ("path must equal the first item in args.tos_prefix." )
560+ exclude = tos_args .get ("exclude" , [])
561+ if not isinstance (exclude , list ):
562+ raise InvalidArgumentError ("args.exclude must be a list of TOS URIs." )
563+ for index , source in enumerate (exclude ):
564+ _validate_tos_uri (source , f"args.exclude[{ index } ]" )
565+ param_config = {"tos_prefix" : tos_prefix }
566+ if exclude :
567+ param_config ["exclude" ] = exclude
378568 elif add_type == "git" :
379569 from openviking .parse .accessors .git_accessor import GitAccessor
380570
@@ -462,19 +652,23 @@ async def submit(
462652 ctx = ctx ,
463653 reason = reason ,
464654 link_root_uri = task_resource_id or "viking://resources" ,
655+ on_success = on_success ,
465656 )
466657
467- background = asyncio .create_task (monitor )
468- self ._background_tasks .add (background )
469- background .add_done_callback (self ._background_tasks .discard )
470-
471658 response = {
472659 "status" : "accepted" ,
473660 "task_id" : task .task_id ,
474661 "connector_task_key" : connector_task_key ,
475662 }
476663 if task_resource_id :
477664 response ["resource_id" ] = task_resource_id
665+ if wait_for_completion :
666+ response .update (await monitor )
667+ return response
668+
669+ background = asyncio .create_task (monitor )
670+ self ._background_tasks .add (background )
671+ background .add_done_callback (self ._background_tasks .discard )
478672 return response
479673
480674 async def _monitor (
@@ -487,6 +681,7 @@ async def _monitor(
487681 ctx : RequestContext ,
488682 reason : str = "" ,
489683 link_root_uri : str = "" ,
684+ on_success : Optional [Callable [[], Awaitable [None ]]] = None ,
490685 ) -> Dict [str , Any ]:
491686 """Poll the Connector task until terminal state, then update OV TaskRecord.
492687
@@ -523,10 +718,10 @@ async def _monitor(
523718 f"for { connector_task_key } : { status_code } ; retrying"
524719 )
525720 continue
526- except httpx .RequestError as exc :
721+ except httpx .RequestError :
527722 logger .warning (
528723 "[ConnectorDelegate] Transient Connector task polling error "
529- f"for { connector_task_key } : { exc } ; retrying"
724+ f"for { connector_task_key } ; retrying"
530725 )
531726 continue
532727 status = (info .get ("Status" ) or info .get ("status" ) or "" ).lower ()
@@ -544,6 +739,8 @@ async def _monitor(
544739 "connector_status" : status ,
545740 "connector_task_key" : connector_task_key ,
546741 }
742+ if on_success is not None :
743+ await on_success ()
547744 if (reason or "" ).strip () and link_root_uri :
548745 link_result : Dict [str , Any ] = {"root_uri" : link_root_uri }
549746 await self ._link_reason_memory (
@@ -590,11 +787,15 @@ async def _monitor(
590787 )
591788 raise
592789 except Exception as exc :
593- logger .error (f"[ConnectorDelegate] Connector task monitor error: { exc } " )
790+ failure = "connector task monitoring failed"
791+ logger .error (
792+ "[ConnectorDelegate] Connector task monitor error, error_type=%s" ,
793+ type (exc ).__name__ ,
794+ )
594795 await task_tracker .fail (
595796 ov_task_id ,
596- str ( exc ) ,
797+ failure ,
597798 account_id = ctx .account_id ,
598799 user_id = ctx .user .user_id ,
599800 )
600- return {"status" : "failed" , "error" : str ( exc ) }
801+ return {"status" : "failed" , "error" : failure }
0 commit comments