feat(executor): thread the defining node into ExecRequest (Phase 4b C2)

Carry the resolved script's owner (its defining node) from dispatch into
the executor so `import` resolution can be lexical (§5.5). The executing
app (`app_id`) stays the SDK isolation boundary; `script_owner` is a
separate axis — the lexical origin for imports.

- `ExecRequest.script_owner: Option<ScriptOwner>` (serde default; `None`
  falls back to `App(app_id)` in the engine for old payloads / cluster wire).
- `ScriptOwner` gains `Serialize`/`Deserialize` for the wire.
- The engine computes `default_origin` and hands it to the resolver
  (used fully in C3; the resolve() lookup already uses it).
- Every dispatch site sets it from the resolved `Script.owner()`:
  the 4 dispatcher arms (queue/trigger/http/invoke_async, via a new
  `ResolvedTrigger.script_owner`), the orchestrator id-bypass, and the
  `invoke()` SDK path (new `ResolvedScript.owner`, so a group script
  invoked by an app resolves imports from the group).

Behaviour-preserving: app scripts still resolve `App(app_id)`; group
scripts can't yet carry imports (lifted in C4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-26 07:17:33 +02:00
parent 7fef098b48
commit 1844bef132
25 changed files with 86 additions and 17 deletions

View File

@@ -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();

View File

@@ -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<SdkCallCx>,
cache: Arc<ModuleCache>,
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<Option<picloud_shared::ModuleScript>, 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,

View File

@@ -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(),

View File

@@ -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<ScriptOwner>,
/// 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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,