feat: add plugin websocket host api
This commit is contained in:
parent
3eac7f8eae
commit
4c1b8c3d0a
|
|
@ -2,7 +2,7 @@
|
|||
title: 'Plugin: URL 権限ベースの WebSocket host API を実装する'
|
||||
state: 'inprogress'
|
||||
created_at: '2026-06-21T07:11:34Z'
|
||||
updated_at: '2026-06-21T11:35:59Z'
|
||||
updated_at: '2026-06-21T12:18:54Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle', 'permissions', 'security', 'persistence']
|
||||
|
|
|
|||
|
|
@ -185,4 +185,38 @@ Validation plan:
|
|||
|
||||
Human authorized queue routing from Workspace Dashboard. Previously recorded dependency on `00001KVMG8FTW` is now satisfied because `host_api.request` is closed/integrated, and this Ticket now has implementation-ready WebSocket host API requirements. Orchestrator accepts implementation.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-06-21T12:18:54Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implemented a separate `host_api.websocket` Plugin host API foundation.
|
||||
|
||||
Summary:
|
||||
- Added independent manifest and enablement `websocket` URL target/grant schema with `host_api.websocket` permission naming.
|
||||
- Added static inspection/CLI diagnostics for requested/granted/missing/grant-only/broad WebSocket targets independently from request targets.
|
||||
- Added host-owned WebSocket handles for raw Wasm and component imports: open, send_text/send-text, recv, close.
|
||||
- Runtime enforces manifest+grant URL allowlist before network I/O; checks ws/wss scheme, host, port, path prefix, embedded credentials, and unsupported handshake headers/subprotocols.
|
||||
- Added bounded text-only send/recv behavior, connection-count/handle-lifetime/message-size/timeouts, and cleanup on close/instance stop/drop.
|
||||
- Kept `host_api.request` WebSocket/SSE rejection intact.
|
||||
- Documented WIT and plugin development guidance including no hidden context/history injection and future SecretRef credential injection.
|
||||
|
||||
Validation run:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p manifest websocket`
|
||||
- `cargo test -p pod websocket`
|
||||
- `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`
|
||||
- `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`
|
||||
- `cargo check -p manifest -p pod -p yoi`
|
||||
- `git diff --check`
|
||||
- stale/boundary grep for request/WebSocket docs and hidden context claims
|
||||
- `cargo run -p yoi -- ticket doctor` -> `doctor: ok`
|
||||
|
||||
Dependency/package notes:
|
||||
- Added sync `tungstenite` dependency to `crates/pod` with `native-tls`, `handshake`, and `url` features.
|
||||
- Updated `Cargo.lock` and `package.nix` `cargoHash` to `sha256-TZrw6nJclXVRpFIUlYvimGTDXlxBMaQt6oM5C5DIGIU=`.
|
||||
- `nix build .#yoi --no-link` advanced past the cargo hash mismatch after updating the hash, then failed in the sandbox while compiling `aws-lc-sys` due `No space left on device`; this appears environmental rather than a Rust/package hash error.
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -876,6 +876,12 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
|
|
@ -2903,6 +2909,7 @@ dependencies = [
|
|||
"toml",
|
||||
"tools",
|
||||
"tracing",
|
||||
"tungstenite",
|
||||
"uuid",
|
||||
"wasmi",
|
||||
"wasmtime",
|
||||
|
|
@ -3903,6 +3910,17 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -4696,6 +4714,25 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.9.4",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type1-encoding-parser"
|
||||
version = "0.1.1"
|
||||
|
|
|
|||
|
|
@ -152,13 +152,18 @@ pub struct PluginGrantConfig {
|
|||
pub permissions: Vec<PluginPermission>,
|
||||
/// Bounded outbound request allowlist entries for `host_api.request`.
|
||||
pub request: Vec<PluginRequestGrant>,
|
||||
/// Bounded outbound WebSocket target allowlist entries for `host_api.websocket`.
|
||||
pub websocket: Vec<PluginWebSocketGrant>,
|
||||
/// Scoped filesystem allowlist entries for `host_api.fs`.
|
||||
pub fs: Vec<PluginFsGrant>,
|
||||
}
|
||||
|
||||
impl PluginGrantConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.permissions.is_empty() && self.request.is_empty() && self.fs.is_empty()
|
||||
self.permissions.is_empty()
|
||||
&& self.request.is_empty()
|
||||
&& self.websocket.is_empty()
|
||||
&& self.fs.is_empty()
|
||||
}
|
||||
|
||||
pub fn binding_error(
|
||||
|
|
@ -261,6 +266,50 @@ impl PluginRequestGrant {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginWebSocketGrant {
|
||||
/// Exact URL scheme allowed by this WebSocket target: `wss` or `ws`; `*` is broad.
|
||||
pub scheme: String,
|
||||
/// Exact WebSocket host allowed by this target. `*` is broad and must be surfaced in diagnostics.
|
||||
pub host: String,
|
||||
/// Optional exact port. `None` means the scheme default or any explicit port for that host.
|
||||
pub port: Option<u16>,
|
||||
/// Optional path prefixes allowed for this target. Empty means any absolute path on the host.
|
||||
pub path_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginWebSocketGrant {
|
||||
pub fn label(&self) -> String {
|
||||
let scheme = if self.scheme.trim().is_empty() {
|
||||
"<no-scheme>"
|
||||
} else {
|
||||
self.scheme.as_str()
|
||||
};
|
||||
let host = if self.host.trim().is_empty() {
|
||||
"<no-host>"
|
||||
} else {
|
||||
self.host.as_str()
|
||||
};
|
||||
let port = self.port.map(|port| format!(":{port}")).unwrap_or_default();
|
||||
let paths = if self.path_prefixes.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
self.path_prefixes.join(",")
|
||||
};
|
||||
let broad = if self.is_broad() {
|
||||
" [broad-websocket]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{scheme}://{host}{port} {paths}{broad}")
|
||||
}
|
||||
|
||||
pub fn is_broad(&self) -> bool {
|
||||
self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginFsGrant {
|
||||
|
|
@ -347,6 +396,8 @@ impl PluginPermission {
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginHostApi {
|
||||
Request,
|
||||
#[serde(rename = "websocket")]
|
||||
WebSocket,
|
||||
Fs,
|
||||
}
|
||||
|
||||
|
|
@ -354,6 +405,7 @@ impl fmt::Display for PluginHostApi {
|
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Request => f.write_str("request"),
|
||||
Self::WebSocket => f.write_str("websocket"),
|
||||
Self::Fs => f.write_str("fs"),
|
||||
}
|
||||
}
|
||||
|
|
@ -480,6 +532,10 @@ pub struct PluginPackageManifest {
|
|||
/// enablement grants must explicitly approve matching targets.
|
||||
#[serde(default)]
|
||||
pub request: Vec<PluginRequestGrant>,
|
||||
/// Manifest-declared URL targets for `host_api.websocket`. These are independent from
|
||||
/// `host_api.request` targets and require independent enablement grants.
|
||||
#[serde(default)]
|
||||
pub websocket: Vec<PluginWebSocketGrant>,
|
||||
}
|
||||
|
||||
impl PluginPackageManifest {
|
||||
|
|
@ -3190,6 +3246,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
|||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
};
|
||||
let resolution = resolve_enabled_plugins(
|
||||
|
|
@ -3217,6 +3274,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
|||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
PluginGrantConfig {
|
||||
|
|
@ -3225,6 +3283,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
|||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
PluginGrantConfig {
|
||||
|
|
@ -3233,6 +3292,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
|||
digest: Some("sha256:unrelated".to_string()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
] {
|
||||
|
|
@ -3449,4 +3509,75 @@ kind = "ambient_shell"
|
|||
fn write_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_manifest_and_grants_parse_independently_from_request() {
|
||||
let manifest: PluginPackageManifest = toml::from_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
id = "project:example"
|
||||
name = "example"
|
||||
version = "1.0.0"
|
||||
surfaces = ["tool"]
|
||||
|
||||
[runtime]
|
||||
kind = "wasm"
|
||||
entry = "plugin.wasm"
|
||||
abi = "yoi-plugin-wasm-1"
|
||||
|
||||
[[permissions]]
|
||||
kind = "host_api"
|
||||
api = "request"
|
||||
|
||||
[[permissions]]
|
||||
kind = "host_api"
|
||||
api = "websocket"
|
||||
|
||||
[[request]]
|
||||
scheme = "https"
|
||||
host = "api.example.com"
|
||||
methods = ["GET"]
|
||||
path_prefixes = ["/v1"]
|
||||
|
||||
[[websocket]]
|
||||
scheme = "wss"
|
||||
host = "gateway.example.com"
|
||||
path_prefixes = ["/gateway"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(manifest.request.len(), 1);
|
||||
assert_eq!(manifest.websocket.len(), 1);
|
||||
assert_eq!(
|
||||
manifest.request[0].label(),
|
||||
"https://api.example.com GET /v1"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.websocket[0].label(),
|
||||
"wss://gateway.example.com /gateway"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.permissions[1],
|
||||
PluginPermission::host_api(PluginHostApi::WebSocket)
|
||||
);
|
||||
|
||||
let grants: PluginGrantConfig = toml::from_str(
|
||||
r#"
|
||||
[[request]]
|
||||
scheme = "https"
|
||||
host = "api.example.com"
|
||||
methods = ["GET"]
|
||||
path_prefixes = ["/v1"]
|
||||
|
||||
[[websocket]]
|
||||
scheme = "wss"
|
||||
host = "gateway.example.com"
|
||||
path_prefixes = ["/gateway"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(grants.request.len(), 1);
|
||||
assert_eq!(grants.websocket.len(), 1);
|
||||
assert!(!grants.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ session-metrics = { workspace = true }
|
|||
arc-swap = "1.9.1"
|
||||
wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] }
|
||||
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
|
||||
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
|
||||
|
||||
[dev-dependencies]
|
||||
dotenv = "0.15.0"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5379,6 +5379,7 @@ permission = "read"
|
|||
ingresses: vec![],
|
||||
permissions: vec![],
|
||||
request: vec![],
|
||||
websocket: vec![],
|
||||
},
|
||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ fn inspect_materialized_package(
|
|||
digest: Some(materialized.package.digest.clone()),
|
||||
permissions: requested_permissions,
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -802,6 +803,11 @@ fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
|
|||
" configured_request_grants: {}",
|
||||
join_or_none(&item.configured_request_grants)
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" configured_websocket_grants: {}",
|
||||
join_or_none(&item.configured_websocket_grants)
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" configured_fs_grants: {}",
|
||||
|
|
@ -977,6 +983,7 @@ fn snapshot_from_resolution(
|
|||
builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied());
|
||||
builder.configured_grants = permission_strings(&enablement.grants.permissions);
|
||||
builder.configured_request_grants = request_grant_strings(&enablement.grants.request);
|
||||
builder.configured_websocket_grants = websocket_grant_strings(&enablement.grants.websocket);
|
||||
builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs);
|
||||
if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) {
|
||||
builder
|
||||
|
|
@ -1070,6 +1077,7 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
|
|||
builder.requested_permissions = permission_strings(&resolved.manifest.permissions);
|
||||
builder.configured_grants = permission_strings(&resolved.grants.permissions);
|
||||
builder.configured_request_grants = request_grant_strings(&resolved.grants.request);
|
||||
builder.configured_websocket_grants = websocket_grant_strings(&resolved.grants.websocket);
|
||||
builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs);
|
||||
|
||||
let record = ResolvedPluginRecord::from_resolved(resolved);
|
||||
|
|
@ -1185,6 +1193,13 @@ fn request_grant_strings(grants: &[manifest::plugin::PluginRequestGrant]) -> Vec
|
|||
values
|
||||
}
|
||||
|
||||
fn websocket_grant_strings(grants: &[manifest::plugin::PluginWebSocketGrant]) -> Vec<String> {
|
||||
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn fs_grant_strings(grants: &[manifest::plugin::PluginFsGrant]) -> Vec<String> {
|
||||
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
|
||||
values.sort();
|
||||
|
|
@ -1263,6 +1278,7 @@ struct PluginInspectionItem {
|
|||
requested_permissions: Vec<String>,
|
||||
configured_grants: Vec<String>,
|
||||
configured_request_grants: Vec<String>,
|
||||
configured_websocket_grants: Vec<String>,
|
||||
configured_fs_grants: Vec<String>,
|
||||
tools: Vec<ToolSummary>,
|
||||
static_runtime: Option<PluginStaticInspection>,
|
||||
|
|
@ -1332,6 +1348,7 @@ struct ItemBuilder {
|
|||
requested_permissions: Vec<String>,
|
||||
configured_grants: Vec<String>,
|
||||
configured_request_grants: Vec<String>,
|
||||
configured_websocket_grants: Vec<String>,
|
||||
configured_fs_grants: Vec<String>,
|
||||
tools: Vec<ToolSummary>,
|
||||
static_runtime: Option<PluginStaticInspection>,
|
||||
|
|
@ -1359,6 +1376,7 @@ impl ItemBuilder {
|
|||
requested_permissions: Vec::new(),
|
||||
configured_grants: Vec::new(),
|
||||
configured_request_grants: Vec::new(),
|
||||
configured_websocket_grants: Vec::new(),
|
||||
configured_fs_grants: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
static_runtime: None,
|
||||
|
|
@ -1431,6 +1449,7 @@ impl ItemBuilder {
|
|||
requested_permissions: self.requested_permissions,
|
||||
configured_grants: self.configured_grants,
|
||||
configured_request_grants: self.configured_request_grants,
|
||||
configured_websocket_grants: self.configured_websocket_grants,
|
||||
configured_fs_grants: self.configured_fs_grants,
|
||||
tools: self.tools,
|
||||
static_runtime: self.static_runtime,
|
||||
|
|
@ -1523,9 +1542,10 @@ mod tests {
|
|||
static_eligible: true,
|
||||
declared_surfaces: vec!["tool".to_string()],
|
||||
enabled_surfaces: vec!["tool".to_string()],
|
||||
requested_permissions: vec!["host_api.request".to_string()],
|
||||
configured_grants: vec!["host_api.request".to_string()],
|
||||
requested_permissions: vec!["host_api.request".to_string(), "host_api.websocket".to_string()],
|
||||
configured_grants: vec!["host_api.request".to_string(), "host_api.websocket".to_string()],
|
||||
configured_request_grants: vec!["*://* GET * [broad-request]".to_string()],
|
||||
configured_websocket_grants: vec!["*://* * [broad-websocket]".to_string()],
|
||||
configured_fs_grants: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
static_runtime: Some(PluginStaticInspection {
|
||||
|
|
@ -1556,6 +1576,27 @@ mod tests {
|
|||
.to_string(),
|
||||
),
|
||||
},
|
||||
PluginPermissionEligibility {
|
||||
permission: "host_api.websocket target wss://gateway.example.test /gateway"
|
||||
.to_string(),
|
||||
requested: true,
|
||||
granted: false,
|
||||
eligible: false,
|
||||
diagnostic: Some(
|
||||
"missing enabled WebSocket grant for manifest target".to_string(),
|
||||
),
|
||||
},
|
||||
PluginPermissionEligibility {
|
||||
permission: "host_api.websocket grant-only *://* * [broad-websocket]"
|
||||
.to_string(),
|
||||
requested: false,
|
||||
granted: true,
|
||||
eligible: false,
|
||||
diagnostic: Some(
|
||||
"enabled WebSocket grant has no matching manifest declaration; broad/arbitrary target"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
],
|
||||
tools: Vec::new(),
|
||||
services: Vec::new(),
|
||||
|
|
@ -1569,10 +1610,18 @@ mod tests {
|
|||
json["configured_request_grants"][0],
|
||||
"*://* GET * [broad-request]"
|
||||
);
|
||||
assert_eq!(
|
||||
json["configured_websocket_grants"][0],
|
||||
"*://* * [broad-websocket]"
|
||||
);
|
||||
let human = render_item_human(&item).unwrap();
|
||||
assert!(human.contains("configured_websocket_grants: *://* * [broad-websocket]"));
|
||||
assert!(human.contains("host_api.request target https://api.example.test"));
|
||||
assert!(human.contains("requested=true granted=true eligible=true"));
|
||||
assert!(human.contains("host_api.request grant *://*"));
|
||||
assert!(human.contains("host_api.websocket target wss://gateway.example.test"));
|
||||
assert!(human.contains("host_api.websocket grant-only *://*"));
|
||||
assert!(human.contains("missing enabled WebSocket grant"));
|
||||
assert!(human.contains("broad/arbitrary"));
|
||||
}
|
||||
|
||||
|
|
@ -1596,6 +1645,7 @@ mod tests {
|
|||
PluginPermission::service("svc"),
|
||||
],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -1650,6 +1700,7 @@ mod tests {
|
|||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -1668,6 +1719,7 @@ mod tests {
|
|||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -1786,6 +1838,7 @@ mod tests {
|
|||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -2321,6 +2374,7 @@ lifecycle = "host-managed"
|
|||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -2352,6 +2406,7 @@ lifecycle = "host-managed"
|
|||
digest: Some(digest),
|
||||
permissions,
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -2380,6 +2435,7 @@ lifecycle = "host-managed"
|
|||
digest: None,
|
||||
permissions,
|
||||
request: Vec::new(),
|
||||
websocket: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
|
|||
|
|
@ -335,6 +335,43 @@ path_prefixes = ["/v1/"]
|
|||
|
||||
Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected.
|
||||
|
||||
## `websocket` host API
|
||||
|
||||
The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Plugin code drives the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state.
|
||||
|
||||
Example manifest shape:
|
||||
|
||||
```toml
|
||||
permissions = [
|
||||
{ kind = "surface", surface = "tool" },
|
||||
{ kind = "tool", name = "gateway_step" },
|
||||
{ kind = "host_api", api = "websocket" },
|
||||
]
|
||||
|
||||
[[websocket]]
|
||||
scheme = "wss"
|
||||
host = "gateway.example.com"
|
||||
path_prefixes = ["/gateway"]
|
||||
```
|
||||
|
||||
Example enablement grant shape:
|
||||
|
||||
```toml
|
||||
[plugins.enabled.grants]
|
||||
permissions = [
|
||||
{ kind = "surface", surface = "tool" },
|
||||
{ kind = "tool", name = "gateway_step" },
|
||||
{ kind = "host_api", api = "websocket" },
|
||||
]
|
||||
|
||||
[[plugins.enabled.grants.websocket]]
|
||||
scheme = "wss"
|
||||
host = "gateway.example.com"
|
||||
path_prefixes = ["/gateway"]
|
||||
```
|
||||
|
||||
Yoi checks scheme (`ws`/`wss`), host, optional port, and path prefix against both declarations before opening the connection. Loopback/private/local targets are not ambient; they require explicit matching manifest and grant entries. Broad WebSocket targets such as `host = "*"` are reported as broad WebSocket diagnostics. v1 is text-only: `send-text` requires UTF-8, binary receive fails closed, guest-supplied handshake headers and embedded URL credentials are rejected, and SecretRef-based credential/header injection is future work. The host bounds open descriptors, text/message size, receive timeout, connection count, handle lifetime, and cleanup on close/instance stop/drop.
|
||||
|
||||
## `fs` host API
|
||||
|
||||
The `fs` host API is Plugin-scoped and grant-gated. Plugins do not inherit the Pod/workspace filesystem authority automatically.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-RER/UXd74C2VhPHAeF36u6ruNBg0oLnR4YeQ/zLag88=";
|
||||
cargoHash = "sha256-TZrw6nJclXVRpFIUlYvimGTDXlxBMaQt6oM5C5DIGIU=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ interface request {
|
|||
request: func(request-json: string) -> string;
|
||||
}
|
||||
|
||||
/// Grant-bound host-owned WebSocket API. Authority requires a manifest `host_api.websocket`
|
||||
/// target and an enablement grant; messages are delivered only by explicit bounded recv calls.
|
||||
/// v1 supports text messages only and rejects guest-supplied handshake headers.
|
||||
interface websocket {
|
||||
open: func(request-json: string) -> string;
|
||||
send-text: func(handle: u32, text: string) -> string;
|
||||
recv: func(handle: u32, timeout-ms: u32) -> string;
|
||||
close: func(handle: u32) -> string;
|
||||
}
|
||||
|
||||
/// Grant-bound filesystem host API. No ambient WASI filesystem is exposed.
|
||||
interface fs {
|
||||
read: func(request-json: string) -> string;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package yoi:plugin@1.0.0;
|
|||
|
||||
world instance {
|
||||
import yoi:host/request@1.0.0;
|
||||
import yoi:host/websocket@1.0.0;
|
||||
import yoi:host/fs@1.0.0;
|
||||
|
||||
export start: func(config-json: string) -> string;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package yoi:plugin@1.0.0;
|
|||
|
||||
world tool {
|
||||
import yoi:host/request@1.0.0;
|
||||
import yoi:host/websocket@1.0.0;
|
||||
import yoi:host/fs@1.0.0;
|
||||
|
||||
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user