diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index 08d53f4..bcf48d0 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -300,11 +300,18 @@ impl Engine { // installed with the real per-call resolver. The resolver owns // `cx.clone()` so cross-app isolation derives from this exact // call's context, not from any script-passed argument. + // The lexical origin for `import` resolution (§5.5): the top-level + // script's defining node. Falls back to the executing app when the + // request predates Phase 4b (old payloads / cluster wire). + let default_origin = req + .script_owner + .unwrap_or(picloud_shared::ScriptOwner::App(req.app_id)); let resolver = PicloudModuleResolver::new( self.services.modules.clone(), cx.clone(), self.module_cache.clone(), effective_limits.module_import_depth_max, + default_origin, ); engine.set_module_resolver(resolver); let self_engine = self.self_arc(); diff --git a/crates/executor-core/src/module_resolver.rs b/crates/executor-core/src/module_resolver.rs index f6cddc5..e5a09b2 100644 --- a/crates/executor-core/src/module_resolver.rs +++ b/crates/executor-core/src/module_resolver.rs @@ -26,7 +26,9 @@ use std::sync::{Arc, Mutex}; use chrono::{DateTime, Utc}; use lru::LruCache; -use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript}; +use picloud_shared::{ + AppId, ModuleSource, ModuleSourceError, ScriptOwner, SdkCallCx, ValidatedScript, +}; use rhai::module_resolvers::ModuleResolver; use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST}; @@ -96,6 +98,12 @@ pub struct PicloudModuleResolver { /// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at /// resolver construction. depth_limit: u32, + + /// Lexical origin for a top-level `import` — the defining node of the + /// script being executed (Phase 4b, §5.5). Used when Rhai gives no + /// `_source` (the entry AST). Nested imports inside a resolved module + /// override this via the module AST's source (C3). + default_origin: ScriptOwner, } impl PicloudModuleResolver { @@ -105,6 +113,7 @@ impl PicloudModuleResolver { cx: Arc, cache: Arc, depth_limit: u32, + default_origin: ScriptOwner, ) -> Self { Self { source, @@ -113,6 +122,7 @@ impl PicloudModuleResolver { in_progress: Mutex::new(Vec::new()), depth: Mutex::new(0), depth_limit, + default_origin, } } @@ -319,19 +329,14 @@ impl ModuleResolver for PicloudModuleResolver { )) })?; - // C1 (Phase 4b): resolve via the owner-aware `resolve`. Until C3 - // threads the importing script's true defining node through, the - // origin is the calling app — behaviour-preserving for app-owned - // scripts (group scripts can't yet carry imports). The app-rooted - // chain walk additionally lets an app import an ancestor group's - // modules, which is the intended Phase 4b capability. + // Lexical resolution (§5.5): resolve `path` against the importing + // node's chain. For the entry script that's `default_origin` (its + // defining node); nested-import origin via `_source` lands in C3. + // An app-rooted walk reaches ancestor group modules; a group-rooted + // walk is sealed from app modules below it (the trust boundary). + let origin = self.default_origin; let lookup_result: Result, ModuleSourceError> = - tokio::task::block_in_place(|| { - handle.block_on( - self.source - .resolve(picloud_shared::ScriptOwner::App(self.cx.app_id), path), - ) - }); + tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path))); let module_row = match lookup_result { Ok(Some(m)) => m, diff --git a/crates/executor-core/src/sdk/invoke.rs b/crates/executor-core/src/sdk/invoke.rs index 5bf9cf2..2668410 100644 --- a/crates/executor-core/src/sdk/invoke.rs +++ b/crates/executor-core/src/sdk/invoke.rs @@ -179,6 +179,10 @@ fn invoke_blocking( rest: String::new(), sandbox_overrides: picloud_shared::ScriptSandbox::default(), app_id: cx.app_id, + // Lexical origin (§5.5): the callee's defining node — a group + // script's imports resolve from the group even when invoked by an + // app. `None` falls back to `App(cx.app_id)` in the engine. + script_owner: resolved.owner, // Same-app invoke is a function call, not a re-auth boundary — // inherit the caller's principal. principal: cx.principal.clone(), diff --git a/crates/executor-core/src/types.rs b/crates/executor-core/src/types.rs index 736fe1d..750c133 100644 --- a/crates/executor-core/src/types.rs +++ b/crates/executor-core/src/types.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use chrono::{DateTime, Utc}; use picloud_shared::{ AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId, - ScriptId, ScriptSandbox, TriggerEvent, + ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent, }; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -69,6 +69,16 @@ pub struct ExecRequest { /// Internal-only; not surfaced via `ctx` (which the script sees). pub app_id: AppId, + /// The **defining node** of the top-level script being executed — + /// the resolved `Script`'s owner (Phase 4b). This is the lexical + /// origin its `import` statements resolve against (§5.5): an inherited + /// group endpoint's imports seal to the **group**, not the inheriting + /// app, so a leaf can't shadow them. Distinct from `app_id`, which is + /// always the execution boundary (where SDK calls scope). `None` (old + /// payloads / cluster wire) falls back to `App(app_id)` in the engine. + #[serde(default)] + pub script_owner: Option, + /// Caller identity, when authenticated. `None` for unauthenticated /// data-plane HTTP requests (the common case for public scripts); /// `Some` when a bearer token or session cookie was resolved. diff --git a/crates/executor-core/tests/engine.rs b/crates/executor-core/tests/engine.rs index eb57f4e..5dd35ff 100644 --- a/crates/executor-core/tests/engine.rs +++ b/crates/executor-core/tests/engine.rs @@ -23,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id: AppId::new(), + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/module_redaction_logging.rs b/crates/executor-core/tests/module_redaction_logging.rs index 71f135c..4a34255 100644 --- a/crates/executor-core/tests/module_redaction_logging.rs +++ b/crates/executor-core/tests/module_redaction_logging.rs @@ -74,6 +74,7 @@ fn req(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/modules.rs b/crates/executor-core/tests/modules.rs index 312f369..ce7993c 100644 --- a/crates/executor-core/tests/modules.rs +++ b/crates/executor-core/tests/modules.rs @@ -137,6 +137,7 @@ fn req(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_contract.rs b/crates/executor-core/tests/sdk_contract.rs index e5183d3..3fa6812 100644 --- a/crates/executor-core/tests/sdk_contract.rs +++ b/crates/executor-core/tests/sdk_contract.rs @@ -51,6 +51,7 @@ fn baseline_request() -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id: AppId::new(), + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 9feb03a..69f7a42 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -257,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_email.rs b/crates/executor-core/tests/sdk_email.rs index 160441d..5948a90 100644 --- a/crates/executor-core/tests/sdk_email.rs +++ b/crates/executor-core/tests/sdk_email.rs @@ -66,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_files.rs b/crates/executor-core/tests/sdk_files.rs index 68a29c6..8293c84 100644 --- a/crates/executor-core/tests/sdk_files.rs +++ b/crates/executor-core/tests/sdk_files.rs @@ -194,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs index 7c175b8..7b66ecc 100644 --- a/crates/executor-core/tests/sdk_http.rs +++ b/crates/executor-core/tests/sdk_http.rs @@ -117,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_invoke.rs b/crates/executor-core/tests/sdk_invoke.rs index 67468ce..3e2f927 100644 --- a/crates/executor-core/tests/sdk_invoke.rs +++ b/crates/executor-core/tests/sdk_invoke.rs @@ -58,6 +58,7 @@ impl InvokeService for FakeInvokeService { Ok(ResolvedScript { script_id: id, app_id: app, + owner: None, source, updated_at: Utc::now(), name, @@ -114,6 +115,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index 6b3b059..c68ea37 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -136,6 +136,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_pubsub.rs b/crates/executor-core/tests/sdk_pubsub.rs index 56daabe..c0eac03 100644 --- a/crates/executor-core/tests/sdk_pubsub.rs +++ b/crates/executor-core/tests/sdk_pubsub.rs @@ -74,6 +74,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_queue.rs b/crates/executor-core/tests/sdk_queue.rs index aaea046..b8e6a32 100644 --- a/crates/executor-core/tests/sdk_queue.rs +++ b/crates/executor-core/tests/sdk_queue.rs @@ -88,6 +88,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_retry.rs b/crates/executor-core/tests/sdk_retry.rs index 116698b..9cf950c 100644 --- a/crates/executor-core/tests/sdk_retry.rs +++ b/crates/executor-core/tests/sdk_retry.rs @@ -52,6 +52,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_secrets.rs b/crates/executor-core/tests/sdk_secrets.rs index 4a5815b..55c1d61 100644 --- a/crates/executor-core/tests/sdk_secrets.rs +++ b/crates/executor-core/tests/sdk_secrets.rs @@ -127,6 +127,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs index 50309d1..84e29ed 100644 --- a/crates/executor-core/tests/sdk_subscriber_token.rs +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -121,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, + script_owner: None, principal: with_principal.then(|| Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Owner, diff --git a/crates/executor-core/tests/stdlib.rs b/crates/executor-core/tests/stdlib.rs index b19a10b..7dc3707 100644 --- a/crates/executor-core/tests/stdlib.rs +++ b/crates/executor-core/tests/stdlib.rs @@ -37,6 +37,7 @@ fn baseline_request() -> ExecRequest { rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id: AppId::new(), + script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs index ab3a2cb..d1d3412 100644 --- a/crates/manager-core/src/dispatcher.rs +++ b/crates/manager-core/src/dispatcher.rs @@ -31,7 +31,7 @@ use picloud_orchestrator_core::{ExecutionGate, ExecutorClient}; use picloud_shared::{ AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource, HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, - RequestId, Script, ScriptId, ScriptSandbox, TriggerEvent, + RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent, }; use rand::Rng; use uuid::Uuid; @@ -471,6 +471,7 @@ impl Dispatcher { rest: String::new(), sandbox_overrides: script.sandbox, app_id: claimed.app_id, + script_owner: script.owner(), principal: Some(principal), trigger_depth: 0, root_execution_id: execution_id, @@ -830,6 +831,7 @@ impl Dispatcher { is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter), active: trigger.enabled && script.enabled, script_id: script.id, + script_owner: script.owner(), script_source: script.source, script_name: script.name, script_updated_at: script.updated_at, @@ -872,6 +874,7 @@ impl Dispatcher { rest: String::new(), sandbox_overrides: resolved.sandbox_overrides, app_id: row.app_id, + script_owner: resolved.script_owner, principal: Some(principal), trigger_depth: row.trigger_depth, root_execution_id: row.root_execution_id.unwrap_or(execution_id), @@ -935,6 +938,7 @@ impl Dispatcher { rest: payload.rest, sandbox_overrides: script.sandbox, app_id: row.app_id, + script_owner: script.owner(), // HTTP outbox rows don't run as the trigger registrant — // they run with no principal (public ingress) or the // attached one (origin_principal forensic field is not @@ -953,6 +957,7 @@ impl Dispatcher { // enqueue is dropped at fire time by the post-match active check. active: script.enabled, script_id, + script_owner: script.owner(), script_source: script.source, script_name: payload.script_name, script_updated_at: script.updated_at, @@ -1035,6 +1040,7 @@ impl Dispatcher { rest: String::new(), sandbox_overrides: script.sandbox, app_id: row.app_id, + script_owner: script.owner(), // invoke_async runs with no principal — same as HTTP outbox. principal: None, trigger_depth, @@ -1054,6 +1060,7 @@ impl Dispatcher { // enqueue is dropped at fire time by the post-match active check. active: script.enabled, script_id: script.id, + script_owner: script.owner(), script_source: script.source, script_name: script.name, script_updated_at: script.updated_at, @@ -1356,6 +1363,10 @@ pub struct ResolvedTrigger { pub script_id: ScriptId, pub script_source: String, pub script_name: String, + /// Phase 4b: the resolved script's defining node — the lexical origin + /// for its `import`s (§5.5). `None` only for a corrupt owner row; the + /// executor then falls back to `App(app_id)`. + pub script_owner: Option, /// v1.1.3: freshness comparator for the orchestrator's top-level /// script cache. The dispatcher hands `(script_id, updated_at)` /// in alongside the source so cached ASTs can be reused across diff --git a/crates/manager-core/src/invoke_service.rs b/crates/manager-core/src/invoke_service.rs index 5b9af81..6ab0ad2 100644 --- a/crates/manager-core/src/invoke_service.rs +++ b/crates/manager-core/src/invoke_service.rs @@ -95,6 +95,7 @@ impl InvokeServiceImpl { Ok(ResolvedScript { script_id: script.id, app_id: cx.app_id, + owner: script.owner(), source: script.source, updated_at: script.updated_at, name: script.name, @@ -124,6 +125,9 @@ impl InvokeServiceImpl { // The execution-context app is always the caller's app — a group // script runs under the inheriting app, never its owner. app_id: cx.app_id, + // …but its `import`s resolve from its *defining node* (the group + // for an inherited script) — the lexical origin (§5.5). + owner: script.owner(), source: script.source, updated_at: script.updated_at, name: script.name, diff --git a/crates/orchestrator-core/src/api.rs b/crates/orchestrator-core/src/api.rs index bf15cc4..e5109c5 100644 --- a/crates/orchestrator-core/src/api.rs +++ b/crates/orchestrator-core/src/api.rs @@ -135,6 +135,7 @@ where let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?; req.sandbox_overrides = script.sandbox; + req.script_owner = script.owner(); let request_id = req.request_id; let request_path = req.path.clone(); let request_headers = req.headers.clone(); @@ -628,6 +629,10 @@ fn build_exec_request( // Overwritten by the handler after the script is resolved. sandbox_overrides: picloud_shared::ScriptSandbox::default(), app_id, + // Set by the handler from the resolved script's owner. The + // id-bypass runs app-owned scripts only (group scripts 404 here), + // so this is always `App(app_id)`; `None` falls back to that. + script_owner: None, principal, // Direct invocations are at depth 0 with a self-referential // root. The triggers framework (v1.1.1) increments depth and diff --git a/crates/shared/src/invoke.rs b/crates/shared/src/invoke.rs index 3fab55e..6c1e64b 100644 --- a/crates/shared/src/invoke.rs +++ b/crates/shared/src/invoke.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use thiserror::Error; -use crate::{AppId, ExecutionId, ScriptId, SdkCallCx}; +use crate::{AppId, ExecutionId, ScriptId, ScriptOwner, SdkCallCx}; /// Identifies the script to invoke. Accepted by `invoke()` and /// `invoke_async()` script-side. @@ -52,6 +52,12 @@ impl InvokeTarget { pub struct ResolvedScript { pub script_id: ScriptId, pub app_id: AppId, + /// The resolved script's **defining node** (Phase 4b) — the lexical + /// origin its `import`s resolve against (§5.5). For an inherited group + /// script this is the **group**, even though `app_id` (the execution + /// boundary) is the caller's app. `None` only for a corrupt owner row; + /// the executor falls back to `App(app_id)`. + pub owner: Option, pub source: String, pub updated_at: DateTime, /// Script.name — surfaced to the callee as `ctx.script_name`. diff --git a/crates/shared/src/script.rs b/crates/shared/src/script.rs index 846bc4f..14c83ae 100644 --- a/crates/shared/src/script.rs +++ b/crates/shared/src/script.rs @@ -152,7 +152,7 @@ pub struct Script { /// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the /// in-memory witness of that invariant so call sites can `match` exhaustively /// instead of juggling two `Option`s. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ScriptOwner { App(AppId), Group(GroupId),